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