From 4647d4cef78701f4bdf7236942f2ca1cf8518da9 Mon Sep 17 00:00:00 2001 From: Jash Vithlani Date: Mon, 4 Aug 2025 21:47:45 +0530 Subject: [PATCH 1/3] added test --- .../e2e/SelectionAlwaysOnDisplay.spec.mjs | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/lexical-playground/__tests__/e2e/SelectionAlwaysOnDisplay.spec.mjs b/packages/lexical-playground/__tests__/e2e/SelectionAlwaysOnDisplay.spec.mjs index 638af3e619e..a3d9377f1b5 100644 --- a/packages/lexical-playground/__tests__/e2e/SelectionAlwaysOnDisplay.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/SelectionAlwaysOnDisplay.spec.mjs @@ -6,7 +6,7 @@ * */ -import {selectAll} from '../keyboardShortcuts/index.mjs'; +import {selectAll, selectCharacters} from '../keyboardShortcuts/index.mjs'; import { evaluate, expect, @@ -58,5 +58,52 @@ test.describe('SelectionAlwaysOnDisplay', () => { await expect(widthDifference).toBeLessThanOrEqual(5); await expect(heightDifference).toBeLessThanOrEqual(5); }); + + test(`retain selection works with reverse selection`, async ({ + page, + isPlainText, + browserName, + }) => { + test.skip(isPlainText); // Fixed in #6873 + await focusEditor(page); + await page.keyboard.type('Lexical'); + + // Position cursor at the end, then create a reverse selection by selecting all text to the left + await selectCharacters(page, 'left', 'Lexical'.length); + + // Click outside to lose focus + await locate(page, 'body').click(); + + const {distance, widthDifference, heightDifference} = await evaluate( + page, + () => { + function compareNodeAlignment(node1, node2, tolerance = 0) { + const rect1 = node1.getBoundingClientRect(); + const rect2 = node2.getBoundingClientRect(); + const distance_ = Math.sqrt( + Math.pow(rect1.left - rect2.left, 2) + + Math.pow(rect1.top - rect2.top, 2), + ); + const widthDifference_ = Math.abs(rect1.width - rect2.width); + const heightDifference_ = Math.abs(rect1.height - rect2.height); + return { + distance: distance_, + widthDifference: widthDifference_, + heightDifference: heightDifference_, + }; + } + const editorSpan = document.querySelector( + '[contenteditable="true"] span', + ); + const fakeSelection = document.querySelector( + '[style*="background: highlight"]', + ); + return compareNodeAlignment(editorSpan, fakeSelection, 5); + }, + ); + await expect(distance).toBeLessThanOrEqual(5); + await expect(widthDifference).toBeLessThanOrEqual(5); + await expect(heightDifference).toBeLessThanOrEqual(5); + }); }); /* eslint-enable sort-keys-fix/sort-keys-fix */ From 26356505f5d7770be0ba3c77895f9fb1bfa66d08 Mon Sep 17 00:00:00 2001 From: jash vithlani Date: Tue, 13 Jan 2026 02:21:55 +0530 Subject: [PATCH 2/3] [lexical-playground]: Added code plugin to maintain a paragraph before and after codeblock --- packages/lexical-playground/src/Editor.tsx | 2 + .../src/plugins/CodePlugin/index.tsx | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 packages/lexical-playground/src/plugins/CodePlugin/index.tsx diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 1284efb2da1..2f96c0d85e0 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -46,6 +46,7 @@ import AutoLinkPlugin from './plugins/AutoLinkPlugin'; import CodeActionMenuPlugin from './plugins/CodeActionMenuPlugin'; import CodeHighlightPrismPlugin from './plugins/CodeHighlightPrismPlugin'; import CodeHighlightShikiPlugin from './plugins/CodeHighlightShikiPlugin'; +import CodePlugin from './plugins/CodePlugin'; import CollapsiblePlugin from './plugins/CollapsiblePlugin'; import CommentPlugin from './plugins/CommentPlugin'; import ComponentPickerPlugin from './plugins/ComponentPickerPlugin'; @@ -190,6 +191,7 @@ export default function Editor(): JSX.Element { + {!(isCollab && useCollabV2) && ( { + if (!editor.hasNodes([CodeNode])) { + return; + } + + return editor.registerNodeTransform(CodeNode, (codeNode) => { + // This is to ensure that there is a paragraph node always present after the code node + if (codeNode.getParent() !== $getRoot()) { + return codeNode; + } + + if (!codeNode.getNextSibling()) { + codeNode.insertAfter($createParagraphNode()); + } + + // This is to ensure that there is a paragraph node always present before the code node + if (!codeNode.getPreviousSibling()) { + codeNode.insertBefore($createParagraphNode()); + } + + return codeNode; + }); + }, [editor]); + + return null; +} From 3f1705606b25da3fe994cde79734705898c6e6f2 Mon Sep 17 00:00:00 2001 From: jash vithlani Date: Tue, 13 Jan 2026 10:19:57 +0530 Subject: [PATCH 3/3] refac --- .../__tests__/e2e/CodeActionMenu.spec.mjs | 6 ++ .../__tests__/e2e/CodeBlock.spec.mjs | 92 +++++++++++++------ .../__tests__/e2e/Indentation.spec.mjs | 2 + .../__tests__/e2e/Markdown.spec.mjs | 2 +- .../__tests__/e2e/Selection.spec.mjs | 2 + .../__tests__/e2e/Tab.spec.mjs | 2 + .../regression/1384-insert-nodes.spec.mjs | 2 + packages/lexical-playground/src/Editor.tsx | 2 +- .../src/plugins/CodePlugin/index.tsx | 14 ++- 9 files changed, 93 insertions(+), 31 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/CodeActionMenu.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeActionMenu.spec.mjs index 926e842da5e..cc67bb9eb22 100644 --- a/packages/lexical-playground/__tests__/e2e/CodeActionMenu.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CodeActionMenu.spec.mjs @@ -195,6 +195,7 @@ test.describe('CodeActionMenu', () => { await assertHTML( page, ` +


{ 'Hello World' +


`, ); @@ -226,6 +228,7 @@ test.describe('CodeActionMenu', () => { await assertHTML( page, ` +


{

+


`, ); }); @@ -271,6 +275,7 @@ test.describe('CodeActionMenu', () => { await assertHTML( page, ` +


{ 'Hello World' +


`, ); diff --git a/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs index 150f5cbc43e..ac0db5f5965 100644 --- a/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs @@ -38,13 +38,14 @@ test.describe('CodeBlock', () => { if (isRichText) { await assertSelection(page, { anchorOffset: 1, - anchorPath: [0, 4, 0], + anchorPath: [1, 4, 0], focusOffset: 1, - focusPath: [0, 4, 0], + focusPath: [1, 4, 0], }); await assertHTML( page, html` +


{ ; +


`, ); // Remove code block (back to a normal paragraph) and check that highlights are converted into regular text await moveToEditorBeginning(page); + await page.keyboard.press('ArrowRight'); await page.keyboard.press('Backspace'); await assertHTML( page, @@ -90,6 +93,7 @@ test.describe('CodeBlock', () => {

alert(1);

+


`, ); } else { @@ -115,13 +119,14 @@ test.describe('CodeBlock', () => { if (isRichText) { await assertSelection(page, { anchorOffset: 0, - anchorPath: [0, 0, 0], + anchorPath: [1, 0, 0], focusOffset: 0, - focusPath: [0, 0, 0], + focusPath: [1, 0, 0], }); await assertHTML( page, html` +


{ ; +


`, ); } else { @@ -210,6 +216,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, html` +


{
meh
+


`, ); }); @@ -292,6 +300,7 @@ test.describe('CodeBlock', () => {
meh +


`, ); }); @@ -306,6 +315,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, html` +


{ from users +


`, ); await click(page, '.toolbar-item.code-language'); @@ -328,6 +339,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, html` +


{ users +


`, ); } else { @@ -385,6 +398,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, html` +


{ ; +


`, ); }); @@ -481,6 +496,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, html` +


{ ; +


`, ); }); @@ -566,6 +583,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, html` +


{ } +


`, ); await page.keyboard.down('Shift'); @@ -640,6 +659,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, html` +


{ } +


`, ); await page.keyboard.down('Shift'); @@ -728,6 +749,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, html` +


{ } +


`, ); await click(page, '.toolbar-item.alignment'); @@ -807,6 +830,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, html` +


{ } +


`, ); }); @@ -875,6 +900,7 @@ test.describe('CodeBlock', () => { }) => { test.skip(isPlainText); const abcHTML = html` +


{ ; +


`; const bcaHTML = html` +


{ ; +


`; const endOfFirstLine = { anchorOffset: 1, - anchorPath: [0, 3, 0], + anchorPath: [1, 3, 0], focusOffset: 1, - focusPath: [0, 3, 0], + focusPath: [1, 3, 0], }; const endOfLastLine = { anchorOffset: 1, - anchorPath: [0, 13, 0], + anchorPath: [1, 13, 0], focusOffset: 1, - focusPath: [0, 13, 0], + focusPath: [1, 13, 0], }; await focusEditor(page); await page.keyboard.type('``` a();\nb();\nc();'); @@ -1072,9 +1101,9 @@ test.describe('CodeBlock', () => { await page.keyboard.press('ArrowUp'); await assertSelection(page, { anchorOffset: 0, - anchorPath: [0, 0, 0], + anchorPath: [1, 0, 0], focusOffset: 0, - focusPath: [0, 0, 0], + focusPath: [1, 0, 0], }); // Test 2: Typing at start stays within code block @@ -1083,6 +1112,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, html` +


{ ; +


`, ); // Let's verify the cursor position after typing the start comment await assertSelection(page, { anchorOffset: 0, - anchorPath: [0, 2, 0], + anchorPath: [1, 2, 0], focusOffset: 0, - focusPath: [0, 2, 0], + focusPath: [1, 2, 0], }); // Test 3: Selection stays at end when pressing down @@ -1145,6 +1176,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, html` +


{ // end +


`, ); await page.keyboard.press('ArrowDown'); await assertSelection(page, { anchorOffset: 6, - anchorPath: [0, 10, 0], + anchorPath: [1, 10, 0], focusOffset: 6, - focusPath: [0, 10, 0], + focusPath: [1, 10, 0], }); // Verify no content escaped the code block const paragraphs = await page.$$('p'); - expect(paragraphs.length).toBe(0); + expect(paragraphs.length).toBe(2); // Should have 2 paragraphs (before and after code block) }); test('When pressing CMD/Ctrl + Left, CMD/Ctrl + Right, the cursor should go to the start of the code', async ({ @@ -1230,6 +1263,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, ` +


{ c d +


`, ); await selectCharacters(page, 'left', 11); await assertSelection(page, { anchorOffset: 5, - anchorPath: [0, 4, 0], + anchorPath: [1, 4, 0], focusOffset: 1, - focusPath: [0, 1, 0], + focusPath: [1, 1, 0], }); await moveToStart(page); await assertSelection(page, { anchorOffset: 0, - anchorPath: [0, 0, 0], + anchorPath: [1, 0, 0], focusOffset: 0, - focusPath: [0, 0, 0], + focusPath: [1, 0, 0], }); await moveToEnd(page); await assertSelection(page, { anchorOffset: 5, - anchorPath: [0, 1, 0], + anchorPath: [1, 1, 0], focusOffset: 5, - focusPath: [0, 1, 0], + focusPath: [1, 1, 0], }); await moveToStart(page); await assertSelection(page, { anchorOffset: 1, - anchorPath: [0, 1, 0], + anchorPath: [1, 1, 0], focusOffset: 1, - focusPath: [0, 1, 0], + focusPath: [1, 1, 0], }); await selectCharacters(page, 'right', 11); await assertSelection(page, { anchorOffset: 1, - anchorPath: [0, 1, 0], + anchorPath: [1, 1, 0], focusOffset: 5, - focusPath: [0, 4, 0], + focusPath: [1, 4, 0], }); await moveToEnd(page); await assertSelection(page, { anchorOffset: 5, - anchorPath: [0, 4, 0], + anchorPath: [1, 4, 0], focusOffset: 5, - focusPath: [0, 4, 0], + focusPath: [1, 4, 0], }); }); @@ -1307,6 +1342,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, html` +


{ data-lexical-text="true"> let d = 4; +


`, ); } else { @@ -1367,6 +1404,7 @@ test.describe('CodeBlock', () => { await assertHTML( page, html` +


{ +


{ code


+


diff --git a/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs b/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs index 1320f0d655d..ee93eb099e4 100644 --- a/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs @@ -106,7 +106,7 @@ test.describe.parallel('Markdown', () => { }, { expectation: - '
', + '




', importExpectation: '', isBlockTest: true, markdownImport: '', diff --git a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs index a0513a361f7..06bf48b492a 100644 --- a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs @@ -156,6 +156,7 @@ test.describe.parallel('Selection', () => {

Line1

+


{ data-language="javascript"> Line2 +


`, ); }); diff --git a/packages/lexical-playground/__tests__/e2e/Tab.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tab.spec.mjs index 200fd9a0652..79deeb8de73 100644 --- a/packages/lexical-playground/__tests__/e2e/Tab.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tab.spec.mjs @@ -101,6 +101,7 @@ test.describe('Tab', () => { await assertHTML( page, html` +


{ function +


`, ); }); diff --git a/packages/lexical-playground/__tests__/regression/1384-insert-nodes.spec.mjs b/packages/lexical-playground/__tests__/regression/1384-insert-nodes.spec.mjs index 3622c1af932..7a02631ceb4 100644 --- a/packages/lexical-playground/__tests__/regression/1384-insert-nodes.spec.mjs +++ b/packages/lexical-playground/__tests__/regression/1384-insert-nodes.spec.mjs @@ -44,6 +44,7 @@ test.describe('Regression test #1384', () => { await assertHTML( page, html` +


{ ; +


`, ); }); diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 2f96c0d85e0..148b93ae809 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -191,7 +191,6 @@ export default function Editor(): JSX.Element { - {!(isCollab && useCollabV2) && ( + {isCollab ? ( useCollabV2 ? ( <> diff --git a/packages/lexical-playground/src/plugins/CodePlugin/index.tsx b/packages/lexical-playground/src/plugins/CodePlugin/index.tsx index fdc92cb47f1..5280756c918 100644 --- a/packages/lexical-playground/src/plugins/CodePlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/CodePlugin/index.tsx @@ -19,16 +19,26 @@ export default function CodePlugin(): null { } return editor.registerNodeTransform(CodeNode, (codeNode) => { - // This is to ensure that there is a paragraph node always present after the code node + // Skip if not a direct child of root if (codeNode.getParent() !== $getRoot()) { return codeNode; } + // Skip if this is markdown mode (single CodeNode with language 'markdown') + if (codeNode.getLanguage() === 'markdown') { + const root = $getRoot(); + const children = root.getChildren(); + if (children.length === 1) { + return codeNode; + } + } + + // Ensure there is a paragraph node after the code node if (!codeNode.getNextSibling()) { codeNode.insertAfter($createParagraphNode()); } - // This is to ensure that there is a paragraph node always present before the code node + // Ensure there is a paragraph node before the code node if (!codeNode.getPreviousSibling()) { codeNode.insertBefore($createParagraphNode()); }