diff --git a/packages/lexical-code-core/src/CodeImportExtension.ts b/packages/lexical-code-core/src/CodeImportExtension.ts index 8a0131bb25a..a824ffbe970 100644 --- a/packages/lexical-code-core/src/CodeImportExtension.ts +++ b/packages/lexical-code-core/src/CodeImportExtension.ts @@ -23,6 +23,7 @@ import { import {$createCodeNode} from './CodeNode'; const LANGUAGE_DATA_ATTRIBUTE = 'data-language'; +const THEME_DATA_ATTRIBUTE = 'data-theme'; /** * True for elements whose `font-family` mentions `monospace` — the @@ -63,11 +64,10 @@ const GitHubCodeTableOverlayRules = /* @__PURE__ */ defineOverlayRules([ const PreRule = /* @__PURE__ */ defineImportRule({ $import: (ctx, el) => [ - $createCodeNode(el.getAttribute(LANGUAGE_DATA_ATTRIBUTE)).splice( - 0, - 0, - ctx.$importChildren(el), - ), + $createCodeNode( + el.getAttribute(LANGUAGE_DATA_ATTRIBUTE), + el.getAttribute(THEME_DATA_ATTRIBUTE), + ).splice(0, 0, ctx.$importChildren(el)), ], match: sel.tag('pre'), name: '@lexical/code/pre', @@ -87,11 +87,10 @@ const MultilineCodeRule = /* @__PURE__ */ defineImportRule({ return $next(); } return [ - $createCodeNode(el.getAttribute(LANGUAGE_DATA_ATTRIBUTE)).splice( - 0, - 0, - ctx.$importChildren(el), - ), + $createCodeNode( + el.getAttribute(LANGUAGE_DATA_ATTRIBUTE), + el.getAttribute(THEME_DATA_ATTRIBUTE), + ).splice(0, 0, ctx.$importChildren(el)), ]; }, match: sel.tag('code'), diff --git a/packages/lexical-code-core/src/CodeIndentation.ts b/packages/lexical-code-core/src/CodeIndentation.ts index aa7452b4e9c..8bc961c41f9 100644 --- a/packages/lexical-code-core/src/CodeIndentation.ts +++ b/packages/lexical-code-core/src/CodeIndentation.ts @@ -206,6 +206,32 @@ function $handleTab(shiftKey: boolean): null | LexicalCommand { return indentOrOutdent; } +/** + * Outdent the single line the collapsed caret sits on. + * + * `$getCodeLines` drops a trailing line when the selection ends exactly at its + * start — for a collapsed caret that is always true, so the caret's own line + * never reaches the outdent loop and the line has to be resolved from the + * anchor here. Applies the same rule as that loop: strip a leading TabNode, or + * `tabSize` leading spaces when the extension is configured for them. + */ +function $outdentLineAtCaret( + selection: RangeSelection, + tabSize: number | undefined, +): void { + const anchorNode = selection.anchor.getNode(); + // An element point (e.g. the caret on a blank line) has no line to outdent. + if (!$isCodeHighlightNode(anchorNode) && !$isTabNode(anchorNode)) { + return; + } + const firstOfLine = $getFirstCodeNodeOfLine(anchorNode); + if ($isTabNode(firstOfLine)) { + firstOfLine.remove(); + } else if (tabSize !== undefined && $isCodeHighlightNode(firstOfLine)) { + $outdentLeadingSpaces(firstOfLine, tabSize, selection); + } +} + function $handleMultilineIndent( type: LexicalCommand, tabSize?: number, @@ -223,6 +249,8 @@ function $handleMultilineIndent( if (codeLinesLength === 0 && selection.isCollapsed()) { if (type === INDENT_CONTENT_COMMAND) { selection.insertNodes([$createTabNode()]); + } else { + $outdentLineAtCaret(selection, tabSize); } return true; } @@ -392,10 +420,15 @@ function $handleShiftLines( return true; } + // A LineBreakNode sibling means the adjacent line is blank, so it has no + // node of its own to anchor the move to — $getFirstCodeNodeOfLine / + // $getLastCodeNodeOfLine hand that linebreak straight back, and it belongs + // to a *different* line. Anchoring on it splices the moving line into the + // line on the far side of the blank one, merging the two. + const adjacentLineIsBlank = $isLineBreakNode(sibling); const maybeInsertionPoint = - $isCodeHighlightNode(sibling) || - $isTabNode(sibling) || - $isLineBreakNode(sibling) + !adjacentLineIsBlank && + ($isCodeHighlightNode(sibling) || $isTabNode(sibling)) ? arrowIsUp ? $getFirstCodeNodeOfLine(sibling) : $getLastCodeNodeOfLine(sibling) @@ -404,7 +437,15 @@ function $handleShiftLines( maybeInsertionPoint != null ? maybeInsertionPoint : sibling; linebreak.remove(); range.forEach(node => node.remove()); - if (type === KEY_ARROW_UP_COMMAND) { + if (adjacentLineIsBlank) { + // The blank line's position is immediately after the sibling linebreak, + // in both directions. + range.forEach(node => { + insertionPoint.insertAfter(node); + insertionPoint = node; + }); + insertionPoint.insertAfter(linebreak); + } else if (type === KEY_ARROW_UP_COMMAND) { range.forEach(node => insertionPoint.insertBefore(node)); insertionPoint.insertBefore(linebreak); } else { diff --git a/packages/lexical-code-core/src/CodeNode.ts b/packages/lexical-code-core/src/CodeNode.ts index 6f6a97a5d36..8e4e5deb837 100644 --- a/packages/lexical-code-core/src/CodeNode.ts +++ b/packages/lexical-code-core/src/CodeNode.ts @@ -417,7 +417,10 @@ export function $isCodeNode( function $convertPreElement(domNode: HTMLElement): DOMConversionOutput { const language = domNode.getAttribute(LANGUAGE_DATA_ATTRIBUTE); - return {node: $createCodeNode(language)}; + // exportDOM writes data-theme next to data-language, so read it back here + // too — otherwise the theme is dropped on every HTML round trip. + const theme = domNode.getAttribute(THEME_DATA_ATTRIBUTE); + return {node: $createCodeNode(language, theme)}; } function $convertDivElement(domNode: Node): DOMConversionOutput { diff --git a/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts index ad2dc71dabe..9cf25caaef7 100644 --- a/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts +++ b/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts @@ -83,6 +83,32 @@ describe('CodeImportExtension', () => { }); }); + test('
 restores the theme', () => {
+    using editor = buildEditor();
+    importInto(
+      editor,
+      '
x
', + ); + editor.read(() => { + const node = $rootCode(); + expect(node.getLanguage()).toBe('ts'); + expect(node.getTheme()).toBe('poimandres'); + }); + }); + + test('multi-line restores the theme', () => { + using editor = buildEditor(); + importInto( + editor, + 'a\nb', + ); + editor.read(() => { + const node = $rootCode(); + expect(node.getLanguage()).toBe('ts'); + expect(node.getTheme()).toBe('poimandres'); + }); + }); + test('multi-line imports as CodeNode (not inline)', () => { using editor = buildEditor(); importInto(editor, 'line1\nline2'); diff --git a/packages/lexical-code-core/src/__tests__/unit/CodeIndentation.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeIndentation.test.ts index 42e09abadab..8c5b047505f 100644 --- a/packages/lexical-code-core/src/__tests__/unit/CodeIndentation.test.ts +++ b/packages/lexical-code-core/src/__tests__/unit/CodeIndentation.test.ts @@ -14,6 +14,7 @@ import { import {buildEditorFromExtensions} from '@lexical/extension'; import {RichTextExtension} from '@lexical/rich-text'; import { + $createLineBreakNode, $createParagraphNode, $getRoot, $isParagraphNode, @@ -23,6 +24,7 @@ import { KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, + type LexicalCommand, } from 'lexical'; import {describe, expect, it} from 'vitest'; @@ -375,4 +377,63 @@ describe('CodeIndentExtension', () => { ); }); }); + + describe('shiftLines', () => { + // "A", "" and "B" — a blank line between two lines of code. + function buildBlankLineEditor(caretOnLastLine: boolean) { + const ext = defineExtension({ + $initialEditorState: () => { + const codeNode = $createCodeNode('javascript'); + const first = $createCodeHighlightNode('A'); + const last = $createCodeHighlightNode('B'); + codeNode.append( + first, + $createLineBreakNode(), + $createLineBreakNode(), + last, + ); + $getRoot().append(codeNode); + (caretOnLastLine ? last : first).select(0, 0); + }, + dependencies: [CodeIndentExtension, RichTextExtension], + name: '[root-shift-lines]', + }); + return buildEditorFromExtensions(ext); + } + + function shift( + editor: ReturnType, + command: LexicalCommand, + ) { + const key = command === KEY_ARROW_UP_COMMAND ? 'ArrowUp' : 'ArrowDown'; + editor.dispatchCommand( + command, + new KeyboardEvent('keydown', {altKey: true, key}), + ); + } + + it('moves a line up past a blank line without merging it into the line above', () => { + using editor = buildBlankLineEditor(true); + + shift(editor, KEY_ARROW_UP_COMMAND); + + editor.read(() => { + const codeNode = $getRoot().getFirstChildOrThrow(); + expect($isCodeNode(codeNode)).toBe(true); + expect(codeNode.getTextContent()).toBe('A\nB\n'); + }); + }); + + it('moves a line down past a blank line without merging it into the line below', () => { + using editor = buildBlankLineEditor(false); + + shift(editor, KEY_ARROW_DOWN_COMMAND); + + editor.read(() => { + const codeNode = $getRoot().getFirstChildOrThrow(); + expect($isCodeNode(codeNode)).toBe(true); + expect(codeNode.getTextContent()).toBe('\nA\nB'); + }); + }); + }); }); diff --git a/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts index c2667207841..e024bb7d0a4 100644 --- a/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts +++ b/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts @@ -6,10 +6,11 @@ * */ -import {$createCodeNode} from '@lexical/code-core'; +import {$createCodeNode, $isCodeNode, CodeNode} from '@lexical/code-core'; +import {$generateNodesFromDOM} from '@lexical/html'; import {$getRoot, type EditorConfig} from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; -import {describe, expect, it} from 'vitest'; +import {assert, describe, expect, it} from 'vitest'; const editorConfig = { namespace: '', @@ -68,10 +69,37 @@ describe('CodeNode', () => { expect(exportedElement!.style.padding).toBe('1px'); expect(exportedElement!.style.color).toBe('blue'); }); + + it('round-trips the theme through exportDOM/importDOM', async () => { + const {editor} = testEnv; + + let exportedElement!: HTMLElement; + + await editor.update(() => { + const codeNode = $createCodeNode('javascript', 'poimandres'); + $getRoot().append(codeNode); + exportedElement = codeNode.exportDOM(editor).element as HTMLElement; + }); + + expect(exportedElement.getAttribute('data-language')).toBe( + 'javascript', + ); + expect(exportedElement.getAttribute('data-theme')).toBe('poimandres'); + + const doc = document.implementation.createHTMLDocument(); + doc.body.append(exportedElement); + + await editor.update(() => { + const [node] = $generateNodesFromDOM(editor, doc); + assert($isCodeNode(node), 'expected a CodeNode'); + expect(node.getLanguage()).toBe('javascript'); + expect(node.getTheme()).toBe('poimandres'); + }); + }); }, { namespace: 'test', - nodes: [], + nodes: [CodeNode], theme: editorConfig.theme, }, ); diff --git a/packages/lexical-code-core/src/__tests__/unit/CodeOutdentCollapsedAtLineStart.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeOutdentCollapsedAtLineStart.test.ts new file mode 100644 index 00000000000..ca12b9d490f --- /dev/null +++ b/packages/lexical-code-core/src/__tests__/unit/CodeOutdentCollapsedAtLineStart.test.ts @@ -0,0 +1,161 @@ +/** + * 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 { + $createCodeHighlightNode, + $createCodeNode, + $isCodeNode, + CodeIndentExtension, +} from '@lexical/code'; +import {buildEditorFromExtensions} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createTabNode, + $getRoot, + $isTabNode, + $isTextNode, + configExtension, + defineExtension, + OUTDENT_CONTENT_COMMAND, +} from 'lexical'; +import {assert, describe, expect, it} from 'vitest'; + +// Shift+Tab (OUTDENT_CONTENT_COMMAND) with a *collapsed* caret at column 0 of +// an indented code line silently did nothing: $getCodeLines drops a trailing +// line when the selection ends exactly at its start, which for a collapsed +// caret is the only line, so the outdent loop had nothing to work on. + +function buildEditor(tabSize?: number) { + return buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => { + const code = $createCodeNode('javascript'); + code.append($createTabNode(), $createCodeHighlightNode('hello')); + $getRoot().append(code); + }, + dependencies: [ + tabSize === undefined + ? CodeIndentExtension + : configExtension(CodeIndentExtension, {tabSize}), + RichTextExtension, + ], + name: '[root-outdent]', + }), + ); +} + +function $codeText(): string { + const code = $getRoot().getFirstChildOrThrow(); + assert($isCodeNode(code), 'expected a CodeNode'); + return code.getTextContent(); +} + +describe('OUTDENT_CONTENT_COMMAND at the start of a code line', () => { + it('outdents when the caret is collapsed at column 0 (on the TabNode)', () => { + using editor = buildEditor(); + + editor.update( + () => { + const tab = $getRoot().getFirstDescendant(); + assert($isTabNode(tab), 'expected a TabNode'); + tab.select(0, 0); + }, + {discrete: true}, + ); + editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); + + expect(editor.read($codeText)).toBe('hello'); + }); + + it('outdents when the caret is collapsed at column 0 of the code text', () => { + using editor = buildEditor(); + + editor.update( + () => { + const text = $getRoot().getLastDescendant(); + assert(text !== null, 'expected a text node'); + text.selectStart(); + }, + {discrete: true}, + ); + editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); + + expect(editor.read($codeText)).toBe('hello'); + }); + + it('still outdents from a non-zero column (unchanged behaviour)', () => { + using editor = buildEditor(); + + editor.update( + () => { + const text = $getRoot().getLastDescendant(); + assert($isTextNode(text), 'expected a text node'); + text.select(1, 1); + }, + {discrete: true}, + ); + editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); + + expect(editor.read($codeText)).toBe('hello'); + }); + + it('strips a space indent from column 0 when tabSize is configured', () => { + using editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => { + const code = $createCodeNode('javascript'); + code.append($createCodeHighlightNode(' hello')); + $getRoot().append(code); + }, + dependencies: [ + configExtension(CodeIndentExtension, {tabSize: 2}), + RichTextExtension, + ], + name: '[root-outdent-spaces]', + }), + ); + + editor.update( + () => { + const text = $getRoot().getLastDescendant(); + assert(text !== null, 'expected a text node'); + text.selectStart(); + }, + {discrete: true}, + ); + editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); + + expect(editor.read($codeText)).toBe('hello'); + }); + + it('is still a no-op on an unindented line', () => { + using editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => { + const code = $createCodeNode('javascript'); + code.append($createCodeHighlightNode('hello')); + $getRoot().append(code); + }, + dependencies: [CodeIndentExtension, RichTextExtension], + name: '[root-outdent-flat]', + }), + ); + + editor.update( + () => { + const text = $getRoot().getLastDescendant(); + assert(text !== null, 'expected a text node'); + text.selectStart(); + }, + {discrete: true}, + ); + editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); + + expect(editor.read($codeText)).toBe('hello'); + }); +}); diff --git a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts index ec0493aa561..1fc617dfe95 100644 --- a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts +++ b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts @@ -218,6 +218,17 @@ function $updateAndRetainSelection( } const anchor = selection.anchor; + // The selection is restored by walking this code node's children, so it can + // only be retained when it actually points inside this code node. When the + // selection lives elsewhere there is nothing to retain and restoring would + // drag the caret into the code block instead of leaving it where the user + // put it. + const anchorNode = anchor.getNode(); + if (anchorNode !== node && !node.isParentOf(anchorNode)) { + updateFn(); + return; + } + const anchorOffset = anchor.offset; const isNewLineAnchor = anchor.type === 'element' && @@ -226,7 +237,6 @@ function $updateAndRetainSelection( // Calculating previous text offset (all text node prior to anchor + anchor own text offset) if (!isNewLineAnchor) { - const anchorNode = anchor.getNode(); textOffset = anchorOffset + anchorNode.getPreviousSiblings().reduce((offset, _node) => { @@ -247,19 +257,30 @@ function $updateAndRetainSelection( } // If it was non-element anchor then we walk through child nodes - // and looking for a position of original text offset - node.getChildren().some(_node => { - const isText = $isTextNode(_node); - if (isText || $isLineBreakNode(_node)) { - const textContentSize = _node.getTextContentSize(); - if (isText && textContentSize >= textOffset) { - _node.select(textOffset, textOffset); - return true; + // and looking for a position of original text offset. A LineBreakNode + // consumes one unit of the offset but can't host a text point, so when the + // offset lands on one we use an element point on the code node instead of + // letting the offset go negative and selecting the next text node at an + // out-of-range position. + const children = node.getChildren(); + for (let index = 0; index < children.length; index++) { + const child = children[index]; + if ($isTextNode(child)) { + const textContentSize = child.getTextContentSize(); + if (textContentSize >= textOffset) { + child.select(textOffset, textOffset); + return; } textOffset -= textContentSize; + } else if ($isLineBreakNode(child)) { + if (textOffset === 0) { + node.select(index, index); + return; + } + textOffset -= 1; } - return false; - }); + } + node.select(children.length, children.length); } // Finds minimal diff range between two nodes lists. It returns from/to range boundaries of prevNodes diff --git a/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts new file mode 100644 index 00000000000..140e3cdfc17 --- /dev/null +++ b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts @@ -0,0 +1,71 @@ +/** + * 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 {$createCodeNode} from '@lexical/code'; +import {registerCodeHighlighting} from '@lexical/code-prism'; +import { + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, +} from 'lexical'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {describe, expect, test} from 'vitest'; + +describe('CodeHighlighterPrism $updateAndRetainSelection', () => { + initializeUnitTest(testEnv => { + test.each([ + ['\nfoo', 'X\nfoo'], + ['\n\nfoo', 'X\n\nfoo'], + ['foo\nbar', 'Xfoo\nbar'], + ])( + 'retains a non-negative offset for code content %j (#8943)', + async (codeText, expectedText) => { + const {editor} = testEnv; + registerCodeHighlighting(editor); + + await editor.update(() => { + const code = $createCodeNode('javascript'); + $getRoot().clear().append(code); + code.append($createTextNode(codeText)); + // Caret at the very start of the code block, while its content is + // still un-flattened, so the highlighting transform has to restore it. + code.select(0, 0); + }); + + editor.read(() => { + const selection = $getSelection(); + expect($isRangeSelection(selection)).toBe(true); + if (!$isRangeSelection(selection)) { + return; + } + for (const point of [selection.anchor, selection.focus]) { + expect(point.offset).toBeGreaterThanOrEqual(0); + if (point.type === 'text') { + expect(point.offset).toBeLessThanOrEqual( + point.getNode().getTextContentSize(), + ); + } + } + }); + + await editor.update(() => { + const selection = $getSelection(); + expect($isRangeSelection(selection)).toBe(true); + if ($isRangeSelection(selection)) { + selection.insertText('X'); + } + }); + + expect( + editor.read(() => $getRoot().getFirstChild()!.getTextContent()), + ).toBe(expectedText); + }, + ); + }); +}); diff --git a/packages/lexical-code-prism/src/__tests__/unit/CodePrismRetainSelection.test.ts b/packages/lexical-code-prism/src/__tests__/unit/CodePrismRetainSelection.test.ts new file mode 100644 index 00000000000..880250e2888 --- /dev/null +++ b/packages/lexical-code-prism/src/__tests__/unit/CodePrismRetainSelection.test.ts @@ -0,0 +1,133 @@ +/** + * 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 {$createCodeNode, $isCodeNode} from '@lexical/code-core'; +import {CodePrismExtension} from '@lexical/code-prism'; +import {buildEditorFromExtensions} from '@lexical/extension'; +import {$convertFromMarkdownString, TRANSFORMERS} from '@lexical/markdown'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + defineExtension, +} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +function createEditor() { + return buildEditorFromExtensions( + defineExtension({ + dependencies: [RichTextExtension, CodePrismExtension], + name: 'code-prism-retain-selection-test', + }), + ); +} + +/** + * Describes where the caret ended up, in terms that survive the highlighter + * splitting a code block's text into CodeHighlightNodes. + */ +function $describeCaret() { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return {selection: String(selection)}; + } + const anchorNode = selection.anchor.getNode(); + return { + blocks: $getRoot() + .getChildren() + .map(child => child.getType()), + inCodeBlock: + $isCodeNode(anchorNode) || anchorNode.getParents().some($isCodeNode), + offset: selection.anchor.offset, + text: anchorNode.getTextContent(), + }; +} + +/** + * $convertFromMarkdownString only moves the caret when there already is one, + * which is the case when a user pastes markdown into a focused editor. + */ +function $seedSelection(): void { + const paragraph = $createParagraphNode(); + $getRoot().clear().append(paragraph); + paragraph.selectEnd(); +} + +describe('Prism highlighting only retains a selection it owns (#6305)', () => { + test('importing markdown that ends in a code block leaves the caret at the document start', () => { + using editor = createEditor(); + + editor.update($seedSelection, {discrete: true}); + editor.update( + () => { + $convertFromMarkdownString( + 'hello world\n\n```\necho hello world\n```', + TRANSFORMERS, + ); + }, + {discrete: true}, + ); + + expect(editor.read($describeCaret)).toEqual({ + blocks: ['paragraph', 'code'], + inCodeBlock: false, + offset: 0, + text: 'hello world', + }); + }); + + test('the caret is not dragged into the last of several imported code blocks', () => { + using editor = createEditor(); + + editor.update($seedSelection, {discrete: true}); + editor.update( + () => { + $convertFromMarkdownString( + 'hello world\n\n```\nfirst\n```\n\nmiddle\n\n```\nlast\n```', + TRANSFORMERS, + ); + }, + {discrete: true}, + ); + + expect(editor.read($describeCaret)).toEqual({ + blocks: ['paragraph', 'code', 'paragraph', 'code'], + inCodeBlock: false, + offset: 0, + text: 'hello world', + }); + }); + + // Control: this selection really is inside the code block, so it passes with + // and without the fix. It guards against "fixing" #6305 by never restoring. + test('a caret inside the code block is still retained across highlighting', () => { + using editor = createEditor(); + + editor.update( + () => { + const codeNode = $createCodeNode('javascript'); + const textNode = $createTextNode('const x = 1;'); + codeNode.append(textNode); + $getRoot().clear().append(codeNode); + // 'const ' — the caret sits inside what becomes a token boundary. + textNode.select(6, 6); + }, + {discrete: true}, + ); + + const caret = editor.read($describeCaret); + expect(caret.inCodeBlock).toBe(true); + // The highlighter split the single TextNode into tokens, so the caret + // rides along to the token that now holds text offset 6. + expect(caret.text).toBe(' x '); + expect(caret.offset).toBe(1); + }); +}); diff --git a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts index d363172f495..d09164a8224 100644 --- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts +++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts @@ -243,6 +243,17 @@ function $updateAndRetainSelection( } const anchor = selection.anchor; + // The selection is restored by walking this code node's children, so it can + // only be retained when it actually points inside this code node. When the + // selection lives elsewhere there is nothing to retain and restoring would + // drag the caret into the code block instead of leaving it where the user + // put it. + const anchorNode = anchor.getNode(); + if (anchorNode !== node && !node.isParentOf(anchorNode)) { + updateFn(); + return; + } + const anchorOffset = anchor.offset; const isNewLineAnchor = anchor.type === 'element' && @@ -251,7 +262,6 @@ function $updateAndRetainSelection( // Calculating previous text offset (all text node prior to anchor + anchor own text offset) if (!isNewLineAnchor) { - const anchorNode = anchor.getNode(); textOffset = anchorOffset + anchorNode.getPreviousSiblings().reduce((offset, _node) => { @@ -272,19 +282,30 @@ function $updateAndRetainSelection( } // If it was non-element anchor then we walk through child nodes - // and looking for a position of original text offset - node.getChildren().some(_node => { - const isText = $isTextNode(_node); - if (isText || $isLineBreakNode(_node)) { - const textContentSize = _node.getTextContentSize(); - if (isText && textContentSize >= textOffset) { - _node.select(textOffset, textOffset); - return true; + // and looking for a position of original text offset. A LineBreakNode + // consumes one unit of the offset but can't host a text point, so when the + // offset lands on one we use an element point on the code node instead of + // letting the offset go negative and selecting the next text node at an + // out-of-range position. + const children = node.getChildren(); + for (let index = 0; index < children.length; index++) { + const child = children[index]; + if ($isTextNode(child)) { + const textContentSize = child.getTextContentSize(); + if (textContentSize >= textOffset) { + child.select(textOffset, textOffset); + return; } textOffset -= textContentSize; + } else if ($isLineBreakNode(child)) { + if (textOffset === 0) { + node.select(index, index); + return; + } + textOffset -= 1; } - return false; - }); + } + node.select(children.length, children.length); } // Finds minimal diff range between two nodes lists. It returns from/to range boundaries of prevNodes diff --git a/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts new file mode 100644 index 00000000000..8ecabe6934b --- /dev/null +++ b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts @@ -0,0 +1,184 @@ +/** + * 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 {$createCodeNode} from '@lexical/code'; +import {$isCodeNode} from '@lexical/code-core'; +import { + CodeShikiExtension, + loadCodeLanguage, + loadCodeTheme, +} from '@lexical/code-shiki'; +import {buildEditorFromExtensions} from '@lexical/extension'; +import {$convertFromMarkdownString, TRANSFORMERS} from '@lexical/markdown'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + defineExtension, +} from 'lexical'; +import {beforeAll, describe, expect, test} from 'vitest'; + +function createEditor() { + return buildEditorFromExtensions( + defineExtension({ + dependencies: [RichTextExtension, CodeShikiExtension], + name: 'code-shiki-retain-selection-test', + }), + ); +} + +describe('CodeHighlighterShiki $updateAndRetainSelection', () => { + beforeAll(async () => { + // Shiki defers highlighting until the grammar and theme have loaded, so + // load them up front to make the transform run on the first update. + await loadCodeLanguage('javascript'); + await loadCodeTheme('one-light'); + }); + + test.each([ + ['\nfoo', 'X\nfoo'], + ['\n\nfoo', 'X\n\nfoo'], + ['foo\nbar', 'Xfoo\nbar'], + ])( + 'retains a non-negative offset for code content %j (#8943)', + (codeText, expectedText) => { + using editor = createEditor(); + + editor.update( + () => { + const code = $createCodeNode('javascript'); + $getRoot().clear().append(code); + code.append($createTextNode(codeText)); + // Caret at the very start of the code block, while its content is + // still un-flattened, so the highlighting transform has to restore it. + code.select(0, 0); + }, + {discrete: true}, + ); + + editor.read(() => { + const selection = $getSelection(); + expect($isRangeSelection(selection)).toBe(true); + if (!$isRangeSelection(selection)) { + return; + } + for (const point of [selection.anchor, selection.focus]) { + expect(point.offset).toBeGreaterThanOrEqual(0); + if (point.type === 'text') { + expect(point.offset).toBeLessThanOrEqual( + point.getNode().getTextContentSize(), + ); + } + } + }); + + editor.update( + () => { + const selection = $getSelection(); + expect($isRangeSelection(selection)).toBe(true); + if ($isRangeSelection(selection)) { + selection.insertText('X'); + } + }, + {discrete: true}, + ); + + expect( + editor.read(() => $getRoot().getFirstChild()!.getTextContent()), + ).toBe(expectedText); + }, + ); +}); + +/** + * Shiki loads grammars and themes asynchronously, so the highlight transform + * that rewrites the code block's children only runs once those settle. + */ +async function settleHighlighting(): Promise { + await loadCodeLanguage('javascript'); + await loadCodeTheme('one-light'); + await Promise.resolve(); +} + +function $describeCaret() { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return {selection: String(selection)}; + } + const anchorNode = selection.anchor.getNode(); + return { + blocks: $getRoot() + .getChildren() + .map(child => child.getType()), + inCodeBlock: + $isCodeNode(anchorNode) || anchorNode.getParents().some($isCodeNode), + offset: selection.anchor.offset, + text: anchorNode.getTextContent(), + }; +} + +/** + * $convertFromMarkdownString only moves the caret when there already is one, + * which is the case when a user pastes markdown into a focused editor. + */ +function $seedSelection(): void { + const paragraph = $createParagraphNode(); + $getRoot().clear().append(paragraph); + paragraph.selectEnd(); +} + +describe('Shiki highlighting only retains a selection it owns (#6305)', () => { + test('importing markdown that ends in a code block leaves the caret at the document start', async () => { + using editor = createEditor(); + + editor.update($seedSelection, {discrete: true}); + editor.update( + () => { + $convertFromMarkdownString( + 'hello world\n\n```\necho hello world\n```', + TRANSFORMERS, + ); + }, + {discrete: true}, + ); + await settleHighlighting(); + + expect(editor.read($describeCaret)).toEqual({ + blocks: ['paragraph', 'code'], + inCodeBlock: false, + offset: 0, + text: 'hello world', + }); + }); + + test('the caret is not dragged into the last of several imported code blocks', async () => { + using editor = createEditor(); + + editor.update($seedSelection, {discrete: true}); + editor.update( + () => { + $convertFromMarkdownString( + 'hello world\n\n```\nfirst\n```\n\nmiddle\n\n```\nlast\n```', + TRANSFORMERS, + ); + }, + {discrete: true}, + ); + await settleHighlighting(); + + expect(editor.read($describeCaret)).toEqual({ + blocks: ['paragraph', 'code', 'paragraph', 'code'], + inCodeBlock: false, + offset: 0, + text: 'hello world', + }); + }); +}); diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts index 12e69705ecb..4bd499b917c 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts @@ -39,6 +39,7 @@ import { $isParagraphNode, $isTextNode, $setState, + type AnyLexicalExtension, configExtension, createState, defineExtension, @@ -355,7 +356,7 @@ describe('DOMImportExtension', () => { }); }); - test('rule priority: later-registered rule runs first; can call $next()', () => { + test('rule priority: the earlier entry in `rules` runs first; can call $next()', () => { using editor = buildTestEditor([ IdAttributeRule, AnchorRule, @@ -370,6 +371,87 @@ describe('DOMImportExtension', () => { }); }); + test('rules contributed by dependents outrank their dependencies', () => { + // A rule that records the extension that contributed it and then + // defers, so the visit order of the whole chain is observable. + const visited: string[] = []; + const traceRule = (name: string) => + defineImportRule({ + $import: (_ctx, _el, $next) => { + visited.push(name); + return $next(); + }, + match: sel.tag('p'), + name: `test/trace-${name}`, + }); + const makeExtension = ( + name: string, + dependencies: AnyLexicalExtension[] = [], + ) => + defineExtension({ + dependencies: [ + ...dependencies, + configExtension(DOMImportExtension, { + // Two rules from the same extension, to show that a + // contribution is inlined as a contiguous chunk. + rules: [traceRule(`${name}-a`), traceRule(`${name}-b`)], + }), + ], + name: `test-${name}`, + }); + const leaf = makeExtension('leaf'); + const mid = makeExtension('mid', [leaf]); + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [mid], + name: 'test-root', + nodes: [LinkNode], + }), + // Configuration passed directly to the builder is merged last of + // all, so it outranks every extension's contribution. + configExtension(DOMImportExtension, {rules: [traceRule('builder')]}), + ); + importInto(editor, '

x

'); + // Each extension's chunk keeps its own order (a before b), and the + // chunks are ordered from the most dependent contributor to the + // least. + expect(visited).toEqual(['builder', 'mid-a', 'mid-b', 'leaf-a', 'leaf-b']); + }); + + test('among sibling dependencies, the later-listed one outranks', () => { + // Pins the order that falls out of the topological sort for two + // extensions where neither depends on the other. Documented as an + // implementation detail rather than an API guarantee — an extension + // that must override another's rules should depend on it — but the + // behavior is worth knowing about when debugging dispatch. + const visited: string[] = []; + const traceRule = (name: string) => + defineImportRule({ + $import: (_ctx, _el, $next) => { + visited.push(name); + return $next(); + }, + match: sel.tag('p'), + name: `test/sibling-${name}`, + }); + const makeExtension = (name: string) => + defineExtension({ + dependencies: [ + configExtension(DOMImportExtension, {rules: [traceRule(name)]}), + ], + name: `test-sibling-${name}`, + }); + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [makeExtension('first'), makeExtension('second')], + name: 'test-sibling-root', + nodes: [LinkNode], + }), + ); + importInto(editor, '

x

'); + expect(visited).toEqual(['second', 'first']); + }); + test('CSS parser: parseSelector("p.foo") matches as expected', () => { const cssRule = defineImportRule({ $import: () => { diff --git a/packages/lexical-html/src/__tests__/unit/DOMPreprocess.test.ts b/packages/lexical-html/src/__tests__/unit/DOMPreprocess.test.ts index 4e87b505bfc..8b61af54339 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMPreprocess.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMPreprocess.test.ts @@ -26,6 +26,7 @@ import { $getEditor, $getRoot, $isParagraphNode, + type AnyLexicalExtension, defineExtension, isHTMLElement, type LexicalEditor, @@ -273,4 +274,40 @@ describe('DOMImportExtension preprocess', () => { // Per-call appends to the stack (highest index = runs first). expect(log).toEqual(['per-call', 'config']); }); + + test('preprocessors contributed by dependents run before their dependencies', () => { + const log: string[] = []; + const trace = + (name: string): DOMPreprocessFn => + (_dom, _ctx, $next) => { + log.push(name); + $next(); + }; + const makeExtension = ( + name: string, + dependencies: AnyLexicalExtension[] = [], + ) => + defineExtension({ + dependencies: [ + ...dependencies, + configExtension(DOMImportExtension, { + preprocess: [trace(`${name}-a`), trace(`${name}-b`)], + }), + ], + name: `preprocess-${name}`, + }); + const leaf = makeExtension('leaf'); + const mid = makeExtension('mid', [leaf]); + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [CoreImportExtension, mid], + name: 'preprocess-root', + }), + ); + importInto(editor, '

x

'); + // The stack is run from its end, so the LAST entry of a contribution + // runs first, and a dependent extension's whole contribution runs + // before its dependency's. + expect(log).toEqual(['mid-b', 'mid-a', 'leaf-b', 'leaf-a']); + }); }); diff --git a/packages/lexical-html/src/import/DOMImportExtension.ts b/packages/lexical-html/src/import/DOMImportExtension.ts index 737ad23ea17..cdb6c55ba9d 100644 --- a/packages/lexical-html/src/import/DOMImportExtension.ts +++ b/packages/lexical-html/src/import/DOMImportExtension.ts @@ -41,18 +41,39 @@ import {selBase} from './sel'; */ export interface DOMImportConfig { /** - * The set of rules contributed by this extension and its dependencies. + * The ordered list of rules compiled into the import dispatcher. * Entries can be raw {@link DOMImportRule}s or a * {@link CompiledOverlayRules} produced by {@link defineOverlayRules} - * (the latter is inlined in priority order — useful for libraries - * that already publish a compiled overlay). + * (the latter is inlined at its position in the list — useful for + * libraries that already publish a compiled overlay). * - * Rules are dispatched in priority order: rules contributed by - * extensions merged later (i.e. closer to the editor root) run first - * and may call `$next()` to delegate to lower-priority rules. + * **Rules are evaluated in list order.** For a given DOM node the + * dispatcher visits every rule whose `match` accepts that node, front + * to back, and the first one that returns without calling `$next()` + * decides the outcome. "Higher priority" and "earlier in this list" + * mean the same thing. * - * `mergeConfig` prepends `partial.rules` to existing `rules`, so later - * configuration carries higher priority. + * **Composition prepends.** `mergeConfig` puts `partial.rules` in + * FRONT of the rules accumulated so far, and configs are merged in + * dependency order — a dependency's contribution is merged before the + * contribution of the extension that depends on it. So: + * + * - Each contributor's array is inlined as one contiguous chunk that + * keeps its own internal order: within a single + * `configExtension(DOMImportExtension, {rules})` call the first + * entry has the highest priority. + * - The chunks are ordered from the most dependent contributor to the + * least. An extension's rules therefore outrank the rules + * contributed by its dependencies, and rules passed directly to + * `buildEditorFromExtensions` (merged last of all) outrank every + * extension's. This extension's own default entry + * ({@link DefaultHoistRule}) is the base of the list and so is + * always tried last. + * + * The relative order of two extensions where neither transitively + * depends on the other falls out of the topological sort and is not + * part of the API. Make the intended precedence explicit by having the + * overriding extension depend on the one whose rules it overrides. */ readonly rules: readonly DOMImportRuleEntry[]; /** @@ -62,16 +83,29 @@ export interface DOMImportConfig { */ readonly contextDefaults: readonly ImportContextPairOrUpdater[]; /** - * Functions run in order on the DOM before walking begins, mutating in - * place. The default config registers - * {@link $inlineStylesFromStyleSheets} (resolves `