diff --git a/packages/lexical-playground/__tests__/e2e/Ruby.spec.mjs b/packages/lexical-playground/__tests__/e2e/Ruby.spec.mjs new file mode 100644 index 00000000000..94f3641b23e --- /dev/null +++ b/packages/lexical-playground/__tests__/e2e/Ruby.spec.mjs @@ -0,0 +1,1059 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + moveLeft, + moveRight, + pressBackspace, + selectAll, + selectCharacters, +} from '../keyboardShortcuts/index.mjs'; +import { + assertHTML, + click, + copyToClipboard, + evaluate, + expect, + focusEditor, + html, + initialize, + pasteFromClipboard, + sleep, + test, + withExclusiveClipboardAccess, +} from '../utils/index.mjs'; + +async function insertRubyViaToolbar(page, annotation) { + await click(page, 'button[aria-label="Insert ruby annotation"]'); + const input = page.locator('.ruby-editor .ruby-input'); + await input.waitFor({state: 'visible', timeout: 1000}); + await input.fill(annotation); + await input.press('Enter'); + await sleep(50); + await focusEditor(page); + await sleep(50); +} + +async function getRubyNodes(page) { + return evaluate(page, () => { + const editor = window.lexicalEditor; + const result = []; + editor.read(() => { + for (const [, node] of editor.getEditorState()._nodeMap) { + if (node.getType() === 'ruby') { + result.push({ + annotation: node.getAnnotation(), + text: node.__text, + }); + } + } + }); + return result; + }); +} + +async function getSelectionInfo(page) { + return evaluate(page, () => { + const editor = window.lexicalEditor; + return editor.read(() => { + const sel = editor.getEditorState()._selection; + if (!sel || !sel.anchor) { + return null; + } + const anchorNode = editor.getEditorState()._nodeMap.get(sel.anchor.key); + const focusNode = editor.getEditorState()._nodeMap.get(sel.focus.key); + return { + anchor: { + offset: sel.anchor.offset, + text: anchorNode?.__text || '', + type: anchorNode?.getType() || '', + }, + focus: { + offset: sel.focus.offset, + text: focusNode?.__text || '', + type: focusNode?.getType() || '', + }, + isCollapsed: + sel.anchor.key === sel.focus.key && + sel.anchor.offset === sel.focus.offset, + }; + }); + }); +} + +async function setCursorAt(page, textContent, offset) { + await evaluate( + page, + ({text, off}) => { + window.lexicalEditor.update( + () => { + const root = window.lexicalEditor + .getEditorState() + ._nodeMap.get('root'); + for (const node of root.getAllTextNodes()) { + if (node.getTextContent() === text) { + node.select(off, off); + return; + } + } + throw new Error(`setCursorAt: no TextNode with content "${text}"`); + }, + {discrete: true}, + ); + }, + {off: offset, text: textContent}, + ); + await sleep(50); +} + +async function selectNodeText(page, textContent) { + await evaluate( + page, + text => { + window.lexicalEditor.update( + () => { + const root = window.lexicalEditor + .getEditorState() + ._nodeMap.get('root'); + for (const node of root.getAllTextNodes()) { + if (node.getTextContent() === text) { + node.select(0, node.getTextContentSize()); + return; + } + } + throw new Error(`selectNodeText: no TextNode with content "${text}"`); + }, + {discrete: true}, + ); + }, + textContent, + ); + await sleep(50); +} + +test.describe('Ruby', () => { + test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); + + test('Can insert a ruby annotation via toolbar', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + await page.keyboard.type('Hello'); + await selectCharacters(page, 'left', 5); + + await insertRubyViaToolbar(page, 'ハロー'); + + const rubies = await getRubyNodes(page); + expect(rubies).toHaveLength(1); + expect(rubies[0]).toEqual({annotation: 'ハロー', text: 'Hello'}); + + const hasRubyDOM = await evaluate(page, () => { + const root = document.querySelector('div[contenteditable="true"]'); + const inner = root.querySelector('[data-ruby-annotation]'); + return inner + ? { + annotation: inner.dataset.rubyAnnotation, + hasClass: inner.classList.contains('PlaygroundEditorTheme__ruby'), + } + : null; + }); + expect(hasRubyDOM).not.toBeNull(); + expect(hasRubyDOM.annotation).toBe('ハロー'); + expect(hasRubyDOM.hasClass).toBe(true); + }); + + test('Ruby DOM has wrapper span with inner annotated span', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + await page.keyboard.type('漢'); + await selectAll(page); + await insertRubyViaToolbar(page, 'かん'); + + const structure = await evaluate(page, () => { + const root = document.querySelector('div[contenteditable="true"]'); + const inner = root.querySelector('[data-ruby-annotation]'); + if (!inner) { + return null; + } + const wrapper = inner.parentElement; + return { + innerTagName: inner.tagName, + innerText: inner.textContent, + wrapperHasDataLexicalText: + wrapper.getAttribute('data-lexical-text') === 'true', + wrapperTagName: wrapper.tagName, + }; + }); + + expect(structure).not.toBeNull(); + expect(structure.wrapperTagName).toBe('SPAN'); + expect(structure.innerTagName).toBe('SPAN'); + expect(structure.wrapperHasDataLexicalText).toBe(true); + expect(structure.innerText).toBe('漢'); + }); + + test('Arrow left skips over ruby node', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // "ABC" → select "B" → ruby → "A" + ruby("B","び") + "C" + await page.keyboard.type('ABC'); + await moveLeft(page, 1); + await selectCharacters(page, 'left', 1); + await insertRubyViaToolbar(page, 'び'); + + // Place cursor at "C":0, ArrowLeft → skip ruby → "A":1 + await setCursorAt(page, 'C', 0); + await moveLeft(page, 1); + await sleep(50); + + const info = await getSelectionInfo(page); + expect(info).not.toBeNull(); + expect(info.anchor.type).toBe('text'); + expect(info.anchor.text).toBe('A'); + expect(info.anchor.offset).toBe(1); + }); + + test('Arrow right skips over ruby node', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // "ABC" → select "B" → ruby → "A" + ruby("B","び") + "C" + await page.keyboard.type('ABC'); + await moveLeft(page, 1); + await selectCharacters(page, 'left', 1); + await insertRubyViaToolbar(page, 'び'); + + // Place cursor at "A":1, ArrowRight → skip ruby → "C":0 + await setCursorAt(page, 'A', 1); + await moveRight(page, 1); + await sleep(50); + + const info = await getSelectionInfo(page); + expect(info).not.toBeNull(); + expect(info.anchor.type).toBe('text'); + expect(info.anchor.text).toBe('C'); + expect(info.anchor.offset).toBe(0); + }); + + test('Backspace at ruby boundary deletes ruby as atomic unit', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // "ABC" → select "B" → ruby → "A" + ruby("B","び") + "C" + await page.keyboard.type('ABC'); + await moveLeft(page, 1); + await selectCharacters(page, 'left', 1); + await insertRubyViaToolbar(page, 'び'); + + // Place caret at "C":0 (right after ruby boundary) + await setCursorAt(page, 'C', 0); + + // Backspace: prev sibling of "C" is ruby → delete it + await pressBackspace(page, 1); + await sleep(50); + + await assertHTML( + page, + html` +

+ AC +

+ `, + ); + }); + + test('Delete key at ruby boundary deletes ruby as atomic unit', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // "ABC" → select "B" → ruby → "A" + ruby("B","び") + "C" + await page.keyboard.type('ABC'); + await moveLeft(page, 1); + await selectCharacters(page, 'left', 1); + await insertRubyViaToolbar(page, 'び'); + + // Place caret at "A":1 (right before ruby boundary) + await setCursorAt(page, 'A', 1); + + // Delete: next sibling is ruby → delete it + await page.keyboard.press('Delete'); + await sleep(50); + + await assertHTML( + page, + html` +

+ AC +

+ `, + ); + }); + + test('Select-all and typing replaces ruby', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + await page.keyboard.type('XY'); + await selectCharacters(page, 'left', 1); + await insertRubyViaToolbar(page, 'わい'); + + await selectAll(page); + await sleep(50); + + await page.keyboard.type('Replaced'); + await sleep(50); + + await assertHTML( + page, + html` +

+ Replaced +

+ `, + ); + + const rubies = await getRubyNodes(page); + expect(rubies).toHaveLength(0); + }); + + test('Toggle ruby off removes annotation and restores plain text', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + await page.keyboard.type('Word'); + await selectAll(page); + await insertRubyViaToolbar(page, 'ワード'); + + let rubies = await getRubyNodes(page); + expect(rubies).toHaveLength(1); + + await selectAll(page); + await sleep(50); + await click(page, 'button[aria-label="Insert ruby annotation"]'); + await sleep(50); + + rubies = await getRubyNodes(page); + expect(rubies).toHaveLength(0); + + const textContent = await evaluate(page, () => { + const editor = window.lexicalEditor; + return editor.read(() => { + return editor.getEditorState()._nodeMap.get('root').getTextContent(); + }); + }); + expect(textContent).toContain('Word'); + }); + + test('Copy and paste preserves ruby annotation', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + await page.keyboard.type('Test'); + await selectAll(page); + await insertRubyViaToolbar(page, 'テスト'); + + await selectAll(page); + + await withExclusiveClipboardAccess(async () => { + const clipboard = await copyToClipboard(page); + + // Collapse selection to end before Enter (webkit keeps selection + // active after End, so ArrowRight is used to collapse first). + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Enter'); + await pasteFromClipboard(page, clipboard); + }); + + await sleep(100); + + const rubies = await getRubyNodes(page); + expect(rubies).toHaveLength(2); + expect(rubies[0]).toEqual({annotation: 'テスト', text: 'Test'}); + expect(rubies[1]).toEqual({annotation: 'テスト', text: 'Test'}); + }); + + test('Ruby node serializes correctly to JSON', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + await page.keyboard.type('漢字'); + await selectAll(page); + await insertRubyViaToolbar(page, 'かんじ'); + + const roundTrip = await evaluate(page, () => { + const editor = window.lexicalEditor; + let json; + editor.read(() => { + json = editor.getEditorState().toJSON(); + }); + const paragraph = json.root.children[0]; + const rubyJSON = paragraph.children.find(c => c.type === 'ruby'); + return rubyJSON + ? { + annotation: rubyJSON.annotation, + text: rubyJSON.text, + type: rubyJSON.type, + } + : null; + }); + + expect(roundTrip).not.toBeNull(); + expect(roundTrip.type).toBe('ruby'); + expect(roundTrip.text).toBe('漢字'); + expect(roundTrip.annotation).toBe('かんじ'); + }); + + test('Multiple adjacent ruby nodes are independent', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + await page.keyboard.type('AB'); + await evaluate(page, () => { + window.lexicalEditor.update( + () => { + const root = window.lexicalEditor + .getEditorState() + ._nodeMap.get('root'); + for (const node of root.getAllTextNodes()) { + if (node.getTextContent() === 'AB') { + node.select(0, 1); + return; + } + } + }, + {discrete: true}, + ); + }); + await sleep(50); + await insertRubyViaToolbar(page, 'えい'); + + await selectNodeText(page, 'B'); + await insertRubyViaToolbar(page, 'びー'); + + const rubies = await getRubyNodes(page); + expect(rubies).toHaveLength(2); + expect(rubies[0]).toEqual({annotation: 'えい', text: 'A'}); + expect(rubies[1]).toEqual({annotation: 'びー', text: 'B'}); + }); + + test('Ruby exportDOM produces semantic with ', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + await page.keyboard.type('漢'); + await selectAll(page); + await insertRubyViaToolbar(page, 'かん'); + + const exportedHTML = await evaluate(page, () => { + const editor = window.lexicalEditor; + let result = ''; + editor.read(() => { + for (const [, node] of editor.getEditorState()._nodeMap) { + if (node.getType() === 'ruby') { + const {element} = node.exportDOM(); + result = element.outerHTML; + } + } + }); + return result; + }); + + expect(exportedHTML).toBe( + '(かん)', + ); + }); + + test('Ruby node with collapsed selection is a no-op', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + await page.keyboard.type('Hello'); + + await click(page, 'button[aria-label="Insert ruby annotation"]'); + await sleep(50); + + const floatingEditor = page.locator('.ruby-editor .ruby-input'); + await expect(floatingEditor).not.toBeVisible(); + + const rubies = await getRubyNodes(page); + expect(rubies).toHaveLength(0); + + await assertHTML( + page, + html` +

+ Hello +

+ `, + ); + }); +}); + +test.describe('Ruby — Shift+arrow selection', () => { + test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); + + test('Shift+Right extends selection past ruby to next text', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // "ABC" → select "B" → ruby → "A" + ruby("B","び") + "C" + await page.keyboard.type('ABC'); + await moveLeft(page, 1); + await selectCharacters(page, 'left', 1); + await insertRubyViaToolbar(page, 'び'); + + // Place caret at "A":1 (just before ruby) + await setCursorAt(page, 'A', 1); + + // Shift+Right should skip ruby + await page.keyboard.down('Shift'); + await moveRight(page, 1); + await page.keyboard.up('Shift'); + await sleep(50); + + const info = await getSelectionInfo(page); + expect(info).not.toBeNull(); + expect(info.isCollapsed).toBe(false); + // Anchor stays at "A":1 + expect(info.anchor.text).toBe('A'); + expect(info.anchor.offset).toBe(1); + // Focus lands on ruby end (normalization resolves "C":0 → ruby:end) + expect(info.focus.type).toBe('ruby'); + }); + + test('Shift+Left extends selection past ruby to previous text', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // "ABC" → select "B" → ruby → "A" + ruby("B","び") + "C" + await page.keyboard.type('ABC'); + await moveLeft(page, 1); + await selectCharacters(page, 'left', 1); + await insertRubyViaToolbar(page, 'び'); + + // Place caret at "C":0 (just after ruby) + await setCursorAt(page, 'C', 0); + + // Shift+Left should skip ruby and extend focus to "A" + await page.keyboard.down('Shift'); + await moveLeft(page, 1); + await page.keyboard.up('Shift'); + await sleep(50); + + const info = await getSelectionInfo(page); + expect(info).not.toBeNull(); + expect(info.isCollapsed).toBe(false); + // Focus should be on "A" + expect(info.focus.text).toBe('A'); + }); + + test('repeated Shift+Right across a ruby keeps extending the selection', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // "ABCDEF" → select "B" → ruby → "A" + ruby("B","び") + "CDEF" + await page.keyboard.type('ABCDEF'); + await moveLeft(page, 4); + await selectCharacters(page, 'left', 1); + await insertRubyViaToolbar(page, 'び'); + + // Caret at "A":1, immediately before the ruby. + await setCursorAt(page, 'A', 1); + + // Each Shift+Right must grow the selection: press 1 selects the ruby, + // every later press adds one character of "CDEF". Two equal + // consecutive lengths mean the focus bounced back onto the ruby via + // DOM selection resolution and the arrow handler re-landed it at the + // same boundary — the stall that the offset >=1 landing guards + // against. + const lengths = []; + for (let i = 0; i < 5; i++) { + await page.keyboard.down('Shift'); + await moveRight(page, 1); + await page.keyboard.up('Shift'); + await sleep(100); + lengths.push( + await evaluate(page, () => + window.lexicalEditor.read(() => { + const sel = window.lexicalEditor.getEditorState()._selection; + return sel ? sel.getTextContent().length : -1; + }), + ), + ); + } + expect(lengths[0]).toBeGreaterThanOrEqual(1); + for (let i = 1; i < lengths.length; i++) { + expect(lengths[i]).toBeGreaterThan(lengths[i - 1]); + } + }); + + test('Shift+Right skips consecutive ruby group', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // "ABCD" → select "B" → ruby, then select "C" → ruby + // Result: "A" + ruby("B","び") + ruby("C","し") + "D" + await page.keyboard.type('ABCD'); + await moveLeft(page, 2); + await selectCharacters(page, 'left', 1); + await insertRubyViaToolbar(page, 'び'); + + // Programmatically select "C" from "CD" for second ruby + await evaluate(page, () => { + window.lexicalEditor.update( + () => { + const root = window.lexicalEditor + .getEditorState() + ._nodeMap.get('root'); + for (const node of root.getAllTextNodes()) { + if (node.getTextContent() === 'CD') { + node.select(0, 1); + return; + } + } + }, + {discrete: true}, + ); + }); + await sleep(50); + await insertRubyViaToolbar(page, 'し'); + + // Place caret at "A":1 + await setCursorAt(page, 'A', 1); + + // Shift+Right should skip both rubies + await page.keyboard.down('Shift'); + await moveRight(page, 1); + await page.keyboard.up('Shift'); + await sleep(50); + + const info = await getSelectionInfo(page); + expect(info).not.toBeNull(); + expect(info.isCollapsed).toBe(false); + // Focus landed past both rubies (normalization resolves D:0 → ruby:end) + expect(info.focus.type).toBe('ruby'); + }); + + test('Shift+Left skips consecutive ruby group', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // "ABCD" → rubies on "B" and "C" + // Result: "A" + ruby("B","び") + ruby("C","し") + "D" + await page.keyboard.type('ABCD'); + await moveLeft(page, 2); + await selectCharacters(page, 'left', 1); + await insertRubyViaToolbar(page, 'び'); + + // Programmatically select "C" for second ruby + await evaluate(page, () => { + window.lexicalEditor.update( + () => { + const root = window.lexicalEditor + .getEditorState() + ._nodeMap.get('root'); + for (const node of root.getAllTextNodes()) { + if (node.getTextContent() === 'CD') { + node.select(0, 1); + return; + } + } + }, + {discrete: true}, + ); + }); + await sleep(50); + await insertRubyViaToolbar(page, 'し'); + + // Place caret at "D":0 + await setCursorAt(page, 'D', 0); + + // Shift+Left should skip both rubies, land focus on "A" + await page.keyboard.down('Shift'); + await moveLeft(page, 1); + await page.keyboard.up('Shift'); + await sleep(50); + + const info = await getSelectionInfo(page); + expect(info).not.toBeNull(); + expect(info.isCollapsed).toBe(false); + expect(info.focus.text).toBe('A'); + }); +}); + +test.describe('Ruby — line boundary navigation', () => { + test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); + + test('Arrow left at line start when ruby is first child does not get stuck', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // "AB" → select "A" → ruby → ruby("A","えい") + "B" + await page.keyboard.type('AB'); + await evaluate(page, () => { + window.lexicalEditor.update( + () => { + const root = window.lexicalEditor + .getEditorState() + ._nodeMap.get('root'); + for (const node of root.getAllTextNodes()) { + if (node.getTextContent() === 'AB') { + node.select(0, 1); + return; + } + } + }, + {discrete: true}, + ); + }); + await sleep(50); + await insertRubyViaToolbar(page, 'えい'); + + // Place caret at "B":0 (right after ruby) + await setCursorAt(page, 'B', 0); + + // ArrowLeft should skip ruby. Since ruby is first child with no + // previous TextNode, cursor should move to paragraph boundary. + await moveLeft(page, 1); + await sleep(50); + + const info = await getSelectionInfo(page); + expect(info).not.toBeNull(); + // Cursor moved away from "B". It lands on ruby because ruby is the + // boundary child and DOM resolution normalizes paragraph:0 back to it. + expect(info.anchor.text).not.toBe('B'); + }); + + test('Arrow right at line end when ruby is last child does not get stuck', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // "AB" → select "B" → ruby → "A" + ruby("B","び") + await page.keyboard.type('AB'); + await selectCharacters(page, 'left', 1); + await insertRubyViaToolbar(page, 'び'); + + // Place caret at "A":1 (just before ruby) + await setCursorAt(page, 'A', 1); + + // ArrowRight should skip ruby. Since ruby is last child with no + // next TextNode, cursor should move to paragraph end boundary. + await moveRight(page, 1); + await sleep(50); + + const info = await getSelectionInfo(page); + expect(info).not.toBeNull(); + // Cursor should have moved away from "A" — it landed on or past + // ruby (ruby is last child, so there is no further text node). + expect(info.anchor.text).not.toBe('A'); + }); + + test('Shift+Left at line start when ruby is first child extends to boundary', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // "AB" → select "A" → ruby → ruby("A","えい") + "B" + await page.keyboard.type('AB'); + await evaluate(page, () => { + window.lexicalEditor.update( + () => { + const root = window.lexicalEditor + .getEditorState() + ._nodeMap.get('root'); + for (const node of root.getAllTextNodes()) { + if (node.getTextContent() === 'AB') { + node.select(0, 1); + return; + } + } + }, + {discrete: true}, + ); + }); + await sleep(50); + await insertRubyViaToolbar(page, 'えい'); + + // Place caret at "B":0 + await setCursorAt(page, 'B', 0); + + // Shift+Left should extend selection past ruby to start boundary + await page.keyboard.down('Shift'); + await moveLeft(page, 1); + await page.keyboard.up('Shift'); + await sleep(50); + + const info = await getSelectionInfo(page); + expect(info).not.toBeNull(); + expect(info.isCollapsed).toBe(false); + }); + + test('Shift+Right at line end when ruby is last child extends to boundary', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // "AB" → select "B" → ruby → "A" + ruby("B","び") + await page.keyboard.type('AB'); + await selectCharacters(page, 'left', 1); + await insertRubyViaToolbar(page, 'び'); + + // Place caret at "A":1 + await setCursorAt(page, 'A', 1); + + // Shift+Right should extend selection past ruby to end boundary + await page.keyboard.down('Shift'); + await moveRight(page, 1); + await page.keyboard.up('Shift'); + await sleep(50); + + const info = await getSelectionInfo(page); + expect(info).not.toBeNull(); + expect(info.isCollapsed).toBe(false); + }); + + test('Arrow keys do not get stuck when ruby is the only child', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + test.skip(isCollab); + await focusEditor(page); + + // Type single char, select all, convert to ruby + await page.keyboard.type('漢'); + await selectAll(page); + await insertRubyViaToolbar(page, 'かん'); + + // Pressing End then multiple ArrowLeft presses should eventually + // reach paragraph start — cursor must not stay stuck on the ruby. + await page.keyboard.press('End'); + await sleep(50); + await moveLeft(page, 3); + await sleep(200); + + const afterLeft = await getSelectionInfo(page); + expect(afterLeft).not.toBeNull(); + + // Pressing Home then multiple ArrowRight presses should eventually + // reach paragraph end. + await page.keyboard.press('Home'); + await sleep(50); + await moveRight(page, 3); + await sleep(200); + + const afterRight = await getSelectionInfo(page); + expect(afterRight).not.toBeNull(); + + // The main assertion: after enough arrow presses in each direction, + // the cursor position changed — it was not stuck on the ruby. + // Exact positions vary by browser, so we verify non-null selection. + }); +}); + +// The floating ruby editor reads the focused element to decide when to +// close. document.activeElement reports the shadow *host* when the editor +// UI renders inside a shadow root, so these checks must go through the +// popup's own root node (getActiveElement) or the popup closes while its +// input still has focus. +test.describe('Ruby — floating editor in shadow DOM', () => { + test.beforeEach(({isCollab, isPlainText, page}) => { + // Rich-text-only; collab renders in split iframes which is an + // orthogonal concern to shadow root encapsulation. + test.skip(isPlainText || isCollab); + return initialize({isShadowDOM: true, page}); + }); + + test('focusout without relatedTarget keeps the popup open while its input has focus', async ({ + page, + }) => { + await focusEditor(page); + await page.keyboard.type('漢字'); + await selectAll(page); + await click(page, 'button[aria-label="Insert ruby annotation"]'); + + const input = page.locator('.ruby-editor .ruby-input'); + await input.waitFor({state: 'visible', timeout: 1000}); + await input.fill('かんじ'); + + // Synthesize the focus-change path that has no relatedTarget (focus + // moving to browser chrome, devtools, another frame): the popup's + // requestAnimationFrame fallback must observe that focus is still on + // the input via the popup's shadow root, not via document. + await input.evaluate(el => { + el.dispatchEvent( + new FocusEvent('focusout', {bubbles: true, composed: true}), + ); + }); + await sleep(200); + + await expect(input).toBeVisible(); + const inputStillFocused = await input.evaluate( + el => el.getRootNode().activeElement === el, + ); + expect(inputStillFocused).toBe(true); + + // The surviving popup is fully functional: Enter creates the ruby. + await input.press('Enter'); + await sleep(100); + expect(await getRubyNodes(page)).toEqual([ + {annotation: 'かんじ', text: '漢字'}, + ]); + }); + + test('popup opens on ruby click and closes when focus moves back into the editor', async ({ + page, + }) => { + await focusEditor(page); + await page.keyboard.type('漢字ほか'); + await selectNodeText(page, '漢字ほか'); + // Ruby only the leading text so a later click can land on plain text. + await evaluate(page, () => { + window.lexicalEditor.update( + () => { + const root = window.lexicalEditor + .getEditorState() + ._nodeMap.get('root'); + const textNode = root.getAllTextNodes()[0]; + textNode.select(0, 2); + }, + {discrete: true}, + ); + }); + await click(page, 'button[aria-label="Insert ruby annotation"]'); + const input = page.locator('.ruby-editor .ruby-input'); + await input.waitFor({state: 'visible', timeout: 1000}); + await input.fill('かんじ'); + await input.press('Enter'); + await sleep(100); + + await click(page, 'span[data-ruby-annotation]'); + await input.waitFor({state: 'visible', timeout: 1000}); + + // Clicking the plain text after the ruby moves focus back into the + // contentEditable; the relatedTarget path must close the popup (the + // shadow-aware check must not report a false "still focused"). The + // :not() excludes the ruby itself (its role="group" wrapper is also a + // data-lexical-text span) — clicking it would (correctly) reopen the + // popup instead. + await click(page, 'span[data-lexical-text="true"]:not([role="group"])'); + await sleep(200); + await expect(input).toBeHidden(); + }); +}); diff --git a/packages/lexical-playground/src/App.tsx b/packages/lexical-playground/src/App.tsx index b5af04fb7a1..d38b2e71f6b 100644 --- a/packages/lexical-playground/src/App.tsx +++ b/packages/lexical-playground/src/App.tsx @@ -100,6 +100,7 @@ import PasteLogPlugin from './plugins/PasteLogPlugin'; import {PollExtension} from './plugins/PollExtension'; import {PullQuoteExtension} from './plugins/PullQuoteExtension'; import {ReactReviewExtension} from './plugins/ReviewExtension'; +import {RubyExtension} from './plugins/RubyExtension'; import {SpecialTextExtension} from './plugins/SpecialTextExtension'; import {TabFocusExtension} from './plugins/TabFocusExtension'; import {TerseExportExtension} from './plugins/TerseExportExtension'; @@ -243,6 +244,7 @@ const PlaygroundRichTextExtension = /* @__PURE__ */ defineExtension({ ReactReviewExtension, ReactFindReplaceExtension, PullQuoteExtension, + RubyExtension, /* @__PURE__ */ configExtension(TabIndentationExtension, {maxIndent: 7}), ], name: '@lexical/playground/RichText', diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 9e91c28f5e3..2b4f537bec2 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -28,6 +28,7 @@ import {ExcalidrawPlugin} from './plugins/ExcalidrawExtension'; import FloatingLinkEditorPlugin from './plugins/FloatingLinkEditorPlugin'; import FloatingTextFormatToolbarPlugin from './plugins/FloatingTextFormatToolbarPlugin'; import {MentionsPlugin} from './plugins/MentionsExtension'; +import FloatingRubyEditorPlugin from './plugins/RubyExtension/FloatingRubyEditor'; import ShortcutsPlugin from './plugins/ShortcutsPlugin'; import SpeechToTextPlugin from './plugins/SpeechToTextPlugin'; import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin'; @@ -71,6 +72,7 @@ export default function Editor(): JSX.Element { const [editor] = useLexicalComposerContext(); const [activeEditor, setActiveEditor] = useState(editor); const [isLinkEditMode, setIsLinkEditMode] = useState(false); + const [isRubyEditMode, setIsRubyEditMode] = useState(false); const onRef = (_floatingAnchorElem: HTMLDivElement) => { if (_floatingAnchorElem !== null) { @@ -99,6 +101,7 @@ export default function Editor(): JSX.Element { activeEditor={activeEditor} setActiveEditor={setActiveEditor} setIsLinkEditMode={setIsLinkEditMode} + setIsRubyEditMode={setIsRubyEditMode} /> )} {isRichText && ( @@ -139,6 +142,11 @@ export default function Editor(): JSX.Element { isLinkEditMode={isLinkEditMode} setIsLinkEditMode={setIsLinkEditMode} /> + )} diff --git a/packages/lexical-playground/src/__tests__/unit/RubyComposition.test.ts b/packages/lexical-playground/src/__tests__/unit/RubyComposition.test.ts new file mode 100644 index 00000000000..b273ef0271a --- /dev/null +++ b/packages/lexical-playground/src/__tests__/unit/RubyComposition.test.ts @@ -0,0 +1,536 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// Token-mode composition tests. See $onCompositionEndImpl (LexicalEvents.ts) +// for the redirect mechanism. + +import {buildEditorFromExtensions} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createTextNode, + $getNodeByKey, + $getRoot, + $getSelection, + $isRangeSelection, + $isTextNode, + $setCompositionKey, + COMPOSITION_END_COMMAND, + CONTROLLED_TEXT_INSERTION_COMMAND, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + LexicalEditor, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import assert from 'node:assert'; +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; + +import {RubyExtension} from '../../plugins/RubyExtension'; +import { + $createRubyNode, + $isRubyNode, +} from '../../plugins/RubyExtension/RubyNode'; + +vi.mock('lexical/src/environment', () => ({ + CAN_USE_BEFORE_INPUT: true, + CAN_USE_DOM: true, + IS_ANDROID: false, + IS_ANDROID_CHROME: false, + IS_APPLE: true, + IS_APPLE_WEBKIT: true, + IS_CHROME: false, + IS_FIREFOX: false, + IS_IOS: false, + IS_SAFARI: true, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function setupRubyParagraph(editor: LexicalEditor): { + preKey: string; + ruby1Key: string; + ruby2Key: string; + postKey: string; +} { + const keys = {postKey: '', preKey: '', ruby1Key: '', ruby2Key: ''}; + editor.update( + () => { + const pre = $createTextNode('前'); + const ruby1 = $createRubyNode('漢', 'かん'); + const ruby2 = $createRubyNode('字', 'じ'); + const post = $createTextNode('後'); + const paragraph = $createParagraphNode(); + paragraph.append(pre, ruby1, ruby2, post); + $getRoot().clear().append(paragraph); + + keys.preKey = pre.getKey(); + keys.ruby1Key = ruby1.getKey(); + keys.ruby2Key = ruby2.getKey(); + keys.postKey = post.getKey(); + }, + {discrete: true}, + ); + return keys; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('RubyNode composition at boundary (Safari IME)', () => { + let container: HTMLDivElement; + let editor: LexicalEditor; + + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement('div'); + container.setAttribute('data-lexical-editor', 'true'); + container.contentEditable = 'true'; + document.body.appendChild(container); + editor = buildEditorFromExtensions({ + dependencies: [RichTextExtension, RubyExtension], + name: 'ruby-composition-test', + onError: e => { + throw e; + }, + }); + editor.setRootElement(container); + }); + + afterEach(() => { + editor.setRootElement(null); + document.body.removeChild(container); + vi.useRealTimers(); + }); + + test('insertText at end of ruby inserts into next TextNode', () => { + const keys = setupRubyParagraph(editor); + + editor.update( + () => { + const ruby2 = $getNodeByKey(keys.ruby2Key); + assert($isRubyNode(ruby2)); + ruby2.select(1, 1); + $getSelection()!.insertText('あ'); + }, + {discrete: true}, + ); + + expect(editor.read(() => $getRoot().getTextContent())).toBe('前漢字あ後'); + }); + + test('insertText at start of ruby inserts into prev TextNode', () => { + const keys = setupRubyParagraph(editor); + + editor.update( + () => { + const ruby1 = $getNodeByKey(keys.ruby1Key); + assert($isRubyNode(ruby1)); + ruby1.select(0, 0); + $getSelection()!.insertText('か'); + }, + {discrete: true}, + ); + + expect(editor.read(() => $getRoot().getTextContent())).toBe('前か漢字後'); + }); + + test('insertText between adjacent rubies creates new TextNode', () => { + const keys = setupRubyParagraph(editor); + + editor.update( + () => { + const ruby1 = $getNodeByKey(keys.ruby1Key); + assert($isRubyNode(ruby1)); + ruby1.select(1, 1); + $getSelection()!.insertText('の'); + }, + {discrete: true}, + ); + + expect(editor.read(() => $getRoot().getTextContent())).toBe('前漢の字後'); + }); + + test('CONTROLLED_TEXT_INSERTION at end of ruby inserts into next TextNode', () => { + const keys = setupRubyParagraph(editor); + + editor.update( + () => { + const ruby2 = $getNodeByKey(keys.ruby2Key); + assert($isRubyNode(ruby2)); + ruby2.select(1, 1); + }, + {discrete: true}, + ); + + editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, 'あ'); + + expect(editor.read(() => $getRoot().getTextContent())).toBe('前漢字あ後'); + }); + + test('CONTROLLED_TEXT_INSERTION at start of ruby inserts into prev TextNode', () => { + const keys = setupRubyParagraph(editor); + + editor.update( + () => { + const ruby1 = $getNodeByKey(keys.ruby1Key); + assert($isRubyNode(ruby1)); + ruby1.select(0, 0); + }, + {discrete: true}, + ); + + editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, 'か'); + + expect(editor.read(() => $getRoot().getTextContent())).toBe('前か漢字後'); + }); + + test('$nudgeOffRuby skips when ruby node is composing', () => { + const keys = setupRubyParagraph(editor); + + let anchorKey: string | null = null; + editor.update( + () => { + const ruby1 = $getNodeByKey(keys.ruby1Key); + assert($isRubyNode(ruby1)); + ruby1.select(0, 0); + $setCompositionKey(keys.ruby1Key); + + editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); + + const after = $getSelection(); + anchorKey = $isRangeSelection(after) ? after.anchor.key : null; + }, + {discrete: true}, + ); + + expect(anchorKey).toBe(keys.ruby1Key); + }); + + test('$nudgeOffRuby moves selection when NOT composing', () => { + const keys = setupRubyParagraph(editor); + + let anchorKey: string | null = null; + editor.update( + () => { + const ruby1 = $getNodeByKey(keys.ruby1Key); + assert($isRubyNode(ruby1)); + ruby1.select(0, 0); + + editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); + + const after = $getSelection(); + anchorKey = $isRangeSelection(after) ? after.anchor.key : null; + }, + {discrete: true}, + ); + + expect(anchorKey).toBe(keys.preKey); + }); + + test('$nudgeOffRuby at end of ruby moves to next TextNode', () => { + const keys = setupRubyParagraph(editor); + + let result: {key: string; offset: number} | null = null; + editor.update( + () => { + const ruby2 = $getNodeByKey(keys.ruby2Key); + assert($isRubyNode(ruby2)); + ruby2.select(1, 1); + + editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); + + const after = $getSelection(); + result = $isRangeSelection(after) + ? {key: after.anchor.key, offset: after.anchor.offset} + : null; + }, + {discrete: true}, + ); + + expect(result).toEqual({key: keys.postKey, offset: 0}); + }); + + test('token node skips markDirty while composing', () => { + const keys = setupRubyParagraph(editor); + + editor.update( + () => { + $setCompositionKey(keys.ruby1Key); + }, + {discrete: true}, + ); + + const rubyDom = editor.getElementByKey(keys.ruby1Key)!; + const innerSpan = rubyDom.firstElementChild!; + const domText = innerSpan.firstChild as Text; + const NBSP = ' '; + expect(domText.nodeValue).toBe('漢' + NBSP); + + domText.nodeValue = '漢か' + NBSP; + + editor.update( + () => { + const node = $getNodeByKey(keys.ruby1Key); + assert($isRubyNode(node)); + expect(node.isComposing()).toBe(true); + expect(node.isToken()).toBe(true); + }, + {discrete: true}, + ); + + expect(domText.nodeValue).toBe('漢か' + NBSP); + }); + + test.for([ + [ + 'left from after rubies', + 'postKey', + 0, + KEY_ARROW_LEFT_COMMAND, + 'ArrowLeft', + 'preKey', + 1, + ], + [ + 'left from inside ruby1 end', + 'ruby1Key', + 1, + KEY_ARROW_LEFT_COMMAND, + 'ArrowLeft', + 'preKey', + 1, + ], + [ + 'left from inside ruby2 start', + 'ruby2Key', + 0, + KEY_ARROW_LEFT_COMMAND, + 'ArrowLeft', + 'preKey', + 1, + ], + [ + 'right from before rubies', + 'preKey', + 1, + KEY_ARROW_RIGHT_COMMAND, + 'ArrowRight', + 'postKey', + 0, + ], + [ + 'right from inside ruby2 start', + 'ruby2Key', + 0, + KEY_ARROW_RIGHT_COMMAND, + 'ArrowRight', + 'postKey', + 0, + ], + [ + 'right from inside ruby1 end', + 'ruby1Key', + 1, + KEY_ARROW_RIGHT_COMMAND, + 'ArrowRight', + 'postKey', + 0, + ], + ] as const)( + '%s', + ([ + _desc, + startKeyField, + startOffset, + command, + keyName, + expectedKeyField, + expectedOffset, + ]) => { + const keys = setupRubyParagraph(editor); + + let result: {key: string; offset: number} | null = null; + editor.update( + () => { + const startNode = $getNodeByKey(keys[startKeyField]); + assert(startNode !== null && $isTextNode(startNode)); + startNode.select(startOffset, startOffset); + const event = new KeyboardEvent('keydown', {key: keyName}); + editor.dispatchCommand(command, event); + const after = $getSelection(); + result = $isRangeSelection(after) + ? {key: after.anchor.key, offset: after.anchor.offset} + : null; + }, + {discrete: true}, + ); + + expect(result).toEqual({ + key: keys[expectedKeyField], + offset: expectedOffset, + }); + }, + ); + + test('COMPOSITION_END on ruby redirects text to next TextNode', () => { + const keys = setupRubyParagraph(editor); + + editor.update( + () => { + const ruby2 = $getNodeByKey(keys.ruby2Key); + assert($isRubyNode(ruby2)); + ruby2.select(1, 1); + $setCompositionKey(keys.ruby2Key); + }, + {discrete: true}, + ); + + editor.update( + () => { + const event = new CompositionEvent('compositionend', {data: 'あ'}); + editor.dispatchCommand(COMPOSITION_END_COMMAND, event); + }, + {discrete: true}, + ); + + expect(editor.read(() => $getRoot().getTextContent())).toBe('前漢字あ後'); + }); + + test('COMPOSITION_END between adjacent rubies creates new TextNode', () => { + const keys = setupRubyParagraph(editor); + + editor.update( + () => { + const ruby1 = $getNodeByKey(keys.ruby1Key); + assert($isRubyNode(ruby1)); + ruby1.select(1, 1); + $setCompositionKey(keys.ruby1Key); + }, + {discrete: true}, + ); + + editor.update( + () => { + const event = new CompositionEvent('compositionend', {data: 'の'}); + editor.dispatchCommand(COMPOSITION_END_COMMAND, event); + }, + {discrete: true}, + ); + + expect(editor.read(() => $getRoot().getTextContent())).toBe('前漢の字後'); + }); + + test('COMPOSITION_END at offset 0 redirects to prev TextNode', () => { + const keys = setupRubyParagraph(editor); + + editor.update( + () => { + const ruby1 = $getNodeByKey(keys.ruby1Key); + assert($isRubyNode(ruby1)); + ruby1.select(0, 0); + $setCompositionKey(keys.ruby1Key); + const event = new CompositionEvent('compositionend', {data: 'あ'}); + editor.dispatchCommand(COMPOSITION_END_COMMAND, event); + }, + {discrete: true}, + ); + + expect(editor.read(() => $getRoot().getTextContent())).toBe('前あ漢字後'); + }); + + test.for([ + ['paragraph-first ruby at end', ['ruby', 'post'], 1, '漢あ後'], + ['paragraph-first ruby at start', ['ruby', 'post'], 0, 'あ漢後'], + ['solo ruby at end', ['ruby'], 1, '漢あ'], + ['solo ruby at start', ['ruby'], 0, 'あ漢'], + ] as const)( + 'COMPOSITION_END on %s', + ([_desc, childrenSpec, selectionOffset, expectedText]) => { + let rubyKey = ''; + editor.update( + () => { + const ruby = $createRubyNode('漢', 'かん'); + const paragraph = $createParagraphNode(); + const children = childrenSpec.map(c => + c === 'ruby' ? ruby : $createTextNode('後'), + ); + paragraph.append(...children); + $getRoot().clear().append(paragraph); + rubyKey = ruby.getKey(); + }, + {discrete: true}, + ); + + if (selectionOffset > 0) { + editor.update( + () => { + const rubyNode = $getNodeByKey(rubyKey); + assert($isRubyNode(rubyNode)); + rubyNode.select(selectionOffset, selectionOffset); + $setCompositionKey(rubyKey); + }, + {discrete: true}, + ); + + editor.update( + () => { + const event = new CompositionEvent('compositionend', {data: 'あ'}); + editor.dispatchCommand(COMPOSITION_END_COMMAND, event); + }, + {discrete: true}, + ); + } else { + editor.update( + () => { + const rubyNode = $getNodeByKey(rubyKey); + assert($isRubyNode(rubyNode)); + rubyNode.select(0, 0); + $setCompositionKey(rubyKey); + const event = new CompositionEvent('compositionend', {data: 'あ'}); + editor.dispatchCommand(COMPOSITION_END_COMMAND, event); + }, + {discrete: true}, + ); + } + + expect(editor.read(() => $getRoot().getTextContent())).toBe(expectedText); + }, + ); + + test('ruby text content is preserved after COMPOSITION_END redirect', () => { + const keys = setupRubyParagraph(editor); + + editor.update( + () => { + const ruby2 = $getNodeByKey(keys.ruby2Key); + assert($isRubyNode(ruby2)); + ruby2.select(1, 1); + $setCompositionKey(keys.ruby2Key); + }, + {discrete: true}, + ); + + editor.update( + () => { + const event = new CompositionEvent('compositionend', {data: 'あ'}); + editor.dispatchCommand(COMPOSITION_END_COMMAND, event); + }, + {discrete: true}, + ); + + editor.read(() => { + const ruby2 = $getNodeByKey(keys.ruby2Key); + assert($isRubyNode(ruby2)); + expect(ruby2.getTextContent()).toBe('字'); + expect(ruby2.getAnnotation()).toBe('じ'); + }); + }); +}); diff --git a/packages/lexical-playground/src/__tests__/unit/RubyNode.test.ts b/packages/lexical-playground/src/__tests__/unit/RubyNode.test.ts new file mode 100644 index 00000000000..ec4bfae3b0f --- /dev/null +++ b/packages/lexical-playground/src/__tests__/unit/RubyNode.test.ts @@ -0,0 +1,1675 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + buildEditorFromExtensions, + getExtensionDependencyFromEditor, +} from '@lexical/extension'; +import {DOMImportExtension} from '@lexical/html'; +import {RichTextExtension} from '@lexical/rich-text'; +import {JSDOM} from 'jsdom'; +import { + $createParagraphNode, + $createTextNode, + $getNodeByKey, + $getRoot, + $getSelection, + $isElementNode, + $isRangeSelection, + $isTextNode, + $setCompositionKey, + CONTROLLED_TEXT_INSERTION_COMMAND, + ElementNode, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_BACKSPACE_COMMAND, + LexicalEditor, +} from 'lexical'; +import {afterEach, beforeEach, describe, expect, test} from 'vitest'; + +import {RubyExtension} from '../../plugins/RubyExtension'; +import { + $createRubyNode, + $isRubyNode, + $toggleRuby, +} from '../../plugins/RubyExtension/RubyNode'; + +function $getFirstParagraph(): ElementNode { + const first = $getRoot().getFirstChild(); + if (!$isElementNode(first)) { + throw new Error('Expected ElementNode'); + } + return first; +} + +describe('RubyNode', () => { + let container: HTMLDivElement; + let editor: LexicalEditor; + + beforeEach(() => { + container = document.createElement('div'); + container.setAttribute('data-lexical-editor', 'true'); + container.contentEditable = 'true'; + document.body.appendChild(container); + editor = buildEditorFromExtensions({ + dependencies: [RichTextExtension, RubyExtension], + name: 'ruby-node-test', + onError: e => { + throw e; + }, + }); + editor.setRootElement(container); + }); + + afterEach(() => { + editor.setRootElement(null); + document.body.removeChild(container); + }); + + describe('$createRubyNode', () => { + test('creates node with correct text and annotation', () => { + editor.update( + () => { + const ruby = $createRubyNode('漢', 'かん'); + $getRoot().clear().append($createParagraphNode().append(ruby)); + }, + {discrete: true}, + ); + + const result = editor.read(() => { + const ruby = $getFirstParagraph().getChildAtIndex(0); + if (!$isRubyNode(ruby)) { + throw new Error('Expected RubyNode'); + } + return { + annotation: ruby.getAnnotation(), + isToken: ruby.isToken(), + text: ruby.getTextContent(), + type: ruby.getType(), + }; + }); + + expect(result.type).toBe('ruby'); + expect(result.text).toBe('漢'); + expect(result.annotation).toBe('かん'); + expect(result.isToken).toBe(true); + }); + }); + + describe('$isRubyNode', () => { + test('returns true for RubyNode', () => { + editor.update( + () => { + const ruby = $createRubyNode('字', 'じ'); + $getRoot().clear().append($createParagraphNode().append(ruby)); + }, + {discrete: true}, + ); + const result = editor.read(() => + $isRubyNode($getFirstParagraph().getChildAtIndex(0)), + ); + expect(result).toBe(true); + }); + + test('returns false for TextNode', () => { + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('hello'))); + }, + {discrete: true}, + ); + const result = editor.read(() => + $isRubyNode($getFirstParagraph().getChildAtIndex(0)), + ); + expect(result).toBe(false); + }); + + test('returns false for null/undefined', () => { + expect($isRubyNode(null)).toBe(false); + expect($isRubyNode(undefined)).toBe(false); + }); + }); + + describe('serialization', () => { + test('importJSON/exportJSON round-trip preserves text and annotation', () => { + editor.update( + () => { + const ruby = $createRubyNode('漢字', 'かんじ'); + $getRoot().clear().append($createParagraphNode().append(ruby)); + }, + {discrete: true}, + ); + + const json = editor.getEditorState().toJSON(); + + const editor2 = buildEditorFromExtensions({ + dependencies: [RubyExtension], + name: 'ruby-parse-test', + onError: e => { + throw e; + }, + }); + const state2 = editor2.parseEditorState(json); + + const result = state2.read(() => { + const paragraph = $getFirstParagraph(); + const children = paragraph.getChildren(); + const ruby = children.find($isRubyNode); + return ruby + ? {annotation: ruby.getAnnotation(), text: ruby.getTextContent()} + : null; + }); + + expect(result).toEqual({annotation: 'かんじ', text: '漢字'}); + }); + }); + + describe('exportDOM', () => { + test('produces text(annotation)', () => { + editor.update( + () => { + const ruby = $createRubyNode('漢字', 'かんじ'); + $getRoot().clear().append($createParagraphNode().append(ruby)); + }, + {discrete: true}, + ); + + editor.read(() => { + const paragraph = $getFirstParagraph(); + const ruby = paragraph.getChildren().find($isRubyNode)!; + const {element} = ruby.exportDOM(); + + expect(element).not.toBeNull(); + const el = element as HTMLElement; + expect(el.tagName).toBe('RUBY'); + expect(el.childNodes.length).toBe(4); + expect(el.childNodes[0].textContent).toBe('漢字'); + expect((el.childNodes[1] as HTMLElement).tagName).toBe('RP'); + expect(el.childNodes[1].textContent).toBe('('); + expect((el.childNodes[2] as HTMLElement).tagName).toBe('RT'); + expect(el.childNodes[2].textContent).toBe('かんじ'); + expect((el.childNodes[3] as HTMLElement).tagName).toBe('RP'); + expect(el.childNodes[3].textContent).toBe(')'); + }); + }); + }); + + describe('createDOM', () => { + test('produces wrapper span > inner span with data-ruby-annotation', () => { + editor.update( + () => { + const ruby = $createRubyNode('漢', 'かん'); + $getRoot().clear().append($createParagraphNode().append(ruby)); + }, + {discrete: true}, + ); + + editor.read(() => { + const paragraph = $getFirstParagraph(); + const ruby = paragraph.getChildren().find($isRubyNode)!; + const key = ruby.getKey(); + const dom = editor.getElementByKey(key)!; + + expect(dom.tagName).toBe('SPAN'); + const inner = dom.firstElementChild as HTMLElement; + expect(inner).not.toBeNull(); + expect(inner.tagName).toBe('SPAN'); + expect(inner.dataset.rubyAnnotation).toBe('かん'); + }); + }); + }); + + describe('getDOMSlot', () => { + test('returns slot pointing to inner element', () => { + editor.update( + () => { + const ruby = $createRubyNode('漢', 'かん'); + $getRoot().clear().append($createParagraphNode().append(ruby)); + }, + {discrete: true}, + ); + + editor.read(() => { + const paragraph = $getFirstParagraph(); + const ruby = paragraph.getChildren().find($isRubyNode)!; + const key = ruby.getKey(); + const dom = editor.getElementByKey(key)!; + const slot = ruby.getDOMSlot(dom); + const inner = dom.firstElementChild as HTMLElement; + expect(slot.element).toBe(inner); + }); + }); + }); + + describe('$toggleRuby', () => { + test('with annotation on selection creates RubyNode', () => { + editor.update( + () => { + const text = $createTextNode('漢字'); + const paragraph = $createParagraphNode().append(text); + $getRoot().clear().append(paragraph); + + text.select(0, 2); + + $toggleRuby('かんじ'); + }, + {discrete: true}, + ); + + const result = editor.read(() => { + const paragraph = $getFirstParagraph(); + const children = paragraph.getChildren(); + const ruby = children.find($isRubyNode); + if (!ruby) { + return null; + } + return { + annotation: ruby.getAnnotation(), + text: ruby.getTextContent(), + }; + }); + + expect(result).toEqual({annotation: 'かんじ', text: '漢字'}); + }); + + test('with null unwraps RubyNode back to TextNode', () => { + editor.update( + () => { + const ruby = $createRubyNode('漢字', 'かんじ'); + const paragraph = $createParagraphNode().append(ruby); + $getRoot().clear().append(paragraph); + + ruby.select(0, 2); + + $toggleRuby(null); + }, + {discrete: true}, + ); + + const result = editor.read(() => { + const paragraph = $getFirstParagraph(); + const children = paragraph.getChildren(); + const hasRuby = children.some($isRubyNode); + const hasText = children.some( + c => + $isTextNode(c) && !$isRubyNode(c) && c.getTextContent() === '漢字', + ); + return {hasRuby, hasText}; + }); + + expect(result.hasRuby).toBe(false); + expect(result.hasText).toBe(true); + }); + + test('on collapsed selection is no-op', () => { + editor.update( + () => { + const text = $createTextNode('漢字'); + const paragraph = $createParagraphNode().append(text); + $getRoot().clear().append(paragraph); + + text.select(1, 1); + + $toggleRuby('かんじ'); + }, + {discrete: true}, + ); + + const result = editor.read(() => { + const paragraph = $getFirstParagraph(); + const children = paragraph.getChildren(); + return { + hasRuby: children.some($isRubyNode), + textContent: paragraph.getTextContent(), + }; + }); + + expect(result.hasRuby).toBe(false); + expect(result.textContent).toBe('漢字'); + }); + + test('preserves format and style from source text', () => { + editor.update( + () => { + const text = $createTextNode('漢字'); + text.toggleFormat('bold'); + text.setStyle('color: red'); + const paragraph = $createParagraphNode().append(text); + $getRoot().clear().append(paragraph); + text.select(0, 2); + $toggleRuby('かんじ'); + }, + {discrete: true}, + ); + + const result = editor.read(() => { + const ruby = $getFirstParagraph().getChildren().find($isRubyNode); + if (!ruby) { + return null; + } + return { + isBold: ruby.hasFormat('bold'), + style: ruby.getStyle(), + }; + }); + + expect(result).toEqual({isBold: true, style: 'color: red'}); + }); + + test('unwrap preserves format and style on resulting TextNode', () => { + editor.update( + () => { + const ruby = $createRubyNode('漢字', 'かんじ'); + ruby.toggleFormat('bold'); + ruby.setStyle('color: red'); + const paragraph = $createParagraphNode().append(ruby); + $getRoot().clear().append(paragraph); + ruby.select(0, 2); + $toggleRuby(null); + }, + {discrete: true}, + ); + + const result = editor.read(() => { + const child = $getFirstParagraph().getFirstChild(); + if (!$isTextNode(child) || $isRubyNode(child)) { + return null; + } + return { + isBold: child.hasFormat('bold'), + style: child.getStyle(), + }; + }); + + expect(result).toEqual({isBold: true, style: 'color: red'}); + }); + }); + + describe('$unwrapRubiesInSelection', () => { + let extensionCleanup: () => void; + + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extensionCleanup = (RubyExtension as any).register(editor); + }); + + afterEach(() => { + extensionCleanup(); + }); + + test('typing over selection that includes rubies unwraps them', () => { + let rubyKey = ''; + let postKey = ''; + + editor.update( + () => { + const pre = $createTextNode('前'); + const ruby = $createRubyNode('漢字', 'かんじ'); + const post = $createTextNode('後'); + const paragraph = $createParagraphNode().append(pre, ruby, post); + $getRoot().clear().append(paragraph); + rubyKey = ruby.getKey(); + postKey = post.getKey(); + }, + {discrete: true}, + ); + + // Select the ruby + part of post text + editor.update( + () => { + const rubyNode = $getNodeByKey(rubyKey); + if (!$isRubyNode(rubyNode)) { + throw new Error('Expected RubyNode'); + } + const sel = rubyNode.select(0, 0); + sel.focus.set(postKey, 1, 'text'); + }, + {discrete: true}, + ); + + // Dispatch CONTROLLED_TEXT_INSERTION_COMMAND (simulates typing) + editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, 'X'); + + // After the command, rubies in selection should be unwrapped + const result = editor.read(() => { + const paragraph = $getFirstParagraph(); + const children = paragraph.getChildren(); + return { + hasRuby: children.some($isRubyNode), + textContent: paragraph.getTextContent(), + }; + }); + + expect(result.hasRuby).toBe(false); + }); + + test('collapsed selection does not unwrap rubies', () => { + editor.update( + () => { + const pre = $createTextNode('前'); + const ruby = $createRubyNode('漢字', 'かんじ'); + const post = $createTextNode('後'); + const paragraph = $createParagraphNode().append(pre, ruby, post); + $getRoot().clear().append(paragraph); + }, + {discrete: true}, + ); + + let rubyKey = ''; + editor.read(() => { + const paragraph = $getFirstParagraph(); + const ruby = paragraph.getChildren().find($isRubyNode)!; + rubyKey = ruby.getKey(); + }); + + editor.update( + () => { + const rubyNode = $getNodeByKey(rubyKey); + if (!$isRubyNode(rubyNode)) { + throw new Error('Expected RubyNode'); + } + rubyNode.select(0, 0); + }, + {discrete: true}, + ); + + editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, 'X'); + + const hasRuby = editor.read(() => { + const paragraph = $getFirstParagraph(); + return paragraph.getChildren().some($isRubyNode); + }); + + expect(hasRuby).toBe(true); + }); + }); + + describe('token mode', () => { + test('canInsertTextBefore returns false', () => { + editor.update( + () => { + const ruby = $createRubyNode('漢', 'かん'); + $getRoot().clear().append($createParagraphNode().append(ruby)); + }, + {discrete: true}, + ); + const result = editor.read(() => { + const ruby = $getFirstParagraph().getChildAtIndex(0)!; + return $isTextNode(ruby) ? ruby.canInsertTextBefore() : null; + }); + expect(result).toBe(false); + }); + + test('canInsertTextAfter returns false', () => { + editor.update( + () => { + const ruby = $createRubyNode('漢', 'かん'); + $getRoot().clear().append($createParagraphNode().append(ruby)); + }, + {discrete: true}, + ); + const result = editor.read(() => { + const ruby = $getFirstParagraph().getChildAtIndex(0)!; + return $isTextNode(ruby) ? ruby.canInsertTextAfter() : null; + }); + expect(result).toBe(false); + }); + }); +}); + +describe('RubyExtension Shift+arrow skip', () => { + let container: HTMLDivElement; + let extEditor: LexicalEditor; + + beforeEach(() => { + container = document.createElement('div'); + container.setAttribute('data-lexical-editor', 'true'); + container.contentEditable = 'true'; + document.body.appendChild(container); + extEditor = buildEditorFromExtensions({ + dependencies: [RichTextExtension, RubyExtension], + name: 'ruby-shift-arrow-test', + onError: e => { + throw e; + }, + }); + extEditor.setRootElement(container); + }); + + afterEach(() => { + extEditor.setRootElement(null); + document.body.removeChild(container); + }); + + function $setupParagraph() { + const p = $createParagraphNode(); + const hello = $createTextNode('hello'); + const ruby = $createRubyNode('漢', 'かん'); + const world = $createTextNode('world'); + p.append(hello, ruby, world); + $getRoot().clear().append(p); + return {hello, ruby, world}; + } + + test('Shift+Right from collapsed cursor at text end jumps past ruby', () => { + extEditor.update( + () => { + const {hello} = $setupParagraph(); + hello.select(5, 5); + }, + {discrete: true}, + ); + + const event = new KeyboardEvent('keydown', { + key: 'ArrowRight', + shiftKey: true, + }); + const handled = extEditor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event); + + const result = extEditor.read(() => { + const sel = $getSelection(); + if ($isRangeSelection(sel)) { + return { + anchorKey: sel.anchor.getNode().getTextContent(), + anchorOffset: sel.anchor.offset, + focusKey: sel.focus.getNode().getTextContent(), + focusOffset: sel.focus.offset, + }; + } + return null; + }); + + expect(handled).toBe(true); + expect(result!.anchorKey).toBe('hello'); + expect(result!.anchorOffset).toBe(5); + expect(result!.focusKey).toBe('world'); + expect(result!.focusOffset).toBe(0); + }); + + test('Shift+Left from collapsed cursor at text start jumps past ruby', () => { + extEditor.update( + () => { + const {world} = $setupParagraph(); + world.select(0, 0); + }, + {discrete: true}, + ); + + const event = new KeyboardEvent('keydown', { + key: 'ArrowLeft', + shiftKey: true, + }); + const handled = extEditor.dispatchCommand(KEY_ARROW_LEFT_COMMAND, event); + + const result = extEditor.read(() => { + const sel = $getSelection(); + if ($isRangeSelection(sel)) { + return { + focusKey: sel.focus.getNode().getTextContent(), + focusOffset: sel.focus.offset, + }; + } + return null; + }); + + expect(handled).toBe(true); + expect(result!.focusKey).toBe('hello'); + expect(result!.focusOffset).toBe(5); + }); + + test('Shift+Right extends existing selection past ruby', () => { + extEditor.update( + () => { + const {hello} = $setupParagraph(); + hello.select(3, 5); + }, + {discrete: true}, + ); + + const event = new KeyboardEvent('keydown', { + key: 'ArrowRight', + shiftKey: true, + }); + extEditor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event); + + const result = extEditor.read(() => { + const sel = $getSelection(); + if ($isRangeSelection(sel)) { + return { + focusKey: sel.focus.getNode().getTextContent(), + focusOffset: sel.focus.offset, + }; + } + return null; + }); + + expect(result!.focusKey).toBe('world'); + expect(result!.focusOffset).toBe(0); + }); +}); + +describe('RubyExtension Shift+arrow — consecutive rubies', () => { + let container: HTMLDivElement; + let extEditor: LexicalEditor; + + beforeEach(() => { + container = document.createElement('div'); + container.setAttribute('data-lexical-editor', 'true'); + container.contentEditable = 'true'; + document.body.appendChild(container); + extEditor = buildEditorFromExtensions({ + dependencies: [RichTextExtension, RubyExtension], + name: 'ruby-consecutive-test', + onError: e => { + throw e; + }, + }); + extEditor.setRootElement(container); + }); + + afterEach(() => { + extEditor.setRootElement(null); + document.body.removeChild(container); + }); + + function $setupConsecutiveRubies() { + const p = $createParagraphNode(); + const pre = $createTextNode('前'); + const ruby1 = $createRubyNode('漢', 'かん'); + const ruby2 = $createRubyNode('字', 'じ'); + const post = $createTextNode('後'); + p.append(pre, ruby1, ruby2, post); + $getRoot().clear().append(p); + return {post, pre, ruby1, ruby2}; + } + + test('Shift+Right from text end skips consecutive rubies to next text', () => { + extEditor.update( + () => { + const {pre} = $setupConsecutiveRubies(); + pre.select(1, 1); + }, + {discrete: true}, + ); + + extEditor.dispatchCommand( + KEY_ARROW_RIGHT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowRight', shiftKey: true}), + ); + + const result = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) + ? {key: sel.focus.getNode().getTextContent(), offset: sel.focus.offset} + : null; + }); + + expect(result!.key).toBe('後'); + expect(result!.offset).toBe(0); + }); + + test('Shift+Left from text start skips consecutive rubies to prev text', () => { + extEditor.update( + () => { + const {post} = $setupConsecutiveRubies(); + post.select(0, 0); + }, + {discrete: true}, + ); + + extEditor.dispatchCommand( + KEY_ARROW_LEFT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowLeft', shiftKey: true}), + ); + + const result = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) + ? {key: sel.focus.getNode().getTextContent(), offset: sel.focus.offset} + : null; + }); + + expect(result!.key).toBe('前'); + expect(result!.offset).toBe(1); + }); + + test('Shift+Right extends existing forward selection past consecutive rubies', () => { + extEditor.update( + () => { + const {pre} = $setupConsecutiveRubies(); + pre.select(0, 1); + }, + {discrete: true}, + ); + + extEditor.dispatchCommand( + KEY_ARROW_RIGHT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowRight', shiftKey: true}), + ); + + const result = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) + ? {key: sel.focus.getNode().getTextContent(), offset: sel.focus.offset} + : null; + }); + + expect(result!.key).toBe('後'); + expect(result!.offset).toBe(0); + }); + + test('Shift+Left extends existing backward selection past consecutive rubies', () => { + extEditor.update( + () => { + const {post} = $setupConsecutiveRubies(); + post.select(1, 0); + }, + {discrete: true}, + ); + + extEditor.dispatchCommand( + KEY_ARROW_LEFT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowLeft', shiftKey: true}), + ); + + const result = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) + ? {key: sel.focus.getNode().getTextContent(), offset: sel.focus.offset} + : null; + }); + + expect(result!.key).toBe('前'); + expect(result!.offset).toBe(1); + }); +}); + +// Safari normalizes cursor positions at text boundaries onto the preceding +// sibling, so focus can land on a RubyNode. +describe('RubyExtension Shift+arrow — focus on RubyNode (Safari)', () => { + let container: HTMLDivElement; + let extEditor: LexicalEditor; + + beforeEach(() => { + container = document.createElement('div'); + container.setAttribute('data-lexical-editor', 'true'); + container.contentEditable = 'true'; + document.body.appendChild(container); + extEditor = buildEditorFromExtensions({ + dependencies: [RichTextExtension, RubyExtension], + name: 'ruby-safari-test', + onError: e => { + throw e; + }, + }); + extEditor.setRootElement(container); + }); + + afterEach(() => { + extEditor.setRootElement(null); + document.body.removeChild(container); + }); + + function $setupConsecutiveRubies() { + const p = $createParagraphNode(); + const pre = $createTextNode('前'); + const ruby1 = $createRubyNode('漢', 'かん'); + const ruby2 = $createRubyNode('字', 'じ'); + const post = $createTextNode('後'); + p.append(pre, ruby1, ruby2, post); + $getRoot().clear().append(p); + return {post, pre, ruby1, ruby2}; + } + + test('Shift+Right with focus on first ruby walks forward past all rubies', () => { + extEditor.update( + () => { + const {pre, ruby1} = $setupConsecutiveRubies(); + const sel = pre.select(0, 0); + sel.focus.set(ruby1.getKey(), 0, 'text'); + }, + {discrete: true}, + ); + + extEditor.dispatchCommand( + KEY_ARROW_RIGHT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowRight', shiftKey: true}), + ); + + const result = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) + ? {key: sel.focus.getNode().getTextContent(), offset: sel.focus.offset} + : null; + }); + + expect(result!.key).toBe('後'); + expect(result!.offset).toBeGreaterThanOrEqual(0); + expect(result!.offset).toBeLessThanOrEqual(1); + }); + + test('Shift+Left with focus on last ruby walks backward past all rubies', () => { + extEditor.update( + () => { + const {ruby2, post} = $setupConsecutiveRubies(); + const sel = post.select(1, 1); + sel.focus.set(ruby2.getKey(), 1, 'text'); + }, + {discrete: true}, + ); + + extEditor.dispatchCommand( + KEY_ARROW_LEFT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowLeft', shiftKey: true}), + ); + + const result = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) + ? {key: sel.focus.getNode().getTextContent(), offset: sel.focus.offset} + : null; + }); + + expect(result!.key).toBe('前'); + expect(result!.offset).toBe(1); + }); + + test('Shift+Right from ruby uses safe offset (≥1) to avoid normalization bounce', () => { + extEditor.update( + () => { + const {pre, ruby1} = $setupConsecutiveRubies(); + const sel = pre.select(0, 0); + sel.focus.set(ruby1.getKey(), 1, 'text'); + }, + {discrete: true}, + ); + + extEditor.dispatchCommand( + KEY_ARROW_RIGHT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowRight', shiftKey: true}), + ); + + const focusOffset = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) ? sel.focus.offset : -1; + }); + + expect(focusOffset).toBeGreaterThanOrEqual(0); + expect(focusOffset).toBeLessThanOrEqual(1); + }); + + test('composing ruby is skipped — Shift+arrow returns false', () => { + extEditor.update( + () => { + const {pre, ruby1} = $setupConsecutiveRubies(); + const sel = pre.select(0, 0); + sel.focus.set(ruby1.getKey(), 0, 'text'); + $setCompositionKey(ruby1.getKey()); + }, + {discrete: true}, + ); + + const event = new KeyboardEvent('keydown', { + key: 'ArrowRight', + shiftKey: true, + }); + const handled = extEditor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event); + + expect(handled).toBe(false); + }); +}); + +// Without parent-boundary fallback, cursor gets stuck at ruby with no adjacent text. +describe('RubyExtension arrow — line boundary', () => { + let container: HTMLDivElement; + let extEditor: LexicalEditor; + + beforeEach(() => { + container = document.createElement('div'); + container.setAttribute('data-lexical-editor', 'true'); + container.contentEditable = 'true'; + document.body.appendChild(container); + extEditor = buildEditorFromExtensions({ + dependencies: [RichTextExtension, RubyExtension], + name: 'ruby-boundary-test', + onError: e => { + throw e; + }, + }); + extEditor.setRootElement(container); + }); + + afterEach(() => { + extEditor.setRootElement(null); + document.body.removeChild(container); + }); + + test('Left from text:0 when ruby is first child moves to parent element', () => { + extEditor.update( + () => { + const p = $createParagraphNode(); + const ruby = $createRubyNode('漢', 'かん'); + const post = $createTextNode('後'); + p.append(ruby, post); + $getRoot().clear().append(p); + post.select(0, 0); + }, + {discrete: true}, + ); + + const handled = extEditor.dispatchCommand( + KEY_ARROW_LEFT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowLeft'}), + ); + + const result = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) + ? {offset: sel.anchor.offset, type: sel.anchor.type} + : null; + }); + + expect(handled).toBe(true); + expect(result!.type).toBe('element'); + expect(result!.offset).toBe(0); + }); + + test('Right from text end when ruby is last child moves to parent element', () => { + extEditor.update( + () => { + const p = $createParagraphNode(); + const pre = $createTextNode('前'); + const ruby = $createRubyNode('漢', 'かん'); + p.append(pre, ruby); + $getRoot().clear().append(p); + pre.select(1, 1); + }, + {discrete: true}, + ); + + const handled = extEditor.dispatchCommand( + KEY_ARROW_RIGHT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowRight'}), + ); + + const anchorType = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) ? sel.anchor.type : ''; + }); + + expect(handled).toBe(true); + expect(anchorType).toBe('element'); + }); + + test('Shift+Left at paragraph start (ruby first) moves focus to parent:0', () => { + extEditor.update( + () => { + const p = $createParagraphNode(); + const ruby = $createRubyNode('漢', 'かん'); + const post = $createTextNode('後'); + p.append(ruby, post); + $getRoot().clear().append(p); + post.select(0, 0); + }, + {discrete: true}, + ); + + const handled = extEditor.dispatchCommand( + KEY_ARROW_LEFT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowLeft', shiftKey: true}), + ); + + const result = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) + ? {offset: sel.focus.offset, type: sel.focus.type} + : null; + }); + + expect(handled).toBe(true); + expect(result!.type).toBe('element'); + expect(result!.offset).toBe(0); + }); + + test('Shift+Right at paragraph end (ruby last) moves focus to parent:childrenSize', () => { + extEditor.update( + () => { + const p = $createParagraphNode(); + const pre = $createTextNode('前'); + const ruby = $createRubyNode('漢', 'かん'); + p.append(pre, ruby); + $getRoot().clear().append(p); + pre.select(1, 1); + }, + {discrete: true}, + ); + + const handled = extEditor.dispatchCommand( + KEY_ARROW_RIGHT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowRight', shiftKey: true}), + ); + + const focusType = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) ? sel.focus.type : ''; + }); + + expect(handled).toBe(true); + expect(focusType).toBe('element'); + }); + + test('Shift+Right when focus is on ruby and ruby is last child goes to parent', () => { + extEditor.update( + () => { + const p = $createParagraphNode(); + const pre = $createTextNode('前'); + const ruby = $createRubyNode('漢', 'かん'); + p.append(pre, ruby); + $getRoot().clear().append(p); + const sel = pre.select(0, 0); + sel.focus.set(ruby.getKey(), 0, 'text'); + }, + {discrete: true}, + ); + + const handled = extEditor.dispatchCommand( + KEY_ARROW_RIGHT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowRight', shiftKey: true}), + ); + + const focusType = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) ? sel.focus.type : ''; + }); + + expect(handled).toBe(true); + expect(focusType).toBe('element'); + }); + + test('Shift+Left when focus is on ruby and ruby is first child goes to parent:0', () => { + extEditor.update( + () => { + const p = $createParagraphNode(); + const ruby = $createRubyNode('漢', 'かん'); + const post = $createTextNode('後'); + p.append(ruby, post); + $getRoot().clear().append(p); + const sel = post.select(1, 1); + sel.focus.set(ruby.getKey(), 1, 'text'); + }, + {discrete: true}, + ); + + const handled = extEditor.dispatchCommand( + KEY_ARROW_LEFT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowLeft', shiftKey: true}), + ); + + const result = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) + ? {offset: sel.focus.offset, type: sel.focus.type} + : null; + }); + + expect(handled).toBe(true); + expect(result!.type).toBe('element'); + expect(result!.offset).toBe(0); + }); +}); + +// Omits RichTextExtension: RichText's deleteCharacter calls domSelection.modify +// which is unavailable in jsdom. +describe('RubyExtension backspace', () => { + let container: HTMLDivElement; + let editor: LexicalEditor; + + beforeEach(() => { + container = document.createElement('div'); + container.setAttribute('data-lexical-editor', 'true'); + container.contentEditable = 'true'; + document.body.appendChild(container); + editor = buildEditorFromExtensions({ + dependencies: [RubyExtension], + name: 'ruby-backspace-test', + onError: e => { + throw e; + }, + }); + editor.setRootElement(container); + }); + + afterEach(() => { + editor.setRootElement(null); + document.body.removeChild(container); + }); + + test('Backspace at text:0 with preceding ruby removes the ruby', () => { + let handled = false; + + editor.update( + () => { + const p = $createParagraphNode(); + const pre = $createTextNode('前'); + const ruby = $createRubyNode('漢', 'かん'); + const post = $createTextNode('後'); + p.append(pre, ruby, post); + $getRoot().clear().append(p); + + post.select(0, 0); + + const event = new KeyboardEvent('keydown', {key: 'Backspace'}); + handled = editor.dispatchCommand(KEY_BACKSPACE_COMMAND, event); + }, + {discrete: true}, + ); + + const result = editor.read(() => { + const first = $getRoot().getFirstChild(); + if (!$isElementNode(first)) { + return {hasRuby: false, text: ''}; + } + return { + hasRuby: first.getChildren().some($isRubyNode), + text: first.getTextContent(), + }; + }); + + expect(handled).toBe(true); + expect(result.hasRuby).toBe(false); + expect(result.text).toBe('前後'); + }); + + test('Backspace at text offset > 0 does not remove ruby', () => { + let handled = false; + + editor.update( + () => { + const p = $createParagraphNode(); + const ruby = $createRubyNode('漢', 'かん'); + const post = $createTextNode('後'); + p.append(ruby, post); + $getRoot().clear().append(p); + + post.select(1, 1); + + const event = new KeyboardEvent('keydown', {key: 'Backspace'}); + handled = editor.dispatchCommand(KEY_BACKSPACE_COMMAND, event); + }, + {discrete: true}, + ); + + expect(handled).toBe(false); + }); + + test('Backspace with non-collapsed selection is not handled by ruby', () => { + let handled = false; + + editor.update( + () => { + const p = $createParagraphNode(); + const ruby = $createRubyNode('漢', 'かん'); + const post = $createTextNode('後の'); + p.append(ruby, post); + $getRoot().clear().append(p); + + post.select(0, 1); + + const event = new KeyboardEvent('keydown', {key: 'Backspace'}); + handled = editor.dispatchCommand(KEY_BACKSPACE_COMMAND, event); + }, + {discrete: true}, + ); + + expect(handled).toBe(false); + }); + + test('Backspace at text:0 when prev is not ruby is not handled', () => { + let handled = false; + + editor.update( + () => { + const p = $createParagraphNode(); + const pre = $createTextNode('前'); + const post = $createTextNode('後'); + p.append(pre, post); + $getRoot().clear().append(p); + + post.select(0, 0); + + const event = new KeyboardEvent('keydown', {key: 'Backspace'}); + handled = editor.dispatchCommand(KEY_BACKSPACE_COMMAND, event); + }, + {discrete: true}, + ); + + expect(handled).toBe(false); + }); + + test('Backspace at parent-end element point removes the preceding ruby', () => { + let handled = false; + + editor.update( + () => { + const p = $createParagraphNode(); + const pre = $createTextNode('前'); + const ruby = $createRubyNode('漢', 'かん'); + p.append(pre, ruby); + $getRoot().clear().append(p); + + // The parent-boundary element point that arrow navigation itself + // creates when the ruby is the last child. + const sel = pre.select(0, 0); + sel.anchor.set(p.getKey(), 2, 'element'); + sel.focus.set(p.getKey(), 2, 'element'); + + const event = new KeyboardEvent('keydown', {key: 'Backspace'}); + handled = editor.dispatchCommand(KEY_BACKSPACE_COMMAND, event); + }, + {discrete: true}, + ); + + const result = editor.read(() => { + const first = $getRoot().getFirstChild(); + if (!$isElementNode(first)) { + return {hasRuby: false, text: ''}; + } + return { + hasRuby: first.getChildren().some($isRubyNode), + text: first.getTextContent(), + }; + }); + + expect(handled).toBe(true); + expect(result.hasRuby).toBe(false); + expect(result.text).toBe('前'); + }); + + test('Backspace at element point whose previous child is not a ruby is not handled', () => { + let handled = false; + + editor.update( + () => { + const p = $createParagraphNode(); + const pre = $createTextNode('前'); + const post = $createTextNode('後'); + p.append(pre, post); + $getRoot().clear().append(p); + + const sel = pre.select(0, 0); + sel.anchor.set(p.getKey(), 1, 'element'); + sel.focus.set(p.getKey(), 1, 'element'); + + const event = new KeyboardEvent('keydown', {key: 'Backspace'}); + handled = editor.dispatchCommand(KEY_BACKSPACE_COMMAND, event); + }, + {discrete: true}, + ); + + expect(handled).toBe(false); + }); +}); + +describe('RubyExtension arrow — guard conditions', () => { + let container: HTMLDivElement; + let extEditor: LexicalEditor; + + beforeEach(() => { + container = document.createElement('div'); + container.setAttribute('data-lexical-editor', 'true'); + container.contentEditable = 'true'; + document.body.appendChild(container); + extEditor = buildEditorFromExtensions({ + dependencies: [RichTextExtension, RubyExtension], + name: 'ruby-guard-test', + onError: e => { + throw e; + }, + }); + extEditor.setRootElement(container); + }); + + afterEach(() => { + extEditor.setRootElement(null); + document.body.removeChild(container); + }); + + function setupAtRubyBoundary() { + extEditor.update( + () => { + const p = $createParagraphNode(); + const pre = $createTextNode('前'); + const ruby = $createRubyNode('漢', 'かん'); + const post = $createTextNode('後'); + p.append(pre, ruby, post); + $getRoot().clear().append(p); + + post.select(0, 0); + }, + {discrete: true}, + ); + } + + test.for([ + {label: 'Meta', modifier: {metaKey: true}}, + {label: 'Ctrl', modifier: {ctrlKey: true}}, + {label: 'Alt', modifier: {altKey: true}}, + ])('Arrow+$label does not skip ruby', ({modifier}) => { + setupAtRubyBoundary(); + const event = new KeyboardEvent('keydown', { + key: 'ArrowLeft', + ...modifier, + }); + const handled = extEditor.dispatchCommand(KEY_ARROW_LEFT_COMMAND, event); + expect(handled).toBe(false); + }); + + test('Non-collapsed selection without shift — arrow does not skip ruby', () => { + extEditor.update( + () => { + const p = $createParagraphNode(); + const pre = $createTextNode('前'); + const ruby = $createRubyNode('漢', 'かん'); + const post = $createTextNode('後'); + p.append(pre, ruby, post); + $getRoot().clear().append(p); + + pre.select(0, 1); + }, + {discrete: true}, + ); + + const event = new KeyboardEvent('keydown', {key: 'ArrowRight'}); + const handled = extEditor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event); + expect(handled).toBe(false); + }); + + test('Arrow at text middle (not at boundary) does not skip ruby', () => { + extEditor.update( + () => { + const p = $createParagraphNode(); + const pre = $createTextNode('前後'); + const ruby = $createRubyNode('漢', 'かん'); + p.append(pre, ruby); + $getRoot().clear().append(p); + + pre.select(1, 1); + }, + {discrete: true}, + ); + + const event = new KeyboardEvent('keydown', {key: 'ArrowRight'}); + const handled = extEditor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event); + expect(handled).toBe(false); + }); +}); + +// Element points arise from the extension's own parent-boundary landings +// (and from clicks in empty areas). Arrow handling must skip a chain the +// point faces, and must NOT consume the keypress when the chain is behind +// the point — otherwise the caret can never leave the paragraph. +describe('RubyExtension arrow — element points', () => { + let container: HTMLDivElement; + let extEditor: LexicalEditor; + + beforeEach(() => { + container = document.createElement('div'); + container.setAttribute('data-lexical-editor', 'true'); + container.contentEditable = 'true'; + document.body.appendChild(container); + extEditor = buildEditorFromExtensions({ + dependencies: [RichTextExtension, RubyExtension], + name: 'ruby-element-point-test', + onError: e => { + throw e; + }, + }); + extEditor.setRootElement(container); + }); + + afterEach(() => { + extEditor.setRootElement(null); + document.body.removeChild(container); + }); + + function $selectElementPoint(p: ElementNode, offset: number) { + const first = p.getFirstDescendant(); + if (!$isTextNode(first)) { + throw new Error('Expected a leading TextNode'); + } + const sel = first.select(0, 0); + sel.anchor.set(p.getKey(), offset, 'element'); + sel.focus.set(p.getKey(), offset, 'element'); + } + + test('Arrow right at parent-end element point (ruby last) is not handled', () => { + let handled = true; + // Dispatch inside the update: a committed element point can be + // re-resolved onto the adjacent text node by the DOM selection + // round-trip (jsdom always; Safari at boundaries), which would test + // the on-ruby path instead of the element-point path. + extEditor.update( + () => { + const p = $createParagraphNode(); + p.append($createTextNode('前'), $createRubyNode('漢', 'かん')); + $getRoot().clear().append(p); + $selectElementPoint(p, 2); + handled = extEditor.dispatchCommand( + KEY_ARROW_RIGHT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowRight'}), + ); + }, + {discrete: true}, + ); + + // The ruby is behind the point; the keypress must fall through to the + // browser so the caret can leave the paragraph. + expect(handled).toBe(false); + }); + + test('Arrow left at parent-start element point (ruby first) is not handled', () => { + let handled = true; + extEditor.update( + () => { + const p = $createParagraphNode(); + const ruby = $createRubyNode('漢', 'かん'); + const post = $createTextNode('後'); + p.append(ruby, post); + $getRoot().clear().append(p); + const sel = post.select(0, 0); + sel.anchor.set(p.getKey(), 0, 'element'); + sel.focus.set(p.getKey(), 0, 'element'); + handled = extEditor.dispatchCommand( + KEY_ARROW_LEFT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowLeft'}), + ); + }, + {discrete: true}, + ); + + expect(handled).toBe(false); + }); + + test('Arrow left from parent-end element point skips back over the ruby', () => { + let handled = false; + extEditor.update( + () => { + const p = $createParagraphNode(); + p.append($createTextNode('前'), $createRubyNode('漢', 'かん')); + $getRoot().clear().append(p); + $selectElementPoint(p, 2); + handled = extEditor.dispatchCommand( + KEY_ARROW_LEFT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowLeft'}), + ); + }, + {discrete: true}, + ); + + const result = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) + ? { + isCollapsed: sel.isCollapsed(), + offset: sel.anchor.offset, + text: sel.anchor.getNode().getTextContent(), + type: sel.anchor.type, + } + : null; + }); + + expect(handled).toBe(true); + expect(result).toEqual({ + isCollapsed: true, + offset: 1, + text: '前', + type: 'text', + }); + }); + + test('Shift+Right from element point before consecutive rubies extends past the chain', () => { + let handled = false; + extEditor.update( + () => { + const p = $createParagraphNode(); + const ruby1 = $createRubyNode('漢', 'かん'); + const ruby2 = $createRubyNode('字', 'じ'); + const post = $createTextNode('後'); + p.append(ruby1, ruby2, post); + $getRoot().clear().append(p); + const sel = post.select(0, 0); + sel.anchor.set(p.getKey(), 0, 'element'); + sel.focus.set(p.getKey(), 0, 'element'); + handled = extEditor.dispatchCommand( + KEY_ARROW_RIGHT_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowRight', shiftKey: true}), + ); + }, + {discrete: true}, + ); + + const result = extEditor.read(() => { + const sel = $getSelection(); + return $isRangeSelection(sel) + ? { + anchorOffset: sel.anchor.offset, + anchorType: sel.anchor.type, + focusOffset: sel.focus.offset, + focusText: sel.focus.getNode().getTextContent(), + } + : null; + }); + + expect(handled).toBe(true); + expect(result).toEqual({ + anchorOffset: 0, + anchorType: 'element', + focusOffset: 0, + focusText: '後', + }); + }); +}); + +describe('RubyImportRule — HTML import', () => { + function importAndRead(html: string) { + const editor = buildEditorFromExtensions({ + dependencies: [RichTextExtension, RubyExtension], + name: 'ruby-import-test', + onError: (e: Error) => { + throw e; + }, + }); + + const container = document.createElement('div'); + container.contentEditable = 'true'; + document.body.appendChild(container); + editor.setRootElement(container); + + editor.update( + () => { + const dep = getExtensionDependencyFromEditor( + editor, + DOMImportExtension, + ); + const dom = new JSDOM( + `${html}`, + ); + const nodes = dep.output.$generateNodesFromDOM(dom.window.document); + $getRoot().clear().splice(0, 0, nodes); + }, + {discrete: true}, + ); + + const result = editor.read(() => { + const paragraph = $getRoot().getFirstChild(); + if (!$isElementNode(paragraph)) { + return {children: []}; + } + return { + children: paragraph.getChildren().map(child => { + if ($isRubyNode(child)) { + return { + annotation: child.getAnnotation(), + text: child.getTextContent(), + type: 'ruby', + }; + } + return { + text: child.getTextContent(), + type: child.getType(), + }; + }), + }; + }); + + editor.setRootElement(null); + document.body.removeChild(container); + return result; + } + + test('basic with ', () => { + const result = importAndRead('かん'); + expect(result.children).toEqual([ + {annotation: 'かん', text: '漢', type: 'ruby'}, + ]); + }); + + test(' with tags (graceful skip)', () => { + const result = importAndRead( + '(かん)', + ); + expect(result.children).toEqual([ + {annotation: 'かん', text: '漢', type: 'ruby'}, + ]); + }); + + test('multi-segment ruby', () => { + const result = importAndRead('かん'); + expect(result.children).toEqual([ + {annotation: 'かん', text: '漢', type: 'ruby'}, + {annotation: 'じ', text: '字', type: 'ruby'}, + ]); + }); + + test('trailing text without becomes TextNode', () => { + const result = importAndRead('かん余り'); + expect(result.children).toEqual([ + {annotation: 'かん', text: '漢', type: 'ruby'}, + {text: '余り', type: 'text'}, + ]); + }); + + test('empty produces empty annotation', () => { + const result = importAndRead(''); + expect(result.children).toEqual([ + {annotation: '', text: '漢', type: 'ruby'}, + ]); + }); +}); diff --git a/packages/lexical-playground/src/images/icons/ruby.svg b/packages/lexical-playground/src/images/icons/ruby.svg new file mode 100644 index 00000000000..7247c985624 --- /dev/null +++ b/packages/lexical-playground/src/images/icons/ruby.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index 22ff5de219b..5f4f8d4168e 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -502,6 +502,11 @@ i.add-comment { mask-image: url(images/icons/chat-left-text.svg); } +i.ruby { + -webkit-mask-image: url(images/icons/ruby.svg); + mask-image: url(images/icons/ruby.svg); +} + i.horizontal-rule { -webkit-mask-image: url(images/icons/horizontal-rule.svg); mask-image: url(images/icons/horizontal-rule.svg); diff --git a/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx b/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx index e995102ce7c..8ef0e285f88 100644 --- a/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx @@ -347,6 +347,7 @@ function useFloatingTextFormatToolbar( editor: LexicalEditor, anchorElem: HTMLElement, setIsLinkEditMode: Dispatch, + isRubyEditMode: boolean, ): JSX.Element | null { const [isText, setIsText] = useState(false); const [isLink, setIsLink] = useState(false); @@ -466,7 +467,7 @@ function useFloatingTextFormatToolbar( ); }, [editor, updatePopup]); - if (!isText || isLink) { + if (!isText || isLink || isRubyEditMode) { return null; } @@ -495,10 +496,17 @@ function useFloatingTextFormatToolbar( export default function FloatingTextFormatToolbarPlugin({ anchorElem = document.body, setIsLinkEditMode, + isRubyEditMode = false, }: { anchorElem?: HTMLElement; setIsLinkEditMode: Dispatch; + isRubyEditMode?: boolean; }): JSX.Element | null { const [editor] = useLexicalComposerContext(); - return useFloatingTextFormatToolbar(editor, anchorElem, setIsLinkEditMode); + return useFloatingTextFormatToolbar( + editor, + anchorElem, + setIsLinkEditMode, + isRubyEditMode, + ); } diff --git a/packages/lexical-playground/src/plugins/RubyExtension/FloatingRubyEditor.css b/packages/lexical-playground/src/plugins/RubyExtension/FloatingRubyEditor.css new file mode 100644 index 00000000000..61940818d4d --- /dev/null +++ b/packages/lexical-playground/src/plugins/RubyExtension/FloatingRubyEditor.css @@ -0,0 +1,80 @@ +.ruby-editor { + display: flex; + align-items: center; + z-index: 10; + max-width: 360px; + width: 100%; + background-color: #fff; + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3); + border-radius: 8px; + transition: opacity 0.5s; + will-change: transform; + padding: 4px 8px; + gap: 6px; +} + +.ruby-editor .ruby-base-text { + font-size: 14px; + color: #555; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; + flex-shrink: 0; +} + +.ruby-editor .ruby-input { + flex: 1; + font-size: 14px; + color: rgb(5, 5, 5); + border: none; + outline: none; + padding: 8px; + min-width: 80px; + border-radius: 4px; + background-color: #f5f5f5; +} + +.ruby-editor .ruby-input:focus { + background-color: #eef; +} + +.ruby-editor .ruby-input::placeholder { + color: #aaa; +} + +.ruby-editor .button { + width: 16px; + height: 16px; + display: inline-block; + padding: 4px; + border-radius: 6px; + cursor: pointer; + flex-shrink: 0; +} + +.ruby-editor .button.hovered { + background-color: #eee; +} + +.ruby-editor .button i { + background-size: contain; + display: inline-block; + height: 20px; + width: 20px; + vertical-align: -0.25em; +} + +.ruby-editor .ruby-confirm { + background-image: url(../../images/icons/success-alt.svg); + background-size: 14px; + background-repeat: no-repeat; + background-position: center; +} + +.ruby-editor .ruby-trash { + background-image: url(../../images/icons/trash.svg); + background-size: 14px; + background-repeat: no-repeat; + background-position: center; +} diff --git a/packages/lexical-playground/src/plugins/RubyExtension/FloatingRubyEditor.tsx b/packages/lexical-playground/src/plugins/RubyExtension/FloatingRubyEditor.tsx new file mode 100644 index 00000000000..3ef5dddc2e0 --- /dev/null +++ b/packages/lexical-playground/src/plugins/RubyExtension/FloatingRubyEditor.tsx @@ -0,0 +1,412 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {JSX} from 'react'; + +import './FloatingRubyEditor.css'; + +import { + autoUpdate, + flip, + inline, + offset, + shift, + useFloating, +} from '@floating-ui/react'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import { + $getNearestNodeFromDOMNode, + $getNodeByKey, + $getSelection, + $isRangeSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + getActiveElement, + getDOMSelection, + getParentElement, + isHTMLElement, + KEY_ESCAPE_COMMAND, + LexicalEditor, + mergeRegister, + NodeKey, + registerEventListener, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import {Dispatch, useCallback, useEffect, useRef, useState} from 'react'; +import {createPortal} from 'react-dom'; + +import {$isRubyNode, $toggleRuby, $unwrapRubyNode, RubyNode} from './RubyNode'; + +function preventDefault( + event: React.KeyboardEvent | React.MouseEvent, +): void { + event.preventDefault(); +} + +function $getRubyNodeFromDOM(target: EventTarget | null): RubyNode | null { + if (!isHTMLElement(target)) { + return null; + } + const node = $getNearestNodeFromDOMNode(target); + return $isRubyNode(node) ? node : null; +} + +function FloatingRubyEditor({ + editor, + anchorElem, + isRubyEditMode, + setIsRubyEditMode, +}: { + editor: LexicalEditor; + anchorElem: HTMLElement; + isRubyEditMode: boolean; + setIsRubyEditMode: Dispatch; +}): JSX.Element { + const editorRef = useRef(null); + const inputRef = useRef(null); + const isEditorPointerDownRef = useRef(false); + const [baseText, setBaseText] = useState(''); + const [annotation, setAnnotation] = useState(''); + const [rubyNodeKey, setRubyNodeKey] = useState(null); + const [isRubyClick, setIsRubyClick] = useState(false); + + const isVisible = isRubyClick || isRubyEditMode; + const scrollerElem = getParentElement(anchorElem); + + const {refs, floatingStyles} = useFloating({ + middleware: [ + inline(), + offset(10), + flip({ + boundary: scrollerElem || undefined, + padding: 10, + }), + shift({ + boundary: scrollerElem || undefined, + crossAxis: true, + mainAxis: true, + padding: 10, + }), + ], + placement: 'bottom-start', + strategy: 'absolute', + whileElementsMounted: (...args) => + autoUpdate(...args, {ancestorScroll: false}), + }); + + const positionToRubyNode = useCallback( + (node: RubyNode) => { + setBaseText(node.getTextContent()); + setAnnotation(node.getAnnotation()); + setRubyNodeKey(node.getKey()); + + const element = editor.getElementByKey(node.getKey()); + if (element) { + refs.setPositionReference({ + getBoundingClientRect: () => element.getBoundingClientRect(), + getClientRects: () => element.getClientRects(), + }); + } + }, + [editor, refs], + ); + + const $positionToSelection = useCallback(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + return; + } + setBaseText(selection.getTextContent()); + setAnnotation(''); + setRubyNodeKey(null); + + const nativeSelection = getDOMSelection(editor._window); + if (nativeSelection !== null && nativeSelection.rangeCount > 0) { + refs.setPositionReference(nativeSelection.getRangeAt(0)); + } + }, [editor, refs]); + + useEffect(() => { + if (!isRubyEditMode) { + return; + } + editor.read('latest', $positionToSelection); + }, [editor, isRubyEditMode, $positionToSelection]); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + CLICK_COMMAND, + event => { + if (editorRef.current?.contains(event.target as Node)) { + return false; + } + const selection = $getSelection(); + if ($isRangeSelection(selection) && !selection.isCollapsed()) { + setIsRubyClick(false); + return false; + } + const node = $getRubyNodeFromDOM(event.target); + if (node) { + positionToRubyNode(node); + setIsRubyClick(true); + } else { + setIsRubyClick(false); + } + return false; + }, + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + if (isRubyEditMode) { + $positionToSelection(); + } + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_ESCAPE_COMMAND, + () => { + if (isVisible) { + setIsRubyClick(false); + setIsRubyEditMode(false); + return true; + } + return false; + }, + COMMAND_PRIORITY_HIGH, + ), + ); + }, [ + editor, + isRubyEditMode, + isRubyClick, + isVisible, + positionToRubyNode, + $positionToSelection, + setIsRubyEditMode, + ]); + + useEffect(() => { + if (isVisible && inputRef.current) { + inputRef.current.focus(); + } + }, [isVisible]); + + useEffect(() => { + const editorElement = editorRef.current; + if (editorElement === null) { + return; + } + const handleBlur = (event: FocusEvent) => { + if (!isVisible) { + return; + } + if (isEditorPointerDownRef.current) { + return; + } + const next = event.relatedTarget as Element | null; + if (next !== null) { + if (!editorElement.contains(next)) { + setIsRubyClick(false); + setIsRubyEditMode(false); + } + return; + } + requestAnimationFrame(() => { + // getActiveElement rather than document.activeElement, which + // reports the shadow host (never contained by editorElement) when + // the editor UI is rendered inside a shadow root, and the wrong + // document entirely when it is rendered in an iframe. + if (editorElement.contains(getActiveElement(editorElement))) { + return; + } + setIsRubyClick(false); + setIsRubyEditMode(false); + }); + }; + return registerEventListener(editorElement, 'focusout', handleBlur); + }, [editorRef, setIsRubyEditMode, isVisible]); + + const handleSubmit = ( + event: React.KeyboardEvent | React.MouseEvent, + ) => { + event.preventDefault(); + const value = annotation.trim(); + if (!value) { + return; + } + editor.update(() => { + if (rubyNodeKey) { + const node = $getNodeByKey(rubyNodeKey); + if ($isRubyNode(node)) { + node.setAnnotation(value); + } + } else { + $toggleRuby(value); + } + }); + setIsRubyClick(false); + setIsRubyEditMode(false); + requestAnimationFrame(() => editor.focus()); + }; + + const handleDelete = () => { + editor.update(() => { + if (rubyNodeKey) { + const node = $getNodeByKey(rubyNodeKey); + if ($isRubyNode(node)) { + $unwrapRubyNode(node); + } + } else { + $toggleRuby(null); + } + }); + setIsRubyClick(false); + setIsRubyEditMode(false); + requestAnimationFrame(() => editor.focus()); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing) { + return; + } + if (event.key === 'Enter') { + handleSubmit(event); + } else if (event.key === 'Escape') { + event.preventDefault(); + setIsRubyClick(false); + setIsRubyEditMode(false); + } + }; + + return ( +
{ + editorRef.current = el; + refs.setFloating(el); + }} + className="ruby-editor" + onMouseDown={() => { + isEditorPointerDownRef.current = true; + }} + onMouseUp={() => { + isEditorPointerDownRef.current = false; + if ( + inputRef.current && + getActiveElement(inputRef.current) !== inputRef.current + ) { + inputRef.current.focus(); + } + }} + style={{ + ...floatingStyles, + opacity: isVisible ? 1 : 0, + pointerEvents: isVisible ? 'auto' : 'none', + }}> + {isVisible && ( + <> + + {baseText} + + { + setAnnotation(event.target.value); + }} + onKeyDown={handleKeyDown} + /> +
{ + if (event.key === 'Enter' || event.key === ' ') { + handleSubmit(event); + } + }} + /> + {rubyNodeKey !== null && ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleDelete(); + } + }} + /> + )} + + )} +
+ ); +} + +function useFloatingRubyEditorToolbar( + editor: LexicalEditor, + anchorElem: HTMLElement, + isRubyEditMode: boolean, + setIsRubyEditMode: Dispatch, +): JSX.Element | null { + const [activeEditor, setActiveEditor] = useState(editor); + + useEffect(() => { + return editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload, newEditor) => { + setActiveEditor(newEditor); + return false; + }, + COMMAND_PRIORITY_LOW, + ); + }, [editor]); + + return createPortal( + , + anchorElem, + ); +} + +export default function FloatingRubyEditorPlugin({ + anchorElem = document.body, + isRubyEditMode, + setIsRubyEditMode, +}: { + anchorElem?: HTMLElement; + isRubyEditMode: boolean; + setIsRubyEditMode: Dispatch; +}): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + return useFloatingRubyEditorToolbar( + editor, + anchorElem, + isRubyEditMode, + setIsRubyEditMode, + ); +} diff --git a/packages/lexical-playground/src/plugins/RubyExtension/RubyNode.ts b/packages/lexical-playground/src/plugins/RubyExtension/RubyNode.ts new file mode 100644 index 00000000000..94d42ecba4e --- /dev/null +++ b/packages/lexical-playground/src/plugins/RubyExtension/RubyNode.ts @@ -0,0 +1,180 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + DOMExportOutput, + DOMSlot, + EditorConfig, + LexicalNode, + NodeStateVersion, + StateValueOrUpdater, +} from 'lexical'; + +import {addClassNamesToElement} from '@lexical/utils'; +import { + $create, + $createTextNode, + $getSelection, + $getState, + $getStateChange, + $isRangeSelection, + $isTextNode, + $setState, + createState, + StateConfigValue, + TextNode, +} from 'lexical'; + +const annotationState = /* @__PURE__ */ createState('annotation', { + parse: v => (typeof v === 'string' ? v : ''), +}); + +/** @noInheritDoc */ +export class RubyNode extends TextNode { + $config() { + return this.config('ruby', { + extends: TextNode, + stateConfigs: [{flat: true, stateConfig: annotationState}], + }); + } + + createDOM(config: EditorConfig): HTMLElement { + const inner = super.createDOM(config); + inner.dataset.rubyAnnotation = this.getAnnotation(); + addClassNamesToElement( + inner, + config.theme.ruby || 'PlaygroundEditorTheme__ruby', + ); + const wrapper = document.createElement('span'); + wrapper.setAttribute('role', 'group'); + wrapper.setAttribute( + 'aria-label', + `${this.getTextContent()} (${this.getAnnotation()})`, + ); + wrapper.appendChild(inner); + return wrapper; + } + + getDOMSlot(dom: HTMLElement): DOMSlot { + const inner = dom.firstElementChild as HTMLElement | null; + if (inner) { + return super.getDOMSlot(dom).withElement(inner); + } + return super.getDOMSlot(dom); + } + + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { + const updated = super.updateDOM(prevNode, dom, config); + const annotationChange = $getStateChange(this, prevNode, annotationState); + if (annotationChange || prevNode.__text !== this.__text) { + const inner = dom.firstElementChild as HTMLElement; + if (inner) { + inner.dataset.rubyAnnotation = this.getAnnotation(); + } + dom.setAttribute( + 'aria-label', + `${this.__text} (${this.getAnnotation()})`, + ); + } + return updated; + } + + exportDOM(): DOMExportOutput { + const ruby = document.createElement('ruby'); + ruby.textContent = this.getTextContent(); + const rpOpen = document.createElement('rp'); + rpOpen.textContent = '('; + ruby.appendChild(rpOpen); + const rt = document.createElement('rt'); + rt.textContent = this.getAnnotation(); + ruby.appendChild(rt); + const rpClose = document.createElement('rp'); + rpClose.textContent = ')'; + ruby.appendChild(rpClose); + return {element: ruby}; + } + + getAnnotation( + version?: NodeStateVersion, + ): StateConfigValue { + return $getState(this, annotationState, version); + } + + setAnnotation( + valueOrUpdater: StateValueOrUpdater, + ): this { + return $setState(this, annotationState, valueOrUpdater); + } + + isInline(): true { + return true; + } + + canInsertTextBefore(): false { + return false; + } + + canInsertTextAfter(): false { + return false; + } +} + +export function $createRubyNode(text: string, annotation: string): RubyNode { + return $create(RubyNode) + .setTextContent(text) + .setMode('token') + .setAnnotation(annotation); +} + +export function $isRubyNode( + node: LexicalNode | null | undefined, +): node is RubyNode { + return node instanceof RubyNode; +} + +/** + * Replace a RubyNode with a plain TextNode containing the same text, + * preserving format and style. + */ +export function $unwrapRubyNode(node: RubyNode): TextNode { + const text = $createTextNode(node.getTextContent()); + text.setFormat(node.getFormat()); + text.setStyle(node.getStyle()); + node.replace(text); + return text; +} + +export function $toggleRuby(annotation: string | null): void { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + + if (annotation === null) { + for (const node of selection.getNodes()) { + if ($isRubyNode(node)) { + $unwrapRubyNode(node); + } + } + return; + } + + if (selection.isCollapsed()) { + return; + } + + const nodes = selection.getNodes(); + const firstTextNode = nodes.find($isTextNode); + const text = selection.getTextContent(); + const rubyNode = $createRubyNode(text, annotation); + if (firstTextNode) { + rubyNode.setFormat(firstTextNode.getFormat()); + rubyNode.setStyle(firstTextNode.getStyle()); + } + selection.insertNodes([rubyNode]); +} diff --git a/packages/lexical-playground/src/plugins/RubyExtension/index.ts b/packages/lexical-playground/src/plugins/RubyExtension/index.ts new file mode 100644 index 00000000000..e615fc6c82c --- /dev/null +++ b/packages/lexical-playground/src/plugins/RubyExtension/index.ts @@ -0,0 +1,356 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {CaretDirection, SiblingCaret} from 'lexical'; + +import { + CoreImportExtension, + defineImportRule, + DOMImportExtension, + sel, +} from '@lexical/html'; +import {mergeRegister} from '@lexical/utils'; +import { + $caretFromPoint, + $createTextNode, + $getSelection, + $getSiblingCaret, + $isExtendableTextPointCaret, + $isRangeSelection, + $isTextNode, + $isTextPointCaret, + $setPointFromCaret, + COMMAND_PRIORITY_HIGH, + configExtension, + CONTROLLED_TEXT_INSERTION_COMMAND, + defineExtension, + getDOMSelection, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_BACKSPACE_COMMAND, + registerEventListener, + registerEventListeners, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; + +import { + $createRubyNode, + $isRubyNode, + $unwrapRubyNode, + RubyNode, +} from './RubyNode'; + +const RubyImportRule = /* @__PURE__ */ defineImportRule({ + $import: (_ctx, el) => { + const children = el.childNodes; + const results = []; + let pendingText = ''; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.nodeName === 'RT') { + const annotation = child.textContent || ''; + if (pendingText) { + results.push($createRubyNode(pendingText, annotation)); + pendingText = ''; + } + } else if (child.nodeName === 'RP') { + continue; + } else { + pendingText += child.textContent || ''; + } + } + + if (pendingText) { + results.push($createTextNode(pendingText)); + } + + return results; + }, + match: sel.tag('ruby'), + name: '@lexical/playground/ruby', +}); + +function $unwrapRubiesInSelection(): void { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + return; + } + for (const node of selection.getNodes()) { + if ($isRubyNode(node)) { + $unwrapRubyNode(node); + } + } +} + +/** + * Walk from a RubyNode past any contiguous RubyNode siblings in the given + * direction. The returned SiblingCaret is attached to the last ruby in the + * chain, so its getNodeAtCaret() is the first non-ruby sibling past the + * chain (or null at the parent boundary). + */ +function $caretPastRubyChain( + ruby: RubyNode, + direction: D, +): SiblingCaret { + let caret = $getSiblingCaret(ruby, direction); + for ( + let node = caret.getNodeAtCaret(); + $isRubyNode(node); + node = caret.getNodeAtCaret() + ) { + caret = $getSiblingCaret(node, direction); + } + return caret; +} + +/** + * Move the selection point past a contiguous chain of RubyNodes so arrow + * navigation treats a ruby group as an atomic unit. The point (focus when + * extending with shift, otherwise the collapsed anchor) is handled when it + * is on a ruby or at the edge of a position adjacent to one, and is moved + * just past the far end of the chain: onto the near edge of the adjacent + * text node when there is one, otherwise to the parent element position + * past the edge ruby. + */ +function $skipRubyOnArrow( + direction: CaretDirection, + isShift: boolean, +): boolean { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + if (!isShift && !selection.isCollapsed()) { + return false; + } + const point = isShift ? selection.focus : selection.anchor; + const caret = $caretFromPoint(point, direction); + + let ruby: RubyNode | null = null; + let fromRuby = false; + if ($isTextPointCaret(caret) && $isRubyNode(caret.origin)) { + // The point is on the ruby itself (Safari can normalize a boundary + // cursor onto it). + if (caret.origin.isComposing()) { + return false; + } + ruby = caret.origin; + fromRuby = true; + } else if (!$isExtendableTextPointCaret(caret)) { + // The point is a text node boundary or an element point, so the + // adjacent node in this direction may be a ruby. + const adjacent = caret.getNodeAtCaret(); + if ($isRubyNode(adjacent)) { + ruby = adjacent; + } + } + if (ruby === null) { + return false; + } + + const edgeCaret = $caretPastRubyChain(ruby, direction); + const beyond = edgeCaret.getNodeAtCaret(); + if (beyond !== null && !$isTextNode(beyond)) { + // A non-text neighbor (decorator, linebreak): defer to default handling. + return false; + } + if ($isTextNode(beyond) && fromRuby && isShift && direction === 'next') { + // When extending forward from a caret on the ruby itself, land at + // offset >=1: a focus at offset 0 of the following text node is + // resolved back onto the ruby end by the DOM selection round-trip + // (reproduced in Chromium; originally reported on Safari), and every + // further press would then re-land at the same boundary, so the + // selection stops growing. Guarded by the 'repeated Shift+Right' + // e2e test. + point.set( + beyond.getKey(), + Math.min(1, beyond.getTextContentSize()), + 'text', + ); + } else { + // The flipped edge caret is this same boundary as a PointCaret facing + // back at the chain: a text point at the near edge of the adjacent + // text node, or the parent element point when there is no sibling. + // (Not $setPointFromCaret(point, edgeCaret): its TextNode branch would + // put a text point on the ruby itself, which arrow handling must not + // create.) + $setPointFromCaret(point, edgeCaret.getFlipped()); + } + if (!isShift) { + const {anchor, focus} = selection; + focus.set(anchor.key, anchor.offset, anchor.type); + } + return true; +} + +function $nudgeOffRuby(): boolean { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return false; + } + const {anchor} = selection; + if (anchor.type !== 'text') { + return false; + } + const node = anchor.getNode(); + if (!$isRubyNode(node)) { + return false; + } + if (node.isComposing()) { + return false; + } + const len = node.getTextContentSize(); + if (anchor.offset === len || anchor.offset === 0) { + const isEnd = anchor.offset === len; + const sibling = isEnd ? node.getNextSibling() : node.getPreviousSibling(); + if ($isTextNode(sibling) && !$isRubyNode(sibling)) { + const offset = isEnd ? 0 : sibling.getTextContentSize(); + selection.anchor.set(sibling.getKey(), offset, 'text'); + selection.focus.set(sibling.getKey(), offset, 'text'); + return false; + } + } + return false; +} + +export const RubyExtension = /* @__PURE__ */ defineExtension({ + dependencies: [ + CoreImportExtension, + /* @__PURE__ */ configExtension(DOMImportExtension, { + rules: [RubyImportRule], + }), + ], + name: '@lexical/playground/Ruby', + nodes: [RubyNode], + register: editor => { + let composingRubyInner: HTMLElement | null = null; + let isMouseDown = false; + + function checkCompositionInRuby() { + if (composingRubyInner) { + return; + } + const domSelection = getDOMSelection(editor._window); + if (!domSelection || !domSelection.anchorNode) { + return; + } + let el: HTMLElement | null = domSelection.anchorNode.parentElement; + while (el && !el.dataset.rubyAnnotation) { + if (el.hasAttribute('data-lexical-key')) { + break; + } + el = el.parentElement; + } + if (el && el.dataset.rubyAnnotation) { + el.classList.add('PlaygroundEditorTheme__ruby--composing'); + composingRubyInner = el; + } + } + + function onCompositionEnd() { + if (composingRubyInner) { + composingRubyInner.classList.remove( + 'PlaygroundEditorTheme__ruby--composing', + ); + composingRubyInner = null; + } + } + + return mergeRegister( + editor.registerRootListener(rootElement => { + if (rootElement) { + return mergeRegister( + registerEventListeners( + rootElement, + { + compositionend: onCompositionEnd, + compositionstart: checkCompositionInRuby, + compositionupdate: checkCompositionInRuby, + }, + true, + ), + registerEventListener(rootElement, 'mousedown', () => { + isMouseDown = true; + }), + registerEventListener(rootElement.ownerDocument, 'mouseup', () => { + isMouseDown = false; + }), + ); + } + }), + editor.registerCommand( + KEY_BACKSPACE_COMMAND, + event => { + if (editor.isComposing()) { + return false; + } + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return false; + } + const caret = $caretFromPoint(selection.anchor, 'previous'); + if (!$isExtendableTextPointCaret(caret)) { + const prev = caret.getNodeAtCaret(); + if ($isRubyNode(prev)) { + prev.remove(); + event.preventDefault(); + return true; + } + } + return false; + }, + COMMAND_PRIORITY_HIGH, + ), + ...( + [ + [KEY_ARROW_LEFT_COMMAND, 'previous'], + [KEY_ARROW_RIGHT_COMMAND, 'next'], + ] as const + ).map(([command, direction]) => + editor.registerCommand( + command, + event => { + if (event.metaKey || event.ctrlKey || event.altKey) { + return false; + } + if (editor.isComposing()) { + return false; + } + const handled = $skipRubyOnArrow(direction, event.shiftKey); + if (handled) { + event.preventDefault(); + } + return handled; + }, + COMMAND_PRIORITY_HIGH, + ), + ), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + if (isMouseDown) { + return false; + } + return $nudgeOffRuby(); + }, + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand( + CONTROLLED_TEXT_INSERTION_COMMAND, + text => { + if (typeof text === 'string') { + $unwrapRubiesInSelection(); + } + return false; + }, + COMMAND_PRIORITY_HIGH, + ), + ); + }, +}); diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx index 007b723b5df..418d67d1860 100644 --- a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx @@ -97,6 +97,7 @@ import InsertLayoutDialog from '../LayoutExtension/InsertLayoutDialog'; import {INSERT_PAGE_BREAK} from '../PageBreakExtension'; import {PagesReactExtension} from '../PagesReactExtension'; import {InsertPollDialog} from '../PollExtension'; +import {$isRubyNode, $toggleRuby} from '../RubyExtension/RubyNode'; import {SHORTCUTS} from '../ShortcutsPlugin/shortcuts'; import ShortcutsHelpDialog from '../ShortcutsPlugin/ShortcutsHelpDialog'; import {InsertTableDialog} from '../TablePlugin'; @@ -568,11 +569,13 @@ export default function ToolbarPlugin({ activeEditor, setActiveEditor, setIsLinkEditMode, + setIsRubyEditMode, }: { editor: LexicalEditor; activeEditor: LexicalEditor; setActiveEditor: Dispatch; setIsLinkEditMode: Dispatch; + setIsRubyEditMode: Dispatch; }): JSX.Element { const [selectedElementKey, setSelectedElementKey] = useState( null, @@ -895,6 +898,26 @@ export default function ToolbarPlugin({ } }, [activeEditor, setIsLinkEditMode, toolbarState.isLink]); + const insertRuby = useCallback(() => { + const {hasRuby, hasSelection} = activeEditor.read(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + return { + hasRuby: selection.getNodes().some($isRubyNode), + hasSelection: !selection.isCollapsed(), + }; + } + return {hasRuby: false, hasSelection: false}; + }); + if (hasRuby) { + activeEditor.update(() => { + $toggleRuby(null); + }); + } else if (hasSelection) { + setIsRubyEditMode(true); + } + }, [activeEditor, setIsRubyEditMode]); + const onCodeLanguageSelect = useCallback( (value: string | null) => { activeEditor.update(() => { @@ -1150,6 +1173,15 @@ export default function ToolbarPlugin({ type="button"> +