From 6d8f737023b800fb04f9eb45bc29f980a9ca780a Mon Sep 17 00:00:00 2001 From: Om Singhal Date: Wed, 23 Sep 2026 21:42:10 -0400 Subject: [PATCH 01/31] [lexical-code-core][lexical-code] Feature: word wrap flag on CodeNode stored in NodeState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description A code block had no way to say that it should soft wrap long lines instead of scrolling sideways. CodeNode now has getWordWrap() and setWordWrap(), stored in a flat NodeState. When it is on: - exportJSON writes "wordWrap": true at the top level. When it is off, the JSON is exactly what it was before, with no wordWrap key and no $. - the element gets data-lexical-code-word-wrap="true". updateDOM only adds or removes the attribute, so the element and its scroll position are kept. - exportDOM writes the same attribute on the
. Both HTML import paths read it back: the CodeNode importDOM and the CodeImportExtension rules for 
 and multi line . A value of "false" counts as off.

importJSON accepts the key at the top level or under $, and updateFromJSON without it turns wrap off. setWordWrap also takes an updater, which leaves the node clean when the value does not change.

Wrapping is visual only. No LineBreakNode is added and the text is unchanged. The package adds no CSS, so a theme decides how a wrapped block looks, and the @lexical/code-core README gets a Word wrap section with the CSS it needs. The Flow types for @lexical/code-core and @lexical/code declare the new methods and the JSON key.

This is the word wrap part of #8459. The scrolling part is fixed by the change this one builds on.

Closes #8459

## Test plan

### Before

New tests, with CodeNode.ts and CodeImportExtension.ts from the parent commit:

pnpm vitest run --project unit packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts

 × CodeImportExtension > 
 turns on word wrap
   TypeError: node.getWordWrap is not a function
 × CodeImportExtension > multi line  turns on word wrap
   TypeError: node.getWordWrap is not a function
 × CodeImportExtension > 
 without the attribute leaves word wrap off
   TypeError: node.getWordWrap is not a function
 × CodeImportExtension > 
 leaves word wrap off
   TypeError: $rootCode(...).getWordWrap is not a function
 × CodeNode > word wrap > serializes wordWrap only while it is on
   TypeError: node.getWordWrap is not a function
 × CodeNode > word wrap > imports wordWrap from the top level or from $
   TypeError: topLevel.getWordWrap is not a function
 × CodeNode > word wrap > updateFromJSON without wordWrap turns it off
   TypeError: (0 , $createCodeNode)(...).setWordWrap is not a function
 × CodeNode > word wrap > createDOM sets the attribute only when on
   TypeError: node.setWordWrap is not a function
 × CodeNode > word wrap > toggling updates the attribute on the same element
   TypeError: node.setWordWrap is not a function
 × CodeNode > word wrap > an updater that keeps the value leaves the node clean
   TypeError: node.setWordWrap is not a function
 × CodeNode > word wrap > round trips through exportDOM and importDOM
   TypeError: node.setWordWrap is not a function
 × CodeNode > word wrap > imports a value of "false" as off
   TypeError: node.getWordWrap is not a function
 Test Files  2 failed (2)
      Tests  12 failed | 17 passed (29)

### After

pnpm vitest run --project unit packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts -t "word wrap"

 ✓ CodeImportExtension > 
 turns on word wrap
 ✓ CodeImportExtension > multi line  turns on word wrap
 ✓ CodeImportExtension > 
 without the attribute leaves word wrap off
 ✓ CodeImportExtension > 
 leaves word wrap off
 ✓ CodeNode > word wrap > serializes wordWrap only while it is on
 ✓ CodeNode > word wrap > imports wordWrap from the top level or from $
 ✓ CodeNode > word wrap > updateFromJSON without wordWrap turns it off
 ✓ CodeNode > word wrap > createDOM sets the attribute only when on
 ✓ CodeNode > word wrap > toggling updates the attribute on the same element
 ✓ CodeNode > word wrap > an updater that keeps the value leaves the node clean
 ✓ CodeNode > word wrap > round trips through exportDOM and importDOM
 ✓ CodeNode > word wrap > imports a value of "false" as off
 Test Files  2 passed (2)
      Tests  12 passed | 17 skipped (29)

pnpm vitest run --project unit packages/lexical-code-core packages/lexical-code/ packages/lexical-code-prism packages/lexical-code-shiki packages/lexical/src/__tests__/unit/LexicalGeneratedJSON.test.ts packages/lexical-fast-check/src/__tests__/unit/CloneCarriesSchemaFields.test.ts

 Test Files  18 passed (18)
      Tests  346 passed | 1 skipped (347)

The generated JSON drift test passes without regenerating, because the codegen leaves NodeState out. The strict exportJSON and createDOM tests in LexicalCodeNode.test.ts pass unchanged. pnpm run tsc and pnpm run flow pass, and prettier and eslint pass on the changed files. Browser mode and e2e tests were not run for this commit.
---
 packages/lexical-code-core/README.md          |  22 ++
 .../flow/LexicalCodeCore.js.flow              |   4 +
 .../src/CodeImportExtension.ts                |  23 +-
 packages/lexical-code-core/src/CodeNode.ts    |  85 ++++++-
 .../unit/CodeImportExtension.test.ts          |  38 +++
 .../src/__tests__/unit/CodeNode.test.ts       | 219 +++++++++++++++++-
 .../lexical-code/flow/LexicalCode.js.flow     |   4 +
 7 files changed, 375 insertions(+), 20 deletions(-)

diff --git a/packages/lexical-code-core/README.md b/packages/lexical-code-core/README.md
index 7bb4297ab3e..8f8d4950eea 100644
--- a/packages/lexical-code-core/README.md
+++ b/packages/lexical-code-core/README.md
@@ -6,6 +6,28 @@ This package contains the base functionality for the code blocks and code highli
 is considered an implementation detail and is being used transitionally while the deprecated highlighting
 functionality is still available in `@lexical/code`.
 
+## Word wrap
+
+`CodeNode.setWordWrap(true)` makes a code block soft wrap lines that are wider than it, instead of scrolling sideways,
+and `getWordWrap()` reads it back. `setWordWrap` also takes an updater, for example `wordWrap => !wordWrap`. Wrapping is
+visual only: no `LineBreakNode` is added and the text is unchanged.
+
+While it is on:
+
+- the node's JSON has `"wordWrap": true`. The key is left out while it is off, so the JSON of other code blocks doesn't
+  change.
+- the code element and the exported `
` get `data-lexical-code-word-wrap="true"`. HTML import reads the attribute
+  back, and a value of `"false"` counts as off.
+
+The package adds no CSS for it. A theme wraps the lines under the attribute:
+
+```css
+code[data-lexical-code-word-wrap] {
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+```
+
 ## Caret scrolling
 
 When Lexical moves the caret itself, it scrolls the code block sideways to show the caret. That happens on the line
diff --git a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow
index c59d07c7cb2..7a81c002658 100644
--- a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow
+++ b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow
@@ -21,6 +21,7 @@ import type {
   TabNode,
   LexicalExtension,
   ExtensionConfigBase,
+  ValueOrUpdater,
 } from 'lexical';
 
 import {ElementNode, TextNode} from 'lexical';
@@ -86,6 +87,7 @@ declare export function $plainifyCodeContent(text: string): LexicalNode[];
 export type SerializedCodeNode = {
   ...SerializedElementNode,
   language: string | null | void,
+  wordWrap?: boolean,
   ...
 };
 
@@ -106,6 +108,8 @@ declare export class CodeNode extends ElementNode {
   collapseAtStart(): true;
   setLanguage(language: string): void;
   getLanguage(): string | void;
+  getWordWrap(): boolean;
+  setWordWrap(valueOrUpdater: ValueOrUpdater): this;
 }
 
 /**
diff --git a/packages/lexical-code-core/src/CodeImportExtension.ts b/packages/lexical-code-core/src/CodeImportExtension.ts
index 1a7c94b3946..ef49e0bf3e6 100644
--- a/packages/lexical-code-core/src/CodeImportExtension.ts
+++ b/packages/lexical-code-core/src/CodeImportExtension.ts
@@ -21,12 +21,9 @@ import {
   type LexicalNode,
 } from 'lexical';
 
-import {$createCodeNode} from './CodeNode';
+import {$createCodeNode, $createCodeNodeFromDOM} from './CodeNode';
 import {$plainifyCodeContent} from './FlatStructureUtils';
 
-const LANGUAGE_DATA_ATTRIBUTE = 'data-language';
-const THEME_DATA_ATTRIBUTE = 'data-theme';
-
 /**
  * Code editing expects flat CodeHighlightNode, TabNode and LineBreakNode
  * children. Preserve imported nodes' plain-text representations, separating
@@ -87,10 +84,11 @@ const GitHubCodeTableOverlayRules = defineOverlayRules([
 
 const PreRule = defineImportRule({
   $import: (ctx, el) => [
-    $createCodeNode(
-      el.getAttribute(LANGUAGE_DATA_ATTRIBUTE),
-      el.getAttribute(THEME_DATA_ATTRIBUTE),
-    ).splice(0, 0, $normalizeCodeChildren(ctx.$importChildren(el))),
+    $createCodeNodeFromDOM(el).splice(
+      0,
+      0,
+      $normalizeCodeChildren(ctx.$importChildren(el)),
+    ),
   ],
   match: sel.tag('pre'),
   name: '@lexical/code/pre',
@@ -110,10 +108,11 @@ const MultilineCodeRule = defineImportRule({
       return $next();
     }
     return [
-      $createCodeNode(
-        el.getAttribute(LANGUAGE_DATA_ATTRIBUTE),
-        el.getAttribute(THEME_DATA_ATTRIBUTE),
-      ).splice(0, 0, $normalizeCodeChildren(ctx.$importChildren(el))),
+      $createCodeNodeFromDOM(el).splice(
+        0,
+        0,
+        $normalizeCodeChildren(ctx.$importChildren(el)),
+      ),
     ];
   },
   match: sel.tag('code'),
diff --git a/packages/lexical-code-core/src/CodeNode.ts b/packages/lexical-code-core/src/CodeNode.ts
index 4c5fdbfdd63..8f27d002d76 100644
--- a/packages/lexical-code-core/src/CodeNode.ts
+++ b/packages/lexical-code-core/src/CodeNode.ts
@@ -17,10 +17,15 @@ import {
   $createTabNode,
   $getDocument,
   $getEditor,
+  $getState,
+  $getStateChange,
   $isLineBreakNode,
   $isTabNode,
   $isTextNode,
+  $setState,
   addClassNamesToElement,
+  booleanValue,
+  createState,
   type DOMConversionOutput,
   type DOMExportOutput,
   type EditorConfig,
@@ -39,6 +44,7 @@ import {
   type SerializedPartial,
   setDOMStyleFromCSS,
   type Spread,
+  type StateValueOrUpdater,
   stringValue,
   type TabNode,
   withAccessors,
@@ -59,6 +65,12 @@ export type SerializedCodeNode = Spread<
   {
     language: string | null | undefined;
     theme?: string | undefined;
+    /**
+     * Present (and `true`) only when the block soft wraps long lines, see
+     * {@link CodeNode.setWordWrap}. Left out otherwise, so the JSON of other
+     * code blocks is unchanged.
+     */
+    wordWrap?: boolean;
   },
   SerializedElementNode
 >;
@@ -95,6 +107,14 @@ function hasChildDOMNodeTag(node: Node, tagName: string) {
 const LANGUAGE_DATA_ATTRIBUTE = 'data-language';
 const HIGHLIGHT_LANGUAGE_DATA_ATTRIBUTE = 'data-highlight-language';
 const THEME_DATA_ATTRIBUTE = 'data-theme';
+const WORD_WRAP_DATA_ATTRIBUTE = 'data-lexical-code-word-wrap';
+
+/**
+ * Whether a code block soft wraps long lines. It is flat, so it serializes
+ * as a top level `wordWrap` key next to `language` and `theme`, and only
+ * while it is true.
+ */
+const codeWordWrapState = createState('wordWrap', {parse: booleanValue()});
 
 const noExtensionDeprecation = warnOnlyOnce(
   'Using CodeNode without CodeExtension is deprecated',
@@ -186,6 +206,7 @@ export class CodeNode extends ElementNode {
         },
       },
       json: codeNodeSchema,
+      stateConfigs: [{flat: true, stateConfig: codeWordWrapState}],
     });
   }
 
@@ -233,6 +254,10 @@ export class CodeNode extends ElementNode {
       element.setAttribute(THEME_DATA_ATTRIBUTE, theme);
     }
 
+    if (this.getWordWrap()) {
+      element.setAttribute(WORD_WRAP_DATA_ATTRIBUTE, 'true');
+    }
+
     const style = this.getStyle();
     if (style) {
       setDOMStyleFromCSS(element.style, style);
@@ -278,6 +303,17 @@ export class CodeNode extends ElementNode {
       dom.removeAttribute(THEME_DATA_ATTRIBUTE);
     }
 
+    // Only the attribute changes. Keep the  element, with its scroll
+    // position and line break wrappers, instead of recreating it.
+    const wordWrap = $getStateChange(this, prevNode, codeWordWrapState);
+    if (wordWrap !== null) {
+      if (wordWrap[0]) {
+        dom.setAttribute(WORD_WRAP_DATA_ATTRIBUTE, 'true');
+      } else {
+        dom.removeAttribute(WORD_WRAP_DATA_ATTRIBUTE);
+      }
+    }
+
     const style = this.__style;
     const prevStyle = prevNode.__style;
     if (style !== prevStyle) {
@@ -305,6 +341,10 @@ export class CodeNode extends ElementNode {
       element.setAttribute(THEME_DATA_ATTRIBUTE, theme);
     }
 
+    if (this.getWordWrap()) {
+      element.setAttribute(WORD_WRAP_DATA_ATTRIBUTE, 'true');
+    }
+
     const style = this.getStyle();
     if (style) {
       setDOMStyleFromCSS(element.style, style);
@@ -427,6 +467,28 @@ export class CodeNode extends ElementNode {
   getTheme(): string | undefined {
     return this.getLatest().__theme;
   }
+
+  /**
+   * `true` when this code block soft wraps lines that are wider than it,
+   * instead of scrolling sideways. Wrapping is visual only: no LineBreakNode
+   * is added and the text is unchanged. The theme does the wrapping, with CSS
+   * on the `data-lexical-code-word-wrap` attribute (see the package README).
+   */
+  getWordWrap(): boolean {
+    return $getState(this, codeWordWrapState);
+  }
+
+  /**
+   * Turns soft wrapping of long lines on or off. With an updater function
+   * the node is only marked dirty when the value changes. This only sets the
+   * flag and the `data-lexical-code-word-wrap` attribute; the theme's CSS
+   * wraps the lines.
+   */
+  setWordWrap(
+    valueOrUpdater: StateValueOrUpdater,
+  ): this {
+    return $setState(this, codeWordWrapState, valueOrUpdater);
+  }
 }
 
 export function $createCodeNode(
@@ -442,12 +504,25 @@ export function $isCodeNode(
   return node instanceof CodeNode;
 }
 
+/**
+ * @internal Creates the CodeNode for an imported 
 or multi line 
+ * from the attributes exportDOM writes, so none are dropped on an HTML
+ * round trip. Shared with CodeImportExtension.
+ */
+export function $createCodeNodeFromDOM(element: HTMLElement): CodeNode {
+  const node = $createCodeNode(
+    element.getAttribute(LANGUAGE_DATA_ATTRIBUTE),
+    element.getAttribute(THEME_DATA_ATTRIBUTE),
+  );
+  const wordWrap = element.getAttribute(WORD_WRAP_DATA_ATTRIBUTE);
+  // Set only when on, so other code blocks don't allocate a NodeState.
+  return wordWrap !== null && wordWrap !== 'false'
+    ? node.setWordWrap(true)
+    : node;
+}
+
 function $convertPreElement(domNode: HTMLElement): DOMConversionOutput {
-  const language = domNode.getAttribute(LANGUAGE_DATA_ATTRIBUTE);
-  // 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)};
+  return {node: $createCodeNodeFromDOM(domNode)};
 }
 
 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 7502e03e99b..0dc6af7f541 100644
--- a/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts
+++ b/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts
@@ -106,6 +106,44 @@ describe('CodeImportExtension', () => {
     });
   });
 
+  test('
 turns on word wrap', () => {
+    using editor = buildEditor();
+    importInto(editor, '
x
'); + editor.read(() => { + const node = $rootCode(); + expect(node.getWordWrap()).toBe(true); + expect(node.exportJSON()).toMatchObject({wordWrap: true}); + }); + }); + + test('multi line turns on word wrap', () => { + using editor = buildEditor(); + importInto(editor, 'a
b
'); + editor.read(() => { + const node = $rootCode(); + expect(node.getWordWrap()).toBe(true); + expect(node.getTextContent()).toBe('a\nb'); + }); + }); + + test('
 without the attribute leaves word wrap off', () => {
+    using editor = buildEditor();
+    importInto(editor, '
x
'); + editor.read(() => { + const node = $rootCode(); + expect(node.getWordWrap()).toBe(false); + expect(node.exportJSON()).not.toHaveProperty('wordWrap'); + }); + }); + + test('
 leaves word wrap off', () => {
+    using editor = buildEditor();
+    importInto(editor, '
x
'); + editor.read(() => { + expect($rootCode().getWordWrap()).toBe(false); + }); + }); + 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/CodeNode.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts index a03b2c87e09..93c2255b518 100644 --- a/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts +++ b/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts @@ -6,9 +6,23 @@ * */ -import {$createCodeNode, $isCodeNode, CodeNode} from '@lexical/code-core'; -import {$generateNodesFromDOM} from '@lexical/html'; -import {$getRoot, type EditorConfig} from 'lexical'; +import { + $createCodeNode, + $isCodeNode, + CodeNode, + type SerializedCodeNode, +} from '@lexical/code-core'; +import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; +import { + $createLineBreakNode, + $createTextNode, + $getNodeByKey, + $getRoot, + type EditorConfig, + type LexicalEditor, + type NodeKey, + type NodeMutation, +} from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; import {assert, describe, expect, it} from 'vitest'; @@ -19,6 +33,47 @@ const editorConfig = { }, } as EditorConfig; +const WORD_WRAP_ATTR = 'data-lexical-code-word-wrap'; + +/** A code block with two lines, "a" and "b", as the only root child. */ +function appendTwoLineCode(editor: LexicalEditor): NodeKey { + let key!: NodeKey; + editor.update( + () => { + const code = $createCodeNode('javascript').append( + $createTextNode('a'), + $createLineBreakNode(), + $createTextNode('b'), + ); + $getRoot().clear().append(code); + key = code.getKey(); + }, + {discrete: true}, + ); + return key; +} + +function $importCodeJSON(json: SerializedCodeNode): CodeNode { + const node = CodeNode.importJSON(json); + assert($isCodeNode(node), 'expected a CodeNode'); + return node; +} + +function setWordWrap( + editor: LexicalEditor, + key: NodeKey, + valueOrUpdater: boolean | ((wordWrap: boolean) => boolean), +): void { + editor.update( + () => { + const node = $getNodeByKey(key); + assert($isCodeNode(node), 'expected a CodeNode'); + node.setWordWrap(valueOrUpdater); + }, + {discrete: true}, + ); +} + describe('CodeNode', () => { initializeUnitTest( testEnv => { @@ -96,6 +151,164 @@ describe('CodeNode', () => { expect(node.getTheme()).toBe('poimandres'); }); }); + + describe('word wrap', () => { + it('serializes wordWrap only while it is on', () => { + testEnv.editor.update( + () => { + const node = $createCodeNode('javascript'); + expect(node.getWordWrap()).toBe(false); + const off = node.exportJSON(); + expect(off).not.toHaveProperty('wordWrap'); + expect(off).not.toHaveProperty('$'); + + node.setWordWrap(true); + expect(node.getWordWrap()).toBe(true); + expect(node.exportJSON()).toStrictEqual({...off, wordWrap: true}); + expect(node.exportJSON(true)).toMatchObject({wordWrap: true}); + expect(node.exportJSON(true)).not.toHaveProperty('$'); + + node.setWordWrap(false); + expect(node.exportJSON()).toStrictEqual(off); + }, + {discrete: true}, + ); + }); + + it('imports wordWrap from the top level or from $', () => { + testEnv.editor.update( + () => { + const off = $createCodeNode('javascript').exportJSON(); + const topLevel = $importCodeJSON({...off, wordWrap: true}); + expect(topLevel.getWordWrap()).toBe(true); + + const nested = $importCodeJSON({...off, $: {wordWrap: true}}); + expect(nested.getWordWrap()).toBe(true); + expect(nested.exportJSON()).toStrictEqual({ + ...off, + wordWrap: true, + }); + + const invalid = $importCodeJSON({ + ...off, + wordWrap: 'yes', + } as unknown as SerializedCodeNode); + expect(invalid.getWordWrap()).toBe(false); + }, + {discrete: true}, + ); + }); + + it('updateFromJSON without wordWrap turns it off', () => { + testEnv.editor.update( + () => { + const off = $createCodeNode('javascript').exportJSON(); + const node = $createCodeNode('javascript').setWordWrap(true); + expect(node.updateFromJSON(off).getWordWrap()).toBe(false); + }, + {discrete: true}, + ); + }); + + it('createDOM sets the attribute only when on', () => { + testEnv.editor.update( + () => { + const node = $createCodeNode(); + expect(node.createDOM(editorConfig).outerHTML).toBe( + '', + ); + node.setWordWrap(true); + expect( + node.createDOM(editorConfig).getAttribute(WORD_WRAP_ATTR), + ).toBe('true'); + }, + {discrete: true}, + ); + }); + + it('toggling updates the attribute on the same element', () => { + const {editor} = testEnv; + const key = appendTwoLineCode(editor); + const dom = editor.getElementByKey(key); + assert(dom !== null, 'expected a rendered code block'); + expect(dom.hasAttribute(WORD_WRAP_ATTR)).toBe(false); + + setWordWrap(editor, key, true); + expect(editor.getElementByKey(key)).toBe(dom); + expect(dom.getAttribute(WORD_WRAP_ATTR)).toBe('true'); + editor.read(() => { + const node = $getNodeByKey(key); + assert($isCodeNode(node), 'expected a CodeNode'); + expect(node.getTextContent()).toBe('a\nb'); + expect(node.getChildrenSize()).toBe(3); + }); + + setWordWrap(editor, key, false); + expect(editor.getElementByKey(key)).toBe(dom); + expect(dom.hasAttribute(WORD_WRAP_ATTR)).toBe(false); + expect(dom.textContent).toBe('ab'); + }); + + it('an updater that keeps the value leaves the node clean', () => { + const {editor} = testEnv; + const key = appendTwoLineCode(editor); + const mutations: NodeMutation[] = []; + const unregister = editor.registerMutationListener( + CodeNode, + nodes => { + mutations.push(...nodes.values()); + }, + {skipInitialization: true}, + ); + + setWordWrap(editor, key, wordWrap => wordWrap); + expect(mutations).toEqual([]); + + setWordWrap(editor, key, wordWrap => !wordWrap); + expect(mutations).toEqual(['updated']); + unregister(); + }); + + it('round trips through exportDOM and importDOM', () => { + const {editor} = testEnv; + const key = appendTwoLineCode(editor); + setWordWrap(editor, key, true); + const html = editor.read(() => $generateHtmlFromNodes(editor, null)); + const doc = new DOMParser().parseFromString(html, 'text/html'); + const pre = doc.querySelector('pre'); + assert(pre !== null, 'expected an exported
');
+          expect(pre.getAttribute(WORD_WRAP_ATTR)).toBe('true');
+
+          editor.update(
+            () => {
+              const [node] = $generateNodesFromDOM(editor, doc);
+              assert($isCodeNode(node), 'expected a CodeNode');
+              expect(node.getWordWrap()).toBe(true);
+              expect(node.getLanguage()).toBe('javascript');
+              expect(node.getTextContent()).toBe('a\nb');
+              expect(node.getChildrenSize()).toBe(3);
+            },
+            {discrete: true},
+          );
+        });
+
+        it('imports a value of "false" as off', () => {
+          const {editor} = testEnv;
+          const doc = new DOMParser().parseFromString(
+            `
a
`, + 'text/html', + ); + editor.update( + () => { + const [node] = $generateNodesFromDOM(editor, doc); + assert($isCodeNode(node), 'expected a CodeNode'); + expect(node.getWordWrap()).toBe(false); + expect(node.exportJSON()).not.toHaveProperty('wordWrap'); + }, + {discrete: true}, + ); + }); + }); }, { namespace: 'test', diff --git a/packages/lexical-code/flow/LexicalCode.js.flow b/packages/lexical-code/flow/LexicalCode.js.flow index aa9d6a535f9..49e29a51bb7 100644 --- a/packages/lexical-code/flow/LexicalCode.js.flow +++ b/packages/lexical-code/flow/LexicalCode.js.flow @@ -20,6 +20,7 @@ import type { TabNode, LexicalExtension, ExtensionConfigBase, + ValueOrUpdater, } from 'lexical'; import {ElementNode, TextNode} from 'lexical'; @@ -81,6 +82,7 @@ declare export function $getStartOfCodeInLine( export type SerializedCodeNode = { ...SerializedElementNode, language: string | null | void, + wordWrap?: boolean, ... }; @@ -101,6 +103,8 @@ declare export class CodeNode extends ElementNode { collapseAtStart(): true; setLanguage(language: string): void; getLanguage(): string | void; + getWordWrap(): boolean; + setWordWrap(valueOrUpdater: ValueOrUpdater): this; } declare export var CodeExtension: LexicalExtension; From 602eb1093bc154f6783b2c77e6035f3b6c263fbe Mon Sep 17 00:00:00 2001 From: Om Singhal Date: Wed, 23 Sep 2026 21:42:22 -0400 Subject: [PATCH 02/31] [lexical-code-core][lexical-code][lexical-website] Feature: CodeLineNumbersExtension for line numbers that follow wrapped lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The highlighters write the line numbers of a code block to its data-gutter attribute, as one string that a theme draws next to the block. That gutter is a single block of text, so it can't follow a line that wraps. CodeLineNumbersExtension is an opt in extension that marks each logical line of a code block, so a theme can number the lines with a CSS counter. The code element gets data-lexical-code-line-numbers, and the
of each LineBreakNode inside a CodeNode is wrapped in a span with data-lexical-code-line-break, whose inner
is exposed through $getDOMSlot. The numbers are generated content, so they never reach the editor's text, the clipboard or exported HTML. With onlyWordWrapped, only code blocks with word wrap on get line number elements. The other blocks render exactly as they do without the extension, data-gutter included, so a theme can keep its data-gutter gutter for them. Turning word wrap on or off for such a block recreates the block's DOM, because the wrappers of all its line breaks change and those LineBreakNodes are not dirty. A caret inside the block keeps its place. When disabled, the overrides are left out of the render pipeline, so code blocks and data-gutter render exactly as before. Both options are also output signals, so they can be switched at runtime, and disabled wins over onlyWordWrapped. There is one set of DOM overrides for each mode, and the render context installs exactly one set at a time. The extension lives in @lexical/code-core next to CodeNode, and @lexical/code also exports it and its config type, as it does CodeExtension and CodeIndentExtension. The included extensions page lists it under @lexical/code. The @lexical/code-core README gets a Line numbers section with a CSS recipe. It also says that WebKit does not draw line 1's number, the code element's ::before, while a right to left block is scrolled sideways. The Word wrap section gets the CSS for numbering wrapped blocks, and the Caret scrolling section says to use scroll-padding-inline-start for a sticky gutter at the inline start. The Shiki cases of the new tests tokenize once before they start. Shiki compiles a grammar the first time it tokenizes with it, and it stops splitting a line that takes longer than tokenizeTimeLimit, 500ms by default. On a busy machine the first line of the first test could then stay one token. ## Test plan ### Before The new tests, with src/index.ts from the parent commit and without src/CodeLineNumbers.ts, so the package has no such extension: pnpm vitest run --project unit packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts × CodeLineNumbersExtension > DOM > wraps the
of each code line break and marks the code element × CodeLineNumbersExtension > DOM > exposes the inner
through $getDOMSlot × CodeLineNumbersExtension > DOM > gives empty lines and a trailing empty line their own wrapper × CodeLineNumbersExtension > DOM > marks an empty code block and keeps its managed
× CodeLineNumbersExtension > DOM > leaves line breaks outside of code blocks alone ... FAIL |unit| packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts > CodeLineNumbersExtension > DOM > wraps the
of each code line break and marks the code element TypeError: Cannot read properties of undefined (reading 'name') ❯ LexicalBuilder.addExtension packages/lexical-extension/src/LexicalBuilder.ts:324:49 Test Files 1 failed (1) Tests 36 failed (36) Without the warm up, one run of the package tests under load failed like this, with the first line of the first editor left as one token: FAIL |unit| packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts > CodeLineNumbersExtension > with Shiki highlighting > renders the default DOM, data-gutter included, when disabled AssertionError: expected 'const a = 1;
... + 2">const ... It did not fail again in runs of the file alone. With the Shiki packages the repo uses, and tokenizeTimeLimit set to 1ms to stand in for a slow first line, a fresh highlighter returns [["const a = 1;"],["let b;"]] on its first call and [["const"," ","a = 1;"],["let"," b;"]] on its second. ### After pnpm vitest run --project unit packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts Test Files 1 passed (1) Tests 36 passed (36) The file passed 5 runs out of 5 in a row. pnpm vitest run --project unit packages/lexical-code-core packages/lexical-code/ packages/lexical-code-prism packages/lexical-code-shiki Test Files 17 passed (17) Tests 343 passed | 1 skipped (344) pnpm vitest run --project scripts-unit scripts/__tests__/unit/treeShakingSource.test.ts -t 'lexical-code' Tests 17 passed | 319 skipped (336) pnpm run tsc and pnpm run flow pass, and prettier and eslint pass on the changed files. Browser mode and e2e tests were not run for this commit. The tests at the end of this branch run the playground's use of the extension in Chromium, Firefox and WebKit. --- packages/lexical-code-core/README.md | 65 +- .../flow/LexicalCodeCore.js.flow | 13 + .../lexical-code-core/src/CodeLineNumbers.ts | 215 ++++ .../__tests__/unit/CodeLineNumbers.test.ts | 1093 +++++++++++++++++ packages/lexical-code-core/src/index.ts | 4 + .../lexical-code/flow/LexicalCode.js.flow | 9 + packages/lexical-code/src/index.ts | 2 + .../docs/extensions/included-extensions.md | 1 + 8 files changed, 1400 insertions(+), 2 deletions(-) create mode 100644 packages/lexical-code-core/src/CodeLineNumbers.ts create mode 100644 packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts diff --git a/packages/lexical-code-core/README.md b/packages/lexical-code-core/README.md index 8f8d4950eea..a2bdbe6b00f 100644 --- a/packages/lexical-code-core/README.md +++ b/packages/lexical-code-core/README.md @@ -6,6 +6,48 @@ This package contains the base functionality for the code blocks and code highli is considered an implementation detail and is being used transitionally while the deprecated highlighting functionality is still available in `@lexical/code`. +## Line numbers + +`@lexical/code-prism` and `@lexical/code-shiki` write the line numbers of each code block to its `data-gutter` +attribute (`"1\n2\n3"`), which a theme can show with `content: attr(data-gutter)`. That gutter is a single block of +text, so it can't follow a line that wraps. + +`CodeLineNumbersExtension`, which `@lexical/code` also exports, is an opt in alternative that gives every logical line +its own element to hang a number on: + +- the DOM of every `CodeNode` gets `data-lexical-code-line-numbers` +- the `
` of each `LineBreakNode` in a code block is wrapped in `` + +A theme can then number the lines with a CSS counter. Line 1 comes from the code element's `::before` and every other +line from the `::after` of the span that ends the line before it, so empty lines get a number too: + +```css +code[data-lexical-code-line-numbers] { + counter-reset: code-line; +} +code[data-lexical-code-line-numbers]::before, +code[data-lexical-code-line-numbers] [data-lexical-code-line-break]::after { + counter-increment: code-line; + content: counter(code-line); + /* size and place the number in a gutter */ +} +``` + +The numbers are generated content, so they never end up in the editor's text, the clipboard or exported HTML, which +still gets a plain `
`. The highlighters keep writing `data-gutter` while the extension is enabled, so hide an +existing `attr(data-gutter)` gutter under `[data-lexical-code-line-numbers]`. The playground theme +(`PlaygroundEditorTheme.css`) has a complete example. + +WebKit does not draw line 1's number, the code element's `::before`, while a right to left block is scrolled sideways. +The numbers drawn by the wrappers stay. The playground theme has this limitation too. + +The extension can be switched off at runtime through its `disabled` signal, which puts back exactly the DOM a code +block has without it. + +Set `onlyWordWrapped` to give line number elements only to code blocks with word wrap on (see below). The other blocks +render exactly as they do without the extension, so a theme can keep numbering them with `data-gutter`. It can also be +switched at runtime through the `onlyWordWrapped` signal. + ## Word wrap `CodeNode.setWordWrap(true)` makes a code block soft wrap lines that are wider than it, instead of scrolling sideways, @@ -28,6 +70,24 @@ code[data-lexical-code-word-wrap] { } ``` +`data-gutter` has one row per logical line, so it can't follow wrapped rows. Number wrapped blocks with +`CodeLineNumbersExtension` instead, and take the numbers out of the line box, because an inline number can be left +alone on a row before a long token: + +```css +code[data-lexical-code-word-wrap][data-lexical-code-line-numbers]::before, +code[data-lexical-code-word-wrap][data-lexical-code-line-numbers] [data-lexical-code-line-break]::after { + position: absolute; + inset-inline-start: 0; + margin-inline: 0; +} +``` + +Keep `top: auto` and an inline `display` such as `inline-block`, so each number stays on the first row of its line, and +give the code element `position: relative`. `margin-inline: 0` drops any margin that placed the number in the line, which +would now move it out of the gutter. Keep `overflow-x: auto` rather than `visible` on the code element, because +`visible` would change the scroll container the numbers are placed against. + ## Caret scrolling When Lexical moves the caret itself, it scrolls the code block sideways to show the caret. That happens on the line @@ -41,8 +101,9 @@ it goes all the way back. That shows the whole indentation, which Home and Enter scrolls just far enough, and keeps the caret out of the code element's `scroll-padding`. A gutter that stays in place while the code scrolls should set that padding to its width, so the caret isn't left under it. A `float: left` gutter, like the one the playground theme draws from `data-gutter`, is on the left in both directions, so use -`scroll-padding-left` for it. +`scroll-padding-left` for it. A sticky gutter at the inline start, like the line numbers above, is on the right in a +right to left block, so use `scroll-padding-inline-start` for it. At the end of the longest line the block is scrolled as far as it goes, and a browser can draw the caret a fraction of a pixel past that edge. A few pixels of `padding-inline-end` on the code element leave room for it. The playground theme -(`PlaygroundEditorTheme.css`) does both. +(`PlaygroundEditorTheme.css`) does all of this. diff --git a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow index 7a81c002658..41d1b9d7665 100644 --- a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow +++ b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow @@ -24,6 +24,8 @@ import type { ValueOrUpdater, } from 'lexical'; +import type {NamedSignalsOutput} from '@lexical/extension'; + import {ElementNode, TextNode} from 'lexical'; /** @@ -131,3 +133,14 @@ declare export function registerCodeIndentation( ): () => void; declare export var CodeIndentExtension: LexicalExtension; + +/** + * CodeLineNumbers + */ + +export type CodeLineNumbersConfig = { + disabled: boolean, + onlyWordWrapped: boolean, +}; + +declare export var CodeLineNumbersExtension: LexicalExtension, void>; diff --git a/packages/lexical-code-core/src/CodeLineNumbers.ts b/packages/lexical-code-core/src/CodeLineNumbers.ts new file mode 100644 index 00000000000..f43c7f36818 --- /dev/null +++ b/packages/lexical-code-core/src/CodeLineNumbers.ts @@ -0,0 +1,215 @@ +/** + * 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 {effect, namedSignals} from '@lexical/extension'; +import { + $setRenderContextValue, + createRenderState, + domOverride, + type DOMOverrideOptions, + DOMRenderExtension, +} from '@lexical/html'; +import { + $getDocument, + configExtension, + defineExtension, + isHTMLElement, + type LexicalNode, + LineBreakNode, + safeCast, +} from 'lexical'; + +import {CodeExtension} from './CodeExtension'; +import {$isCodeNode, CodeNode} from './CodeNode'; + +export interface CodeLineNumbersConfig { + /** + * When `true` the extension renders nothing, so code blocks have exactly + * the DOM they have without it. Can be flipped at runtime through the + * `disabled` output signal. + */ + disabled: boolean; + /** + * When `true`, only code blocks with word wrap on + * ({@link CodeNode.getWordWrap}) get line number elements. Other blocks + * render exactly as they do without the extension, so a theme can keep + * numbering them with `data-gutter`, which can't follow wrapped rows. + * Can be flipped at runtime through the `onlyWordWrapped` output signal. + */ + onlyWordWrapped: boolean; +} + +/** Set on the DOM of every CodeNode that has line number elements. */ +const CODE_LINE_NUMBERS_ATTR = 'data-lexical-code-line-numbers'; +/** Set on the `` wrapped around the `
` of a code LineBreakNode. */ +const CODE_LINE_BREAK_ATTR = 'data-lexical-code-line-break'; + +type CodeLineNumbersMode = 'off' | 'all' | 'wordWrapped'; + +/** Editor render context state mirroring the extension's signals. */ +const CodeLineNumbersModeState = createRenderState( + 'codeLineNumbersMode', + () => 'all', +); + +function isLineBreakWrapper(dom: HTMLElement): boolean { + return dom.hasAttribute(CODE_LINE_BREAK_ATTR); +} + +function $hasLineNumbers( + node: LexicalNode | null, + mode: 'all' | 'wordWrapped', +): boolean { + return $isCodeNode(node) && (mode === 'all' || node.getWordWrap()); +} + +/** + * One set of overrides per mode. Exactly one set is installed at a time, so + * a mode change swaps the sets, and the render runtime recompiles and + * recreates the affected nodes without marking any node dirty. + */ +function lineNumberOverrides(mode: 'all' | 'wordWrapped') { + const options = { + disabledForEditor: ctx => ctx.get(CodeLineNumbersModeState) !== mode, + } satisfies DOMOverrideOptions; + return [ + domOverride( + [LineBreakNode], + { + $createDOM: (node, $next) => { + const dom = $next(); + if (!$hasLineNumbers(node.getParent(), mode)) { + return dom; + } + const wrapper = $getDocument().createElement('span'); + wrapper.setAttribute(CODE_LINE_BREAK_ATTR, 'true'); + wrapper.appendChild(dom); + return wrapper; + }, + $getDOMSlot: (_node, dom, $next) => { + const slot = $next(); + const inner = dom.firstElementChild; + return isLineBreakWrapper(dom) && + slot.element === dom && + isHTMLElement(inner) + ? slot.withElement(inner) + : slot; + }, + $updateDOM: (node, _prevNode, dom, $next) => { + const isWrapped = isLineBreakWrapper(dom); + if ( + // Moved into or out of a numbered code block (e.g. + // collapseAtStart or $setBlocksType), the reconciler reuses the + // DOM. + isWrapped !== $hasLineNumbers(node.getParent(), mode) || + // The browser removed the
; the mutation observer only + // marks the node dirty, so recreate it here. + (isWrapped && dom.firstElementChild === null) + ) { + return true; + } + return $next(); + }, + }, + options, + ), + domOverride( + [CodeNode], + { + $createDOM: (node, $next) => { + const dom = $next(); + if ($hasLineNumbers(node, mode)) { + dom.setAttribute(CODE_LINE_NUMBERS_ATTR, 'true'); + } + return dom; + }, + // With onlyWordWrapped, turning word wrap on or off adds or removes + // the wrapper of every line break. Those LineBreakNodes aren't + // dirty, so the whole block is recreated. CodeNode.updateDOM itself + // never recreates. + $updateDOM: (node, _prevNode, dom, $next) => + dom.hasAttribute(CODE_LINE_NUMBERS_ATTR) !== + $hasLineNumbers(node, mode) || $next(), + }, + options, + ), + ]; +} + +/** + * The overrides for both modes. A spread at module scope would count as a + * side effect and keep the extension in bundles that don't use it, so the + * array is built here. + * + * @__NO_SIDE_EFFECTS__ + */ +function allLineNumberOverrides() { + return [...lineNumberOverrides('all'), ...lineNumberOverrides('wordWrapped')]; +} + +/** + * @experimental + * + * Gives every logical line of a code block its own element for a line + * number, so a theme can number the lines with a CSS counter instead of + * the `data-gutter` attribute. The numbers stay in step with the lines when + * a line wraps, and no text is added to the DOM, so copy, find and IME are + * unaffected. + * + * While enabled: + * - the DOM of every `CodeNode` gets `data-lexical-code-line-numbers`, where + * a theme resets the counter and draws line 1 in `::before`. + * - the `
` of each `LineBreakNode` that is a child of a `CodeNode` is + * wrapped in ``, whose `::after` draws + * the number of the line that starts after it. That includes empty lines, + * which a pseudo element on the `
` itself can't style. + * + * The wrapper is the keyed DOM of the `LineBreakNode` and the inner `
` + * is exposed through `$getDOMSlot`, so a DOM selection inside the wrapper + * resolves to a point just before or just after the `LineBreakNode`. HTML + * export and the clipboard don't use this render pipeline, so they still + * get a plain `
`. + * + * When `disabled`, the overrides are left out of the render pipeline and + * code blocks render exactly as they do without the extension. Toggling it + * renders the existing code blocks again, without recreating the editor. + * + * With `onlyWordWrapped`, only code blocks with word wrap on + * ({@link CodeNode.setWordWrap}) get this DOM, and the other blocks render + * as they do without the extension. Turning word wrap on or off for a block + * then recreates that block's DOM. + * + * The `data-gutter` attribute written by the highlighter extensions is not + * touched, so a theme that wants per line numbers has to hide its old + * gutter under `[data-lexical-code-line-numbers]`. See the package README + * for a CSS recipe. + */ +export const CodeLineNumbersExtension = defineExtension({ + build: (editor, config) => namedSignals(config), + config: safeCast({ + disabled: false, + onlyWordWrapped: false, + }), + dependencies: [ + CodeExtension, + configExtension(DOMRenderExtension, { + overrides: allLineNumberOverrides(), + }), + ], + name: '@lexical/code-line-numbers', + register: (editor, _config, state) => { + const {disabled, onlyWordWrapped} = state.getOutput(); + return effect(() => { + $setRenderContextValue( + CodeLineNumbersModeState, + disabled.value ? 'off' : onlyWordWrapped.value ? 'wordWrapped' : 'all', + editor, + ); + }); + }, +}); diff --git a/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts new file mode 100644 index 00000000000..0e1fbd8e5a3 --- /dev/null +++ b/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts @@ -0,0 +1,1093 @@ +/** + * 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 {$insertDataTransferForRichText} from '@lexical/clipboard'; +import { + $createCodeHighlightNode, + $createCodeNode, + $isCodeNode, + CodeExtension, + CodeLineNumbersExtension, + type CodeNode, +} from '@lexical/code-core'; +import {CodePrismExtension} from '@lexical/code-prism'; +import { + CodeShikiExtension, + loadCodeLanguage, + loadCodeTheme, +} from '@lexical/code-shiki'; +import { + buildEditorFromExtensions, + getExtensionDependencyFromEditor, + type LexicalEditorWithDispose, +} from '@lexical/extension'; +import {HistoryExtension} from '@lexical/history'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import {RichTextExtension} from '@lexical/rich-text'; +import {$setBlocksType} from '@lexical/selection'; +import { + $createLineBreakNode, + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getDOMSlot, + $getNodeByKey, + $getRoot, + $getSelection, + $isElementNode, + $isLineBreakNode, + $isParagraphNode, + $isRangeSelection, + $isTextNode, + $setSelection, + $splitNode, + type AnyLexicalExtensionArgument, + configExtension, + defineExtension, + getDOMSelection, + INSERT_PARAGRAPH_COMMAND, + isHTMLElement, + type LexicalEditor, + type NodeKey, + type PointType, + REDO_COMMAND, + UNDO_COMMAND, +} from 'lexical'; +import {$assertNodeType} from 'lexical/src/__tests__/utils'; +import {$internalCreateRangeSelection} from 'lexical/src/LexicalSelection'; +import {afterEach, assert, beforeAll, describe, expect, test} from 'vitest'; + +const LINE_NUMBERS_ATTR = 'data-lexical-code-line-numbers'; +const LINE_BREAK_ATTR = 'data-lexical-code-line-break'; +const WORD_WRAP_ATTR = 'data-lexical-code-word-wrap'; + +const OnlyWordWrappedLineNumbers = configExtension(CodeLineNumbersExtension, { + onlyWordWrapped: true, +}); + +const mountedRoots: HTMLElement[] = []; +afterEach(() => { + for (const root of mountedRoots.splice(0)) { + root.remove(); + } +}); + +function mount(editor: LexicalEditor): HTMLElement { + const root = document.createElement('div'); + document.body.appendChild(root); + mountedRoots.push(root); + editor.setRootElement(root); + return root; +} + +/** One CodeHighlightNode per non empty line, a LineBreakNode between. */ +function $createCodeFromLines(lines: readonly string[]): CodeNode { + const code = $createCodeNode('javascript'); + lines.forEach((line, i) => { + if (i > 0) { + code.append($createLineBreakNode()); + } + if (line !== '') { + code.append($createCodeHighlightNode(line)); + } + }); + return code; +} + +function $appendCode(lines: readonly string[]): void { + $getRoot().clear().append($createCodeFromLines(lines)); +} + +function createEditor( + $initialEditorState: () => void, + dependencies: AnyLexicalExtensionArgument[] = [CodeLineNumbersExtension], +): LexicalEditorWithDispose { + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState, + dependencies: [RichTextExtension, ...dependencies], + name: '[code-line-numbers-test]', + }), + ); + mount(editor); + return editor; +} + +function getRootElement(editor: LexicalEditor): HTMLElement { + const root = editor.getRootElement(); + assert(root !== null, 'expected a mounted editor'); + return root; +} + +function getCodeElement(editor: LexicalEditor): HTMLElement { + const code = getRootElement(editor).querySelector('code'); + assert(isHTMLElement(code), 'expected a rendered code block'); + return code; +} + +/** The first code block in the document. */ +function $getCode(): CodeNode { + return $assertNodeType( + $getRoot().getChildren().find($isCodeNode), + $isCodeNode, + ); +} + +/** The code blocks in the document, in order. */ +function $getCodes(): CodeNode[] { + return $getRoot().getChildren().filter($isCodeNode); +} + +function getCodeElements(editor: LexicalEditor): HTMLElement[] { + return Array.from( + getRootElement(editor).querySelectorAll('code'), + ); +} + +function setWordWrap(editor: LexicalEditor, wordWrap: boolean, index = 0) { + editor.update(() => $getCodes()[index].setWordWrap(wordWrap), { + discrete: true, + }); +} + +function getLineBreakKeys(editor: LexicalEditor): NodeKey[] { + return editor.read(() => + $getCode() + .getChildren() + .filter($isLineBreakNode) + .map(node => node.getKey()), + ); +} + +function getWrappers(editor: LexicalEditor): HTMLElement[] { + return Array.from( + getRootElement(editor).querySelectorAll( + `[${LINE_BREAK_ATTR}]`, + ), + ); +} + +/** + * Every LineBreakNode of the (first) code block renders as a wrapper span + * holding only its
, and the wrappers are direct children of the code + * element in model order. + */ +function expectWrappedLineBreaks( + editor: LexicalEditor, + lineBreakCount: number, +): HTMLElement[] { + const codeElement = getCodeElement(editor); + expect(codeElement.getAttribute(LINE_NUMBERS_ATTR)).toBe('true'); + const keys = getLineBreakKeys(editor); + expect(keys).toHaveLength(lineBreakCount); + const wrappers = getWrappers(editor); + expect(wrappers).toHaveLength(lineBreakCount); + keys.forEach((key, i) => { + const dom = editor.getElementByKey(key); + expect(dom).toBe(wrappers[i]); + assert(dom !== null); + expect(dom.tagName).toBe('SPAN'); + expect(dom.parentElement).toBe(codeElement); + expect(dom.childNodes).toHaveLength(1); + expect(dom.firstChild?.nodeName).toBe('BR'); + }); + return wrappers; +} + +function expectNoLineNumberDOM(editor: LexicalEditor): void { + const root = getRootElement(editor); + expect(root.querySelector(`[${LINE_BREAK_ATTR}]`)).toBeNull(); + expect(root.querySelector(`[${LINE_NUMBERS_ATTR}]`)).toBeNull(); +} + +/** + * Every LineBreakNode in the document renders directly inside its parent's + * DOM, wrapped when the parent is a CodeNode and a bare
otherwise, and + * no wrapper is left behind by a line break that moved or was removed. + * Returns how many line breaks are in code blocks and how many aren't. + */ +function expectLineBreaksMatchParents(editor: LexicalEditor): { + inCode: number; + outside: number; +} { + const counts = {inCode: 0, outside: 0}; + editor.read(() => { + for (const block of $getRoot().getChildren()) { + assert($isElementNode(block)); + const blockDOM = editor.getElementByKey(block.getKey()); + for (const child of block.getChildren()) { + if (!$isLineBreakNode(child)) { + continue; + } + const dom = editor.getElementByKey(child.getKey()); + assert(dom !== null, 'expected every line break to be rendered'); + expect(dom.parentElement).toBe(blockDOM); + if ($isCodeNode(block)) { + counts.inCode++; + expect(dom.getAttribute(LINE_BREAK_ATTR)).toBe('true'); + expect(dom.childNodes).toHaveLength(1); + expect(dom.firstChild?.nodeName).toBe('BR'); + } else { + counts.outside++; + expect(dom.nodeName).toBe('BR'); + } + } + } + }); + expect(getWrappers(editor)).toHaveLength(counts.inCode); + return counts; +} + +function setDOMCaret(node: Node, offset: number): Selection { + const domSelection = getDOMSelection(window); + assert(domSelection !== null); + const range = document.createRange(); + range.setStart(node, offset); + range.collapse(true); + domSelection.removeAllRanges(); + domSelection.addRange(range); + return domSelection; +} + +type ResolvedCaret = Pick & { + /** Whether the reconciler will write the DOM caret back. */ + dirty: boolean; +}; + +/** Resolve a DOM caret the way a selectionchange would. */ +function resolveDOMCaret( + editor: LexicalEditor, + node: Node, + offset: number, +): ResolvedCaret { + const domSelection = setDOMCaret(node, offset); + let point: ResolvedCaret | undefined; + editor.update( + () => { + const selection = $internalCreateRangeSelection( + $getSelection(), + domSelection, + editor, + {type: 'selectionchange'} as Event, + ); + assert($isRangeSelection(selection), 'expected a RangeSelection'); + const {key, offset: pointOffset, type} = selection.anchor; + point = {dirty: selection.dirty, key, offset: pointOffset, type}; + }, + {discrete: true}, + ); + assert(point !== undefined); + return point; +} + +const $initialMixedContent = () => { + $getRoot() + .clear() + .append( + $createParagraphNode().append( + $createTextNode('p'), + $createLineBreakNode(), + $createTextNode('q'), + ), + $createCodeFromLines(['a', '', 'b', '']), + $createCodeNode('javascript'), + ); +}; + +describe('CodeLineNumbersExtension', () => { + describe('DOM', () => { + test('wraps the
of each code line break and marks the code element', () => { + using editor = createEditor(() => $appendCode(['a', 'b', 'c'])); + const codeElement = getCodeElement(editor); + expectWrappedLineBreaks(editor, 2); + // No bare
: the last line isn't empty so there is no managed one. + expect(codeElement.querySelector(':scope > br')).toBeNull(); + // The numbers are CSS generated content, nothing lands in the DOM text. + expect(codeElement.textContent).toBe('abc'); + expect(editor.read(() => $getCode().getTextContent())).toBe('a\nb\nc'); + }); + + test('exposes the inner
through $getDOMSlot', () => { + using editor = createEditor(() => $appendCode(['a', 'b'])); + const [key] = getLineBreakKeys(editor); + const wrapper = editor.getElementByKey(key); + assert(wrapper !== null); + editor.read(() => { + const node = $getNodeByKey(key); + assert(node !== null); + const slot = $getDOMSlot(node, wrapper, editor); + expect(slot.element).toBe(wrapper.firstChild); + expect(slot.element.tagName).toBe('BR'); + }); + }); + + test('gives empty lines and a trailing empty line their own wrapper', () => { + using editor = createEditor(() => $appendCode(['a', '', 'b', ''])); + const codeElement = getCodeElement(editor); + const wrappers = expectWrappedLineBreaks(editor, 3); + // The empty second line sits between two adjacent wrappers. + expect(wrappers[0].nextSibling).toBe(wrappers[1]); + // The reconciler still adds its managed
after the final wrapper + // so the trailing empty line has height. + expect(codeElement.lastChild?.nodeName).toBe('BR'); + expect(codeElement.lastChild?.previousSibling).toBe(wrappers[2]); + }); + + test('marks an empty code block and keeps its managed
', () => { + using editor = createEditor(() => { + $getRoot().clear().append($createCodeNode('javascript')); + }); + const codeElement = getCodeElement(editor); + expect(codeElement.getAttribute(LINE_NUMBERS_ATTR)).toBe('true'); + expect(codeElement.childNodes).toHaveLength(1); + expect(codeElement.firstChild?.nodeName).toBe('BR'); + expect(getWrappers(editor)).toHaveLength(0); + }); + + test('leaves line breaks outside of code blocks alone', () => { + using editor = createEditor($initialMixedContent); + const paragraph = getRootElement(editor).querySelector('p'); + assert(paragraph !== null); + expect(paragraph.querySelector(`[${LINE_BREAK_ATTR}]`)).toBeNull(); + expect(paragraph.querySelector(':scope > br')).not.toBeNull(); + expect(getWrappers(editor)).toHaveLength(3); + }); + }); + + describe('default output', () => { + test('is unchanged when the extension is configured as disabled', () => { + using baseline = createEditor($initialMixedContent, [CodeExtension]); + using disabled = createEditor($initialMixedContent, [ + configExtension(CodeLineNumbersExtension, {disabled: true}), + ]); + expectNoLineNumberDOM(disabled); + expect(getRootElement(disabled).innerHTML).toBe( + getRootElement(baseline).innerHTML, + ); + }); + + test('toggling off restores the default DOM and toggling on wraps again', () => { + using baseline = createEditor($initialMixedContent, [CodeExtension]); + using editor = createEditor($initialMixedContent); + const {disabled} = getExtensionDependencyFromEditor( + editor, + CodeLineNumbersExtension, + ).output; + expect(getRootElement(editor).innerHTML).not.toBe( + getRootElement(baseline).innerHTML, + ); + + disabled.value = true; + expectNoLineNumberDOM(editor); + expect(getRootElement(editor).innerHTML).toBe( + getRootElement(baseline).innerHTML, + ); + // The line breaks render as the bare
again. + for (const key of getLineBreakKeys(editor)) { + expect(editor.getElementByKey(key)?.nodeName).toBe('BR'); + } + + disabled.value = false; + expectWrappedLineBreaks(editor, 3); + expect( + getRootElement(editor).querySelectorAll(`[${LINE_NUMBERS_ATTR}]`), + ).toHaveLength(2); + + // Editing still works after a round trip. + editor.update( + () => { + $getCode().append( + $createLineBreakNode(), + $createCodeHighlightNode('c'), + ); + }, + {discrete: true}, + ); + expectWrappedLineBreaks(editor, 4); + }); + + test('never reaches exported HTML, mounted or not', () => { + using mounted = createEditor(() => $appendCode(['a', '', 'b'])); + using headless = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => $appendCode(['a', '', 'b']), + dependencies: [RichTextExtension, CodeLineNumbersExtension], + name: '[code-line-numbers-headless]', + }), + ); + expect(getWrappers(mounted)).toHaveLength(2); + for (const editor of [mounted, headless]) { + const html = editor.read(() => $generateHtmlFromNodes(editor, null)); + expect(html).toContain('
'); + expect(html).not.toContain(LINE_BREAK_ATTR); + expect(html).not.toContain(LINE_NUMBERS_ATTR); + } + }); + }); + + describe('editing lines', () => { + test('wraps a line break added with Enter', () => { + using editor = createEditor(() => $appendCode(['a'])); + expectWrappedLineBreaks(editor, 0); + editor.update( + () => { + $getCode().selectEnd(); + editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); + }, + {discrete: true}, + ); + expectWrappedLineBreaks(editor, 1); + }); + + test('drops the wrapper of a line break deleted with Backspace', () => { + using editor = createEditor(() => $appendCode(['a', 'b', 'c'])); + const [, secondKey] = getLineBreakKeys(editor); + editor.update( + () => { + const third = $getCode().getLastChild(); + assert($isTextNode(third)); + third.select(0, 0); + const selection = $getSelection(); + assert($isRangeSelection(selection)); + selection.deleteCharacter(true); + }, + {discrete: true}, + ); + expect(editor.read(() => $getCode().getTextContent())).toBe('a\nbc'); + expectWrappedLineBreaks(editor, 1); + expect(editor.getElementByKey(secondKey)).toBeNull(); + }); + + test('wraps every line break of pasted text', () => { + using editor = createEditor(() => $appendCode(['a'])); + editor.update( + () => { + $getCode().selectEnd(); + const selection = $getSelection(); + assert($isRangeSelection(selection)); + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', 'x\n\ny\n'); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }, + {discrete: true}, + ); + expect(editor.read(() => $getCode().getTextContent())).toBe('ax\n\ny\n'); + const wrappers = expectWrappedLineBreaks(editor, 3); + const codeElement = getCodeElement(editor); + expect(codeElement.lastChild?.nodeName).toBe('BR'); + expect(codeElement.lastChild?.previousSibling).toBe(wrappers[2]); + }); + + test('recreates a wrapper whose
the browser removed', async () => { + using editor = createEditor(() => $appendCode(['a', 'b', 'c'])); + const [firstKey, secondKey] = getLineBreakKeys(editor); + + // Through the mutation observer, which only marks the node dirty. + editor.getElementByKey(firstKey)?.firstChild?.remove(); + await new Promise(resolve => setTimeout(resolve, 0)); + expectWrappedLineBreaks(editor, 2); + + // And directly through a dirty reconcile. + editor.getElementByKey(secondKey)?.firstChild?.remove(); + editor.update(() => $getNodeByKey(secondKey)?.markDirty(), { + discrete: true, + }); + expectWrappedLineBreaks(editor, 2); + }); + }); + + describe('code and paragraph conversion', () => { + test('unwraps line breaks when a code block collapses into a paragraph', () => { + using editor = createEditor(() => $appendCode(['a', 'b'])); + const [key] = getLineBreakKeys(editor); + editor.update(() => $getCode().collapseAtStart(), {discrete: true}); + editor.read(() => { + expect($isParagraphNode($getRoot().getFirstChild())).toBe(true); + }); + expectNoLineNumberDOM(editor); + const br = editor.getElementByKey(key); + expect(br?.nodeName).toBe('BR'); + expect(br?.parentElement?.nodeName).toBe('P'); + }); + + test('unwraps line breaks when a code block becomes a paragraph', () => { + using editor = createEditor(() => $appendCode(['a', 'b'])); + editor.update( + () => { + $getCode().select(0, 0); + $setBlocksType($getSelection(), () => $createParagraphNode()); + }, + {discrete: true}, + ); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('a\nb'); + expect($isParagraphNode($getRoot().getFirstChild())).toBe(true); + }); + expectNoLineNumberDOM(editor); + expect(getRootElement(editor).querySelector('p > br')).not.toBeNull(); + }); + + test('wraps line breaks when a paragraph becomes a code block', () => { + using editor = createEditor(() => { + $getRoot() + .clear() + .append( + $createParagraphNode().append( + $createTextNode('a'), + $createLineBreakNode(), + $createLineBreakNode(), + $createTextNode('b'), + ), + ); + }); + expectNoLineNumberDOM(editor); + editor.update( + () => { + $getRoot().selectStart(); + $setBlocksType($getSelection(), () => $createCodeNode('javascript')); + }, + {discrete: true}, + ); + expectWrappedLineBreaks(editor, 2); + }); + }); + + describe('moving line breaks between blocks', () => { + test('keeps both halves wrapped when $splitNode splits a code block', () => { + using editor = createEditor(() => $appendCode(['a', 'b', 'c', 'd'])); + editor.update( + () => { + // a LB b LB c LB d: split before "c". + const [, right] = $splitNode($getCode(), 4); + assert($isCodeNode(right)); + }, + {discrete: true}, + ); + editor.read(() => { + expect( + $getRoot() + .getChildren() + .map(node => node.getTextContent()), + ).toEqual(['a\nb\n', 'c\nd']); + }); + expect(expectLineBreaksMatchParents(editor)).toEqual({ + inCode: 3, + outside: 0, + }); + expect( + getRootElement(editor).querySelectorAll(`[${LINE_NUMBERS_ATTR}]`), + ).toHaveLength(2); + }); + + test('unwraps the line breaks a cross block delete moves into a paragraph', () => { + using editor = createEditor(() => { + $getRoot() + .clear() + .append( + $createParagraphNode().append($createTextNode('x')), + $createCodeFromLines(['a', 'b', 'c']), + ); + }); + editor.update( + () => { + // From the end of "x" to the end of "a", so the rest of the code + // block joins the paragraph. + const paragraphText = $getRoot().getFirstDescendant(); + const codeText = $getCode().getFirstChild(); + assert($isTextNode(paragraphText) && $isTextNode(codeText)); + const selection = $createRangeSelection(); + selection.anchor.set(paragraphText.getKey(), 1, 'text'); + selection.focus.set(codeText.getKey(), 1, 'text'); + $setSelection(selection); + selection.removeText(); + }, + {discrete: true}, + ); + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(1); + expect($isParagraphNode($getRoot().getFirstChild())).toBe(true); + expect($getRoot().getTextContent()).toBe('x\nb\nc'); + }); + expect(expectLineBreaksMatchParents(editor)).toEqual({ + inCode: 0, + outside: 2, + }); + expectNoLineNumberDOM(editor); + }); + + test('wraps the line breaks a cross block delete moves into a code block', () => { + using editor = createEditor(() => { + $getRoot() + .clear() + .append( + $createCodeFromLines(['a', 'b']), + $createParagraphNode().append( + $createTextNode('p'), + $createLineBreakNode(), + $createTextNode('q'), + ), + ); + }); + editor.update( + () => { + // From the end of "b" to the end of "p", so the rest of the + // paragraph joins the code block. + const codeText = $getCode().getLastChild(); + const paragraphText = $getRoot().getLastChildOrThrow(); + assert($isElementNode(paragraphText)); + const pText = paragraphText.getFirstChild(); + assert($isTextNode(codeText) && $isTextNode(pText)); + const selection = $createRangeSelection(); + selection.anchor.set(codeText.getKey(), 1, 'text'); + selection.focus.set(pText.getKey(), 1, 'text'); + $setSelection(selection); + selection.removeText(); + }, + {discrete: true}, + ); + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(1); + expect($getRoot().getTextContent()).toBe('a\nb\nq'); + }); + expect(expectLineBreaksMatchParents(editor)).toEqual({ + inCode: 2, + outside: 0, + }); + }); + + test('undo and redo of collapseAtStart wrap and unwrap the restored line breaks', () => { + using editor = createEditor( + () => $appendCode(['a', 'b', 'c']), + [ + CodeLineNumbersExtension, + configExtension(HistoryExtension, {delay: 0}), + ], + ); + const inCode = {inCode: 2, outside: 0}; + const inParagraph = {inCode: 0, outside: 2}; + editor.update(() => $getCode().collapseAtStart(), {discrete: true}); + expect(expectLineBreaksMatchParents(editor)).toEqual(inParagraph); + expectNoLineNumberDOM(editor); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(expectLineBreaksMatchParents(editor)).toEqual(inCode); + expect(getCodeElement(editor).getAttribute(LINE_NUMBERS_ATTR)).toBe( + 'true', + ); + + editor.dispatchCommand(REDO_COMMAND, undefined); + expect(expectLineBreaksMatchParents(editor)).toEqual(inParagraph); + expectNoLineNumberDOM(editor); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(expectLineBreaksMatchParents(editor)).toEqual(inCode); + }); + }); + + describe('selection', () => { + // a, LB, LB, b: the second line is empty. + function createSelectionEditor() { + const editor = createEditor(() => $appendCode(['a', '', 'b'])); + const codeKey = editor.read(() => $getCode().getKey()); + const [first, second] = expectWrappedLineBreaks(editor, 2); + return {codeKey, editor, first, second}; + } + + // The wrapper offsets on their own are pinned in lexical's + // LexicalSelectionResolveLeafPosition tests. + test('every DOM caret on an empty line resolves to the same point', () => { + const {codeKey, editor, first, second} = createSelectionEditor(); + using _editor = editor; + const emptyLine = {key: codeKey, offset: 2, type: 'element'}; + const codeElement = getCodeElement(editor); + // After the first wrapper, between the wrappers, before the second + //
. These carets are on keyed DOM, so they aren't dirty and the + // DOM caret stays where the browser put it. + expect(resolveDOMCaret(editor, first, 1)).toEqual({ + ...emptyLine, + dirty: false, + }); + expect(resolveDOMCaret(editor, codeElement, 2)).toEqual({ + ...emptyLine, + dirty: false, + }); + expect(resolveDOMCaret(editor, second, 0)).toEqual({ + ...emptyLine, + dirty: false, + }); + // A caret reported on the
itself counts as before the break. The + //
has no key, so it's dirty and gets written back onto the code + // element. + const br = second.firstChild; + assert(br !== null); + expect(resolveDOMCaret(editor, br, 0)).toEqual({ + ...emptyLine, + dirty: true, + }); + }); + + test('a caret in the text of a line is unchanged', () => { + const {editor} = createSelectionEditor(); + using _editor = editor; + const textKey = editor.read(() => + $getCode().getLastChildOrThrow().getKey(), + ); + const textDOM = editor.getElementByKey(textKey)?.firstChild; + assert(textDOM !== null && textDOM !== undefined); + expect(resolveDOMCaret(editor, textDOM, 1)).toEqual({ + dirty: false, + key: textKey, + offset: 1, + type: 'text', + }); + }); + + test('an element point is written between the wrappers, never inside one', () => { + const {editor, second} = createSelectionEditor(); + using _editor = editor; + const codeElement = getCodeElement(editor); + getRootElement(editor).focus(); + editor.update(() => $getCode().select(2, 2), {discrete: true}); + const domSelection = getDOMSelection(window); + assert(domSelection !== null); + expect(domSelection.anchorNode).toBe(codeElement); + expect(domSelection.anchorOffset).toBe(2); + expect(codeElement.childNodes[domSelection.anchorOffset]).toBe(second); + }); + }); + + describe('onlyWordWrapped', () => { + test('renders the default DOM while no block has word wrap on', () => { + using baseline = createEditor($initialMixedContent, [CodeExtension]); + using editor = createEditor($initialMixedContent, [ + OnlyWordWrappedLineNumbers, + ]); + expectNoLineNumberDOM(editor); + expect(getRootElement(editor).innerHTML).toBe( + getRootElement(baseline).innerHTML, + ); + }); + + test('numbers a block while its word wrap is on', () => { + using baseline = createEditor($initialMixedContent, [CodeExtension]); + using editor = createEditor($initialMixedContent, [ + OnlyWordWrappedLineNumbers, + ]); + const before = getCodeElement(editor); + + setWordWrap(editor, true); + // The block was recreated with a wrapper on each of its line breaks. + expect(getCodeElement(editor)).not.toBe(before); + expectWrappedLineBreaks(editor, 3); + const [first, second] = getCodeElements(editor); + expect(first.getAttribute(WORD_WRAP_ATTR)).toBe('true'); + // The empty block has word wrap off, so it keeps the default DOM. + expect(second.hasAttribute(LINE_NUMBERS_ATTR)).toBe(false); + expect(editor.read(() => $getCode().getTextContent())).toBe('a\n\nb\n'); + + setWordWrap(editor, false); + expectNoLineNumberDOM(editor); + expect(getRootElement(editor).innerHTML).toBe( + getRootElement(baseline).innerHTML, + ); + }); + + test('undo and redo of word wrap unwrap and wrap the line breaks', () => { + using baseline = createEditor($initialMixedContent, [CodeExtension]); + using editor = createEditor($initialMixedContent, [ + OnlyWordWrappedLineNumbers, + configExtension(HistoryExtension, {delay: 0}), + ]); + setWordWrap(editor, true); + expectWrappedLineBreaks(editor, 3); + + // editor.read flushes the history update before the DOM is checked. + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(editor.read(() => $getCode().getWordWrap())).toBe(false); + expectNoLineNumberDOM(editor); + expect(getRootElement(editor).innerHTML).toBe( + getRootElement(baseline).innerHTML, + ); + + editor.dispatchCommand(REDO_COMMAND, undefined); + expect(editor.read(() => $getCode().getWordWrap())).toBe(true); + expectWrappedLineBreaks(editor, 3); + expect(getCodeElement(editor).getAttribute(WORD_WRAP_ATTR)).toBe('true'); + }); + + test('a caret in the block stays put when its DOM is recreated', () => { + using editor = createEditor( + () => $appendCode(['ab', 'cd']), + [OnlyWordWrappedLineNumbers], + ); + getRootElement(editor).focus(); + const textKey = editor.read(() => + $getCode().getLastChildOrThrow().getKey(), + ); + editor.update( + () => { + const text = $getNodeByKey(textKey); + assert($isTextNode(text)); + text.select(1, 1); + }, + {discrete: true}, + ); + + for (const wordWrap of [true, false]) { + const before = getCodeElement(editor); + setWordWrap(editor, wordWrap); + const codeElement = getCodeElement(editor); + expect(codeElement).not.toBe(before); + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(textKey); + expect(selection.anchor.offset).toBe(1); + }); + const domSelection = getDOMSelection(window); + assert(domSelection !== null); + const {anchorNode} = domSelection; + assert(anchorNode !== null); + expect(anchorNode.isConnected).toBe(true); + expect(codeElement.contains(anchorNode)).toBe(true); + expect(anchorNode).toBe(editor.getElementByKey(textKey)?.firstChild); + expect(domSelection.anchorOffset).toBe(1); + } + }); + + test('the onlyWordWrapped signal switches between the modes', () => { + using editor = createEditor(() => { + $getRoot() + .clear() + .append( + $createCodeFromLines(['a', 'b']).setWordWrap(true), + $createCodeFromLines(['c', 'd']), + ); + }); + const {disabled, onlyWordWrapped} = getExtensionDependencyFromEditor( + editor, + CodeLineNumbersExtension, + ).output; + const expectNumbered = (numbered: boolean[]) => { + const codeElements = getCodeElements(editor); + expect( + codeElements.map(code => code.hasAttribute(LINE_NUMBERS_ATTR)), + ).toEqual(numbered); + expect( + codeElements.map( + code => code.querySelectorAll(`[${LINE_BREAK_ATTR}]`).length, + ), + ).toEqual(numbered.map(isNumbered => (isNumbered ? 1 : 0))); + expect( + codeElements.map(code => code.querySelectorAll('br').length), + ).toEqual([1, 1]); + }; + expectNumbered([true, true]); + + onlyWordWrapped.value = true; + expectNumbered([true, false]); + + // disabled wins over onlyWordWrapped. + disabled.value = true; + expectNoLineNumberDOM(editor); + disabled.value = false; + expectNumbered([true, false]); + + onlyWordWrapped.value = false; + expectNumbered([true, true]); + }); + + test('a line break keeps a wrapper only while its block has word wrap on', () => { + using editor = createEditor(() => { + $getRoot() + .clear() + .append( + $createCodeFromLines(['a', 'b']).setWordWrap(true), + $createCodeFromLines(['c']), + ); + }, [OnlyWordWrappedLineNumbers]); + const [key] = getLineBreakKeys(editor); + const moveLineBreak = (to: number) => { + editor.update( + () => { + const lineBreak = $getNodeByKey(key); + assert($isLineBreakNode(lineBreak)); + $getCodes()[to].append(lineBreak); + }, + {discrete: true}, + ); + return editor.getElementByKey(key); + }; + + const inUnwrapped = moveLineBreak(1); + expect(inUnwrapped?.nodeName).toBe('BR'); + expect(inUnwrapped?.parentElement).toBe(getCodeElements(editor)[1]); + expect(getWrappers(editor)).toHaveLength(0); + + const inWrapped = moveLineBreak(0); + expect(inWrapped?.getAttribute(LINE_BREAK_ATTR)).toBe('true'); + expect(inWrapped?.parentElement).toBe(getCodeElements(editor)[0]); + expect(getWrappers(editor)).toHaveLength(1); + }); + }); + + describe.each([ + ['Prism', CodePrismExtension], + ['Shiki', CodeShikiExtension], + ] as const)('with %s highlighting', (_name, HighlighterExtension) => { + const $initialCode = () => $appendCode(['const a = 1;', 'let b;']); + + beforeAll(async () => { + // Shiki defers highlighting until the grammar and theme have loaded. + await loadCodeLanguage('javascript'); + await loadCodeTheme('one-light'); + // Shiki compiles the grammar the first time it tokenizes with it, and + // it stops splitting a line that takes longer than 500ms. On a busy + // machine the first line of the first test could then stay one token. + // Tokenize once here, so no test pays for that. + using _warmUp = createEditor($initialCode, [HighlighterExtension]); + }); + + function getGutter(editor: LexicalEditor): string | null { + return getCodeElement(editor).getAttribute('data-gutter'); + } + + test('renders the default DOM, data-gutter included, when disabled', () => { + using baseline = createEditor($initialCode, [HighlighterExtension]); + using disabled = createEditor($initialCode, [ + HighlighterExtension, + configExtension(CodeLineNumbersExtension, {disabled: true}), + ]); + // Both blocks were split into tokens, the first line included, so the + // comparison below covers the highlighter's DOM too. + for (const editor of [baseline, disabled]) { + expect( + editor.read(() => $getCode().getFirstChildOrThrow().getTextContent()), + ).toBe('const'); + } + expect(getGutter(baseline)).toBe('1\n2'); + expectNoLineNumberDOM(disabled); + expect(getRootElement(disabled).innerHTML).toBe( + getRootElement(baseline).innerHTML, + ); + }); + + test('keeps the wrappers and data-gutter in step while retokenizing', () => { + using editor = createEditor($initialCode, [ + HighlighterExtension, + CodeLineNumbersExtension, + ]); + // Highlighting split the lines into tokens. + expect(editor.read(() => $getCode().getChildrenSize())).toBeGreaterThan( + 3, + ); + expectWrappedLineBreaks(editor, 1); + expect(getGutter(editor)).toBe('1\n2'); + + editor.update( + () => { + const code = $getCode(); + const lineBreak = code.getChildren().find($isLineBreakNode); + assert(lineBreak !== undefined); + const index = lineBreak.getIndexWithinParent(); + code.select(index, index); + const selection = $getSelection(); + assert($isRangeSelection(selection)); + selection.insertText(' // one'); + }, + {discrete: true}, + ); + expect(editor.read(() => $getCode().getTextContent())).toBe( + 'const a = 1; // one\nlet b;', + ); + expectWrappedLineBreaks(editor, 1); + expect(getGutter(editor)).toBe('1\n2'); + + editor.update( + () => { + $getCode().selectEnd(); + editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); + }, + {discrete: true}, + ); + expectWrappedLineBreaks(editor, 2); + expect(getGutter(editor)).toBe('1\n2\n3'); + }); + + test('with onlyWordWrapped, a wrapped block keeps its wrappers and data-gutter in step', () => { + using editor = createEditor($initialCode, [ + HighlighterExtension, + OnlyWordWrappedLineNumbers, + ]); + expectNoLineNumberDOM(editor); + expect(getGutter(editor)).toBe('1\n2'); + + setWordWrap(editor, true); + expectWrappedLineBreaks(editor, 1); + expect(getCodeElement(editor).getAttribute(WORD_WRAP_ATTR)).toBe('true'); + expect(getGutter(editor)).toBe('1\n2'); + + editor.update( + () => { + const code = $getCode(); + const lineBreak = code.getChildren().find($isLineBreakNode); + assert(lineBreak !== undefined); + const index = lineBreak.getIndexWithinParent(); + code.select(index, index); + const selection = $getSelection(); + assert($isRangeSelection(selection)); + selection.insertText(' // one'); + }, + {discrete: true}, + ); + expect(editor.read(() => $getCode().getChildrenSize())).toBeGreaterThan( + 3, + ); + expectWrappedLineBreaks(editor, 1); + expect(getGutter(editor)).toBe('1\n2'); + + editor.update( + () => { + $getCode().selectEnd(); + editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); + }, + {discrete: true}, + ); + expectWrappedLineBreaks(editor, 2); + expect(getCodeElement(editor).getAttribute(WORD_WRAP_ATTR)).toBe('true'); + expect(getGutter(editor)).toBe('1\n2\n3'); + + setWordWrap(editor, false); + expectNoLineNumberDOM(editor); + expect(getCodeElement(editor).hasAttribute(WORD_WRAP_ATTR)).toBe(false); + expect(getGutter(editor)).toBe('1\n2\n3'); + }); + + test('data-gutter is written again after toggling the extension', () => { + using editor = createEditor($initialCode, [ + HighlighterExtension, + CodeLineNumbersExtension, + ]); + const {disabled} = getExtensionDependencyFromEditor( + editor, + CodeLineNumbersExtension, + ).output; + const before = getCodeElement(editor); + disabled.value = true; + // The code element was recreated without the extension's attribute, + // and the highlighter's mutation listener filled in its gutter. + expect(getCodeElement(editor)).not.toBe(before); + expectNoLineNumberDOM(editor); + expect(getGutter(editor)).toBe('1\n2'); + disabled.value = false; + expectWrappedLineBreaks(editor, 1); + expect(getGutter(editor)).toBe('1\n2'); + }); + }); +}); diff --git a/packages/lexical-code-core/src/index.ts b/packages/lexical-code-core/src/index.ts index 519820b8eb1..2682e7dcc73 100644 --- a/packages/lexical-code-core/src/index.ts +++ b/packages/lexical-code-core/src/index.ts @@ -19,6 +19,10 @@ export { CodeIndentExtension, registerCodeIndentation, } from './CodeIndentation'; +export { + type CodeLineNumbersConfig, + CodeLineNumbersExtension, +} from './CodeLineNumbers'; export type {SerializedCodeNode} from './CodeNode'; export { $createCodeNode, diff --git a/packages/lexical-code/flow/LexicalCode.js.flow b/packages/lexical-code/flow/LexicalCode.js.flow index 49e29a51bb7..e2a1faf74cf 100644 --- a/packages/lexical-code/flow/LexicalCode.js.flow +++ b/packages/lexical-code/flow/LexicalCode.js.flow @@ -23,6 +23,8 @@ import type { ValueOrUpdater, } from 'lexical'; +import type {NamedSignalsOutput} from '@lexical/extension'; + import {ElementNode, TextNode} from 'lexical'; /** @@ -115,3 +117,10 @@ export type CodeIndentConfig = { }; declare export var CodeIndentExtension: LexicalExtension; + +export type CodeLineNumbersConfig = { + disabled: boolean, + onlyWordWrapped: boolean, +}; + +declare export var CodeLineNumbersExtension: LexicalExtension, void>; diff --git a/packages/lexical-code/src/index.ts b/packages/lexical-code/src/index.ts index 88ce8c8fc89..afaeddd8e29 100644 --- a/packages/lexical-code/src/index.ts +++ b/packages/lexical-code/src/index.ts @@ -22,6 +22,8 @@ export { CodeHighlightNode, type CodeIndentConfig, CodeIndentExtension, + type CodeLineNumbersConfig, + CodeLineNumbersExtension, CodeNode, DEFAULT_CODE_LANGUAGE, getDefaultCodeLanguage, diff --git a/packages/lexical-website/docs/extensions/included-extensions.md b/packages/lexical-website/docs/extensions/included-extensions.md index fc6bb6a4ec4..e8acc3cf326 100644 --- a/packages/lexical-website/docs/extensions/included-extensions.md +++ b/packages/lexical-website/docs/extensions/included-extensions.md @@ -21,6 +21,7 @@ Framework-agnostic accessibility extensions. See [Keyboard Accessibility](/docs/ - [CodeExtension](/docs/api/modules/lexical_code#codeextension) - CodeNode (code blocks) - [CodeIndentExtension](/docs/api/modules/lexical_code#codeindentextension) - CodeNode tab key indentation (code blocks) +- [CodeLineNumbersExtension](/docs/api/modules/lexical_code#codelinenumbersextension) - Marks each line of a CodeNode so a theme can number lines with a CSS counter that follows wrapped lines; set `onlyWordWrapped` to number only code blocks with word wrap on (experimental) [@lexical/code-prism](/docs/api/modules/lexical_code-prism) From f6555d2c61a34663cfcb0b6e61533a06a2de4204 Mon Sep 17 00:00:00 2001 From: Om Singhal Date: Wed, 23 Sep 2026 21:42:22 -0400 Subject: [PATCH 03/31] [lexical-playground] Feature: wrap lines toggle and per line numbers for code blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The playground had no way to soft wrap a code block, and no way to try CodeLineNumbersExtension. The code action menu now has a wrap button next to Copy. It toggles CodeNode word wrap on the hovered block, and it finds the block by its node key, because with line numbers only on wrapped blocks the toggle replaces the code element. The button has aria-label "wrap", the title "Wrap lines" and aria-pressed. The pressed state comes from the node when the block is hovered, and from later updates of that node. The icon reuses text-paragraph.svg as a mask, like the other menu icons. The menu's icon rule gives a mask no color to show, so the wrap icon sets its own. The playground registers CodeLineNumbersExtension with onlyWordWrapped. A wrapped block always gets per line numbers, because data-gutter can't follow wrapped rows, and the other blocks keep the data-gutter float, so their DOM does not change. A new setting, Per Line Numbers in All Code Blocks, off by default, turns onlyWordWrapped off, so every block is numbered per line. PlaygroundEditorTheme.css: - Per line numbers use a CSS counter, in the same 52px that the data-gutter float takes up. The code element's ::before numbers line 1, and the ::after of each line break wrapper numbers the line it starts. Each number takes no room in the line, so the text doesn't move, and a background draws the gutter column for the full height of the block. The right to left background uses :dir(rtl), because code blocks render dir="auto", which [dir='rtl'] never matches. - The numbers are sticky with inset-inline-start: -52px. Chromium, Firefox and WebKit measure a sticky offset from inside the code element's 52px start padding, so 0 would push every number over the start of its line. - A block with per line numbers resets the base rule's scroll-padding-left to 0 and uses scroll-padding-inline-start: 52px, so Lexical keeps the caret out from under the numbers on whichever side they are drawn. - It also gets the 8px of bottom padding that the float used to add. WebKit needs it to keep the number of the last line in view while the block scrolls sideways. The base rule's padding-inline-end already leaves room for the caret at the end of the longest line. - A wrapped block uses white-space: pre-wrap and overflow-wrap: anywhere, and keeps overflow-x: auto. Its numbers are placed with position: absolute at the inline start, so a number can't be left alone on a row before a long token. top stays auto, so each number stays on the first row of its line. VisibleNonPrintingCodeLineNumbers.test.ts checks that the playground's VisibleNonPrintingExtension and CodeLineNumbersExtension together give each line break exactly one wrapper. ## Test plan ### Before The new tests, with the parent commit's playground source: pnpm vitest run --project unit packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx packages/lexical-playground/__tests__/unit/VisibleNonPrintingCodeLineNumbers.test.ts × CodeActionMenuPlugin wrap button > shows the word wrap of the hovered code block AssertionError: expected the menu to have a wrap button × CodeActionMenuPlugin wrap button > toggles word wrap on the hovered code block AssertionError: expected the menu to have a wrap button Test Files 1 failed | 1 passed (2) Tests 2 failed | 3 passed (5) VisibleNonPrintingCodeLineNumbers.test.ts passes there too. It checks two extensions that both exist at the parent commit, together, as the playground now registers them. ### After pnpm vitest run --project unit packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx packages/lexical-playground/__tests__/unit/VisibleNonPrintingCodeLineNumbers.test.ts --reporter=verbose ✓ VisibleNonPrintingExtension with CodeLineNumbersExtension > wraps each line break once, by its parent ✓ VisibleNonPrintingExtension with CodeLineNumbersExtension > a code line break is bare while line numbers are off ✓ VisibleNonPrintingExtension with CodeLineNumbersExtension > swaps wrappers when a code block collapses into a paragraph ✓ CodeActionMenuPlugin wrap button > shows the word wrap of the hovered code block ✓ CodeActionMenuPlugin wrap button > toggles word wrap on the hovered code block Test Files 2 passed (2) Tests 5 passed (5) pnpm vitest run --project unit packages/lexical-playground/__tests__/unit packages/lexical-playground/src/__tests__/unit Test Files 34 passed (34) Tests 373 passed (373) pnpm run tsc and pnpm run flow pass, and prettier and eslint pass on the changed files. The CSS is checked in headless Chromium, Firefox and WebKit by CodeBlockCaretScroll.test.ts and CodeBlockWordWrap.test.ts, in the tests commit on top of this one. Its Before section shows which cases fail without the per line scroll padding and without the wrap rules. The WebKit note on padding-bottom comes from measuring the numbers drawn next to each row with elementFromPoint, with and without it. --- .../unit/CodeActionMenuWrapButton.test.tsx | 180 ++++++++++++++++++ .../VisibleNonPrintingCodeLineNumbers.test.ts | 160 ++++++++++++++++ packages/lexical-playground/src/App.tsx | 3 +- packages/lexical-playground/src/Settings.tsx | 9 + .../lexical-playground/src/appSettings.ts | 1 + .../src/hooks/useSynchronizeSettings.ts | 3 + packages/lexical-playground/src/index.css | 5 + .../components/WrapButton/index.tsx | 43 +++++ .../plugins/CodeActionMenuPlugin/index.css | 20 ++ .../plugins/CodeActionMenuPlugin/index.tsx | 25 +++ .../src/themes/PlaygroundEditorTheme.css | 104 ++++++++++ 11 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx create mode 100644 packages/lexical-playground/__tests__/unit/VisibleNonPrintingCodeLineNumbers.test.ts create mode 100644 packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WrapButton/index.tsx diff --git a/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx b/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx new file mode 100644 index 00000000000..22c9ad22358 --- /dev/null +++ b/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx @@ -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 { + $createCodeHighlightNode, + $createCodeNode, + $isCodeNode, + CodeExtension, + CodeLineNumbersExtension, +} from '@lexical/code-core'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createLineBreakNode, + $getRoot, + configExtension, + defineExtension, + type LexicalEditor, +} from 'lexical'; +import * as React from 'react'; +import {act} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, assert, beforeEach, describe, expect, it} from 'vitest'; + +import CodeActionMenuPlugin from '../../src/plugins/CodeActionMenuPlugin'; + +// The first code block is unwrapped and the second is wrapped. Line numbers +// are only on wrapped blocks, as in the playground, so a toggle recreates the +// code element. +const CodeMenuTestExtension = defineExtension({ + $initialEditorState: () => { + $getRoot() + .clear() + .append( + $createCodeNode('javascript').append( + $createCodeHighlightNode('a'), + $createLineBreakNode(), + $createCodeHighlightNode('b'), + ), + $createCodeNode('javascript') + .setWordWrap(true) + .append($createCodeHighlightNode('c')), + ); + }, + dependencies: [ + RichTextExtension, + CodeExtension, + configExtension(CodeLineNumbersExtension, {onlyWordWrapped: true}), + ], + name: '[test-code-action-menu-wrap]', + theme: {code: 'PlaygroundEditorTheme__code'}, +}); + +describe('CodeActionMenuPlugin wrap button', () => { + let container: HTMLDivElement; + let anchorElem: HTMLDivElement; + let reactRoot: Root; + let editor: LexicalEditor; + + function Capture() { + const [contextEditor] = useLexicalComposerContext(); + editor = contextEditor; + return null; + } + + beforeEach(async () => { + container = document.createElement('div'); + anchorElem = document.createElement('div'); + document.body.append(container, anchorElem); + reactRoot = createRoot(container); + + await act(async () => { + reactRoot.render( + + + + , + ); + }); + }); + + afterEach(async () => { + await act(async () => { + reactRoot.unmount(); + }); + container.remove(); + anchorElem.remove(); + }); + + function getCodeElements(): HTMLElement[] { + const rootElement = editor.getRootElement(); + assert(rootElement !== null); + return Array.from(rootElement.querySelectorAll('code')); + } + + function getWordWraps(): boolean[] { + return editor.read(() => + $getRoot() + .getChildren() + .map(node => $isCodeNode(node) && node.getWordWrap()), + ); + } + + function getWrapButton(): HTMLButtonElement { + const button = anchorElem.querySelector( + 'button[aria-label="wrap"]', + ); + assert(button !== null, 'expected the menu to have a wrap button'); + return button; + } + + async function hover(code: HTMLElement): Promise { + // The menu reads the hovered block after a 50ms debounce. + await act(async () => { + code.dispatchEvent(new MouseEvent('mousemove', {bubbles: true})); + await new Promise(resolve => setTimeout(resolve, 100)); + }); + } + + async function clickWrapButton(): Promise { + await act(async () => { + getWrapButton().click(); + // Commit the update, so the mutation listener sees it. + editor.read(() => {}); + }); + } + + it('shows the word wrap of the hovered code block', async () => { + const [first, second] = getCodeElements(); + await hover(first); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('false'); + await hover(second); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('true'); + await hover(first); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('false'); + + // An edit in the other block, which is wrapped, leaves the button alone. + await act(async () => { + editor.update( + () => { + const code = $getRoot().getLastChildOrThrow(); + assert($isCodeNode(code)); + code.append($createCodeHighlightNode('d')); + }, + {discrete: true}, + ); + }); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('false'); + }); + + it('toggles word wrap on the hovered code block', async () => { + const [first, second] = getCodeElements(); + expect(first.hasAttribute('data-lexical-code-line-numbers')).toBe(false); + await hover(first); + + await clickWrapButton(); + expect(getWordWraps()).toEqual([true, true]); + const [wrapped] = getCodeElements(); + expect(wrapped).not.toBe(first); + expect(wrapped.getAttribute('data-lexical-code-word-wrap')).toBe('true'); + expect(wrapped.hasAttribute('data-lexical-code-line-numbers')).toBe(true); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('true'); + + // No hover in between, so the menu still holds the code element that + // was replaced. + await clickWrapButton(); + expect(getWordWraps()).toEqual([false, true]); + expect( + getCodeElements()[0].hasAttribute('data-lexical-code-word-wrap'), + ).toBe(false); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('false'); + expect(getCodeElements()[1]).toBe(second); + }); +}); diff --git a/packages/lexical-playground/__tests__/unit/VisibleNonPrintingCodeLineNumbers.test.ts b/packages/lexical-playground/__tests__/unit/VisibleNonPrintingCodeLineNumbers.test.ts new file mode 100644 index 00000000000..a6ca7ed670b --- /dev/null +++ b/packages/lexical-playground/__tests__/unit/VisibleNonPrintingCodeLineNumbers.test.ts @@ -0,0 +1,160 @@ +/** + * 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, + CodeLineNumbersExtension, +} from '@lexical/code-core'; +import { + buildEditorFromExtensions, + getExtensionDependencyFromEditor, + type LexicalEditorWithDispose, +} from '@lexical/extension'; +import {ListExtension} from '@lexical/list'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $getRoot, + $isElementNode, + $isLineBreakNode, + defineExtension, + type LexicalEditor, +} from 'lexical'; +import {afterEach, assert, describe, expect, test} from 'vitest'; + +import {VisibleNonPrintingExtension} from '../../src/plugins/VisibleNonPrintingExtension'; + +const CODE_LINE_BREAK_ATTR = 'data-lexical-code-line-break'; +const VISIBLE_LINE_BREAK_ATTR = 'data-lexical-visible-non-printing-linebreak'; + +const mountedRoots: HTMLElement[] = []; +afterEach(() => { + for (const root of mountedRoots.splice(0)) { + root.remove(); + } +}); + +function createEditor(): LexicalEditorWithDispose { + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => { + $getRoot() + .clear() + .append( + $createParagraphNode().append( + $createTextNode('p'), + $createLineBreakNode(), + $createTextNode('q'), + ), + $createCodeNode('javascript').append( + $createCodeHighlightNode('a'), + $createLineBreakNode(), + $createCodeHighlightNode('b'), + ), + ); + }, + dependencies: [ + RichTextExtension, + ListExtension, + CodeLineNumbersExtension, + VisibleNonPrintingExtension, + ], + name: '[visible-non-printing-code-line-numbers]', + }), + ); + const root = document.createElement('div'); + document.body.appendChild(root); + mountedRoots.push(root); + editor.setRootElement(root); + return editor; +} + +/** + * Every LineBreakNode renders as exactly one wrapper around its
: the + * line number wrapper inside a code block and the visible line break wrapper + * everywhere else. Returns how many line breaks are in each. + */ +function expectOneWrapperEach(editor: LexicalEditor): { + inCode: number; + outside: number; +} { + const counts = {inCode: 0, outside: 0}; + editor.read(() => { + for (const block of $getRoot().getChildren()) { + assert($isElementNode(block)); + const isCode = $isCodeNode(block); + for (const child of block.getChildren()) { + if (!$isLineBreakNode(child)) { + continue; + } + const dom = editor.getElementByKey(child.getKey()); + assert(dom !== null, 'expected every line break to be rendered'); + expect(dom.parentElement).toBe(editor.getElementByKey(block.getKey())); + expect(dom.tagName).toBe('SPAN'); + expect(dom.childNodes).toHaveLength(1); + expect(dom.firstChild?.nodeName).toBe('BR'); + expect(dom.hasAttribute(CODE_LINE_BREAK_ATTR)).toBe(isCode); + expect(dom.hasAttribute(VISIBLE_LINE_BREAK_ATTR)).toBe(!isCode); + counts[isCode ? 'inCode' : 'outside']++; + } + } + }); + const root = editor.getRootElement(); + assert(root !== null); + expect(root.querySelectorAll(`[${CODE_LINE_BREAK_ATTR}]`)).toHaveLength( + counts.inCode, + ); + expect(root.querySelectorAll(`[${VISIBLE_LINE_BREAK_ATTR}]`)).toHaveLength( + counts.outside, + ); + return counts; +} + +describe('VisibleNonPrintingExtension with CodeLineNumbersExtension', () => { + test('wraps each line break once, by its parent', () => { + using editor = createEditor(); + expect(expectOneWrapperEach(editor)).toEqual({inCode: 1, outside: 1}); + }); + + test('a code line break is bare while line numbers are off', () => { + using editor = createEditor(); + const {disabled} = getExtensionDependencyFromEditor( + editor, + CodeLineNumbersExtension, + ).output; + disabled.value = true; + const codeLineBreak = editor.read(() => { + const code = $getRoot().getLastChildOrThrow(); + assert($isCodeNode(code)); + const lineBreak = code.getChildren().find($isLineBreakNode); + assert(lineBreak !== undefined); + return editor.getElementByKey(lineBreak.getKey()); + }); + expect(codeLineBreak?.nodeName).toBe('BR'); + expect(codeLineBreak?.parentElement?.nodeName).toBe('CODE'); + disabled.value = false; + expect(expectOneWrapperEach(editor)).toEqual({inCode: 1, outside: 1}); + }); + + test('swaps wrappers when a code block collapses into a paragraph', () => { + using editor = createEditor(); + editor.update( + () => { + const code = $getRoot().getLastChildOrThrow(); + assert($isCodeNode(code)); + code.collapseAtStart(); + }, + {discrete: true}, + ); + expect(expectOneWrapperEach(editor)).toEqual({inCode: 0, outside: 2}); + }); +}); diff --git a/packages/lexical-playground/src/App.tsx b/packages/lexical-playground/src/App.tsx index 72814ded2a4..aacefa8d2e6 100644 --- a/packages/lexical-playground/src/App.tsx +++ b/packages/lexical-playground/src/App.tsx @@ -13,7 +13,7 @@ import { HistoryAnnounceExtension, RovingTabIndexExtension, } from '@lexical/a11y'; -import {$isCodeNode} from '@lexical/code'; +import {$isCodeNode, CodeLineNumbersExtension} from '@lexical/code'; import { $defaultShouldInsertAfter, AutoFocusExtension, @@ -288,6 +288,7 @@ const AppExtension = defineExtension({ configExtension(VisibleNonPrintingExtension, { disabled: true, }), + configExtension(CodeLineNumbersExtension, {onlyWordWrapped: true}), // DOMImportExtension pipeline — `PlaygroundImportExtension` bundles // the shared `CoreImportExtension` baseline, the playground-specific // inline-style overlay and the `ClipboardDOMImportExtension` paste diff --git a/packages/lexical-playground/src/Settings.tsx b/packages/lexical-playground/src/Settings.tsx index 4edd6782a4a..3c7c16ebb1a 100644 --- a/packages/lexical-playground/src/Settings.tsx +++ b/packages/lexical-playground/src/Settings.tsx @@ -38,6 +38,7 @@ export default function Settings(): JSX.Element { selectBlock, isCodeHighlighted, isCodeShiki, + isCodeLineNumbers, isShadowDOM, }, } = useSettings(); @@ -224,6 +225,14 @@ export default function Settings(): JSX.Element { text="Use Shiki for Code Highlighting" /> + { + setOption('isCodeLineNumbers', !isCodeLineNumbers); + }} + checked={isCodeLineNumbers} + text="Per Line Numbers in All Code Blocks" + /> + { setOption('isShadowDOM', !isShadowDOM); diff --git a/packages/lexical-playground/src/appSettings.ts b/packages/lexical-playground/src/appSettings.ts index 57d8828f810..464933a1443 100644 --- a/packages/lexical-playground/src/appSettings.ts +++ b/packages/lexical-playground/src/appSettings.ts @@ -20,6 +20,7 @@ export const DEFAULT_SETTINGS = { isCharLimit: false, isCharLimitUtf8: false, isCodeHighlighted: true, + isCodeLineNumbers: false, isCodeShiki: false, isCollab: false, isMaxLength: false, diff --git a/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts b/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts index 8ac1fa22291..8dd6885d0c9 100644 --- a/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts +++ b/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts @@ -8,6 +8,7 @@ import type {AnyLexicalExtension, LexicalEditor} from 'lexical'; +import {CodeLineNumbersExtension} from '@lexical/code'; import { batch, effect, @@ -89,6 +90,8 @@ export function synchronizeSettingsToSignals( !settings.isAutocomplete; output(editor, VisibleNonPrintingExtension).disabled.value = !settings.isVisibleNonPrinting; + output(editor, CodeLineNumbersExtension).onlyWordWrapped.value = + !settings.isCodeLineNumbers; output(editor, MaxLengthExtension).disabled.value = !settings.isMaxLength; const codeHighlight = peerOutput(editor, CodeHighlightExtension); if (codeHighlight) { diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index 263d534928a..91d51e61040 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -749,6 +749,11 @@ i.copy { mask-image: url(images/icons/copy.svg); } +i.wrap { + -webkit-mask-image: url(images/icons/text-paragraph.svg); + mask-image: url(images/icons/text-paragraph.svg); +} + i.paste { -webkit-mask-image: url(images/icons/clipboard.svg); mask-image: url(images/icons/clipboard.svg); diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WrapButton/index.tsx b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WrapButton/index.tsx new file mode 100644 index 00000000000..ecea0f2efdb --- /dev/null +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WrapButton/index.tsx @@ -0,0 +1,43 @@ +/** + * 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 {$isCodeNode} from '@lexical/code'; +import {$getNodeByKey, type LexicalEditor, type NodeKey} from 'lexical'; + +interface Props { + editor: LexicalEditor; + getCodeKey: () => NodeKey | null; + isWrapped: boolean; +} + +export function WrapButton({editor, getCodeKey, isWrapped}: Props) { + // Find the node by key rather than through the hovered code element. With + // line numbers only on wrapped blocks, toggling wrap replaces that element. + function handleClick(): void { + const key = getCodeKey(); + if (key === null) { + return; + } + editor.update(() => { + const codeNode = $getNodeByKey(key); + if ($isCodeNode(codeNode)) { + codeNode.setWordWrap(wordWrap => !wordWrap); + } + }); + } + + return ( + + ); +} diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.css b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.css index ec85b8a78fd..f7673a4bfbc 100644 --- a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.css +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.css @@ -43,6 +43,17 @@ background-size: contain; } +/* i.wrap is a mask, so it needs a color to show through it. */ +.code-action-menu-container button.menu-item i.format.wrap { + background-color: CanvasText; + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; +} + .code-action-menu-container button.menu-item:hover { border: 1px solid rgba(0, 0, 0, 0.3); opacity: 0.9; @@ -52,3 +63,12 @@ background-color: rgba(223, 232, 250); border: 1px solid rgba(0, 0, 0, 0.45); } + +.code-action-menu-container button.menu-item[aria-pressed='true'] { + background-color: rgba(223, 232, 250); + border: 1px solid rgba(0, 0, 0, 0.3); +} + +.code-action-menu-container button.menu-item[aria-pressed='true'] i.format { + opacity: 1; +} diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx index 637fb599459..d877fb50fd5 100644 --- a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx @@ -17,9 +17,11 @@ import { import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { $getNearestNodeFromDOMNode, + $getNodeByKey, getComposedEventTarget, isHTMLElement, mergeRegister, + type NodeKey, registerEventListener, } from 'lexical'; import * as React from 'react'; @@ -28,6 +30,7 @@ import {createPortal} from 'react-dom'; import {CopyButton} from './components/CopyButton'; import {PrettierButton} from './components/PrettierButton'; +import {WrapButton} from './components/WrapButton'; import {canBePrettier} from './formatCodeWithPrettier'; import {useDebounce} from './utils'; @@ -46,6 +49,7 @@ function CodeActionMenuContainer({ const [editor] = useLexicalComposerContext(); const [lang, setLang] = useState(''); + const [isWrapped, setWrapped] = useState(false); const [isShown, setShown] = useState(false); const [shouldListenMouseMove, setShouldListenMouseMove] = useState(false); @@ -55,11 +59,16 @@ function CodeActionMenuContainer({ }); const codeSetRef = useRef>(new Set()); const codeDOMNodeRef = useRef(null); + const codeKeyRef = useRef(null); function getCodeDOMNode(): HTMLElement | null { return codeDOMNodeRef.current; } + function getCodeKey(): NodeKey | null { + return codeKeyRef.current; + } + const debouncedOnMouseMove = useDebounce( (event: MouseEvent) => { const {codeDOMNode, isOutside} = getMouseInfo(event); @@ -76,6 +85,7 @@ function CodeActionMenuContainer({ let codeNode: CodeNode | null = null; let _lang = ''; + let _wordWrap = false; editor.update(() => { const maybeCodeNode = $getNearestNodeFromDOMNode(codeDOMNode); @@ -83,6 +93,8 @@ function CodeActionMenuContainer({ if ($isCodeNode(maybeCodeNode)) { codeNode = maybeCodeNode; _lang = codeNode.getLanguage() || ''; + _wordWrap = codeNode.getWordWrap(); + codeKeyRef.current = codeNode.getKey(); } }); @@ -91,6 +103,7 @@ function CodeActionMenuContainer({ anchorElem.getBoundingClientRect(); const {y, right} = codeDOMNode.getBoundingClientRect(); setLang(_lang); + setWrapped(_wordWrap); setShown(true); setPosition({ right: `${editorElemRight - right + CODE_PADDING}px`, @@ -131,6 +144,13 @@ function CodeActionMenuContainer({ codeSetRef.current.delete(key); break; + case 'updated': + if (key === codeKeyRef.current) { + const node = $getNodeByKey(key); + setWrapped($isCodeNode(node) && node.getWordWrap()); + } + break; + default: break; } @@ -158,6 +178,11 @@ function CodeActionMenuContainer({
{codeFriendlyName}
+ {canBePrettier(normalizedLang) ? ( that starts it, so a number stays with its line when the + * line wraps and empty lines get one too. Each number advances the line by + * zero (negative start margin), so it sits in the same 52px the float gutter + * above takes up and the text doesn't move. The background draws the gutter + * column for the full height of the block. The sticky inset cancels the 52px + * start padding: browsers measure sticky offsets from inside the scroll + * container's padding, so 0 would push every number over the start of its + * line. + */ +.PlaygroundEditorTheme__code[data-lexical-code-line-numbers] { + counter-reset: lexical-code-line; + padding-inline-start: 52px; + background-image: linear-gradient( + to right, + #eee 41px, + #ccc 41px, + #ccc 42px, + transparent 42px + ); + background-repeat: no-repeat; + /* This gutter is at the inline start. The logical declaration must come + after the physical reset. */ + scroll-padding-left: 0; + scroll-padding-inline-start: 52px; + /* The data-gutter float used to add this space under the last line. + WebKit also needs it to keep the last line's sticky number in view + while the block scrolls sideways. */ + padding-bottom: 8px; +} +.PlaygroundEditorTheme__code[data-lexical-code-line-numbers]:dir(rtl) { + background-image: linear-gradient( + to left, + #eee 41px, + #ccc 41px, + #ccc 42px, + transparent 42px + ); +} +/* Keep these after the data-gutter rules above: they override + .PlaygroundEditorTheme__code:dir(rtl):before, which has the same + specificity, so the order decides. */ +.PlaygroundEditorTheme__code[data-lexical-code-line-numbers]:before, +.PlaygroundEditorTheme__code[data-lexical-code-line-numbers] + [data-lexical-code-line-break]:after { + counter-increment: lexical-code-line; + content: counter(lexical-code-line); + content: counter(lexical-code-line) / ''; + display: inline-block; + box-sizing: border-box; + width: 42px; + min-width: 0; + min-height: 0; + margin: 0; + margin-inline-start: -52px; + margin-inline-end: 10px; + padding: 0 8px; + float: none; + border: 0; + border-inline-end: 1px solid #ccc; + position: sticky; + top: auto; + left: auto; + inset-inline-start: -52px; + z-index: 3; + background-color: #eee; + color: #777; + text-align: end; + white-space: pre; + pointer-events: none; + user-select: none; + -webkit-user-select: none; +} +/* + * Word wrap (CodeNode.setWordWrap). Long lines wrap instead of scrolling, + * and no LineBreakNode is added. + * + * overflow-x stays auto from the base rule: overflow-x: visible would + * change the scroll container the numbers are placed against. + * + * The numbers leave the line box, because an inline-block number in the + * flow is a wrap opportunity and can be left alone on a row before a long + * token. top stays auto, so each number stays on the first row of its line + * (its static position after the
). The display must stay inline, like + * inline-block, because a block display would move that static position. + * + * Nothing scrolls sideways, so the numbers don't need to be sticky. + * Nothing in flow sits before a line's text, so the caret can't end up + * under a number. + */ +.PlaygroundEditorTheme__code[data-lexical-code-word-wrap] { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.PlaygroundEditorTheme__code[data-lexical-code-word-wrap][data-lexical-code-line-numbers]:before, +.PlaygroundEditorTheme__code[data-lexical-code-word-wrap][data-lexical-code-line-numbers] + [data-lexical-code-line-break]:after { + position: absolute; + inset-inline-start: 0; + margin-inline-start: 0; + margin-inline-end: 0; +} .PlaygroundEditorTheme__tableScrollableWrapper { overflow-x: auto; margin: 0px 0px 5px 0px; From 5c7577583e24bd212b64b62dd39b7d25ffdbac87 Mon Sep 17 00:00:00 2001 From: Om Singhal Date: Wed, 23 Sep 2026 21:42:22 -0400 Subject: [PATCH 04/31] [lexical-playground] Chore: browser and e2e tests for code block word wrap and per line numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The caret scrolling tests only covered the data-gutter float, and word wrap had no test in a real browser. Both depend on real layout, which the jsdom unit tests can't check. CodeBlockCaretScroll.test.ts now runs every scroll case for two gutters. The data-gutter float now runs with CodeLineNumbersExtension configured as onlyWordWrapped, the playground's default, and checks that the block has no line number elements. Per line numbers run with onlyWordWrapped false, as the playground's setting does. When the caret sits before a line break wrapper, the test measures the wrapper's
, because the wrapper's own rect also covers the number drawn on the next line. One new case, for both gutters, moves from the logical end of a long right to left line to the end of a shorter one. The block scrolls back only part of the way, and the caret must stop clear of the per line numbers, which are drawn on the right there. That case is what checks the per line rule's scroll-padding-inline-start. The data-gutter float has scrolled out of view by then in all three browsers, so for the float it only checks that the caret is in view. CodeBlockWordWrap.test.ts turns word wrap on for a block with a long line, an empty line, one long token and a short line. It runs with line numbers only on wrapped blocks, the playground default, and with line numbers on every block, the setting. It checks that the block stops overflowing, that the first row of each line has its number and the rows it wraps onto have none, and that no number is drawn over text. Numbers are found by hit testing with only the number pseudo elements as targets. Unwrapping brings back the overflow, and the data-gutter float or the per line numbers. CodeBlockCaretScroll.spec.mjs now runs its keys for each gutter: the data-gutter float with Prism, and per line numbers with Shiki. After Enter it also checks that the caret is not inside a line break wrapper. Shiki loads its grammar asynchronously, so the spec waits for the tokens before the first Enter. When the grammar lands after an Enter that left the caret on a new empty line, the highlighter puts the caret back by child index, and the next line is typed into the first one. With the html grammar held back by 1.5s, that happened 3 times out of 3 with the core files from main too, so it is not caused by these changes and is not fixed here. The e2e initialize helper takes isCodeLineNumbers and isCodeShiki now. Existing specs don't pass them and are unchanged. CodeBlockWordWrap.spec.mjs clicks the new wrap button in the playground with Prism and with Shiki. It checks aria-pressed, the line numbers attribute, the JSON, the overflow, the number on the first row of each line, the copied text and HTML, and toggling back. In WebKit, a right to left block with per line numbers loses the number of line 1 while it is scrolled sideways. That number is the code element's own ::before, and WebKit does not keep it in view there, while the numbers of the other lines stay. Chromium and Firefox keep it. Measured on this branch with elementFromPoint in a 360px block with three right to left lines, line 1's number is found at x 317 to 359 with the block at scrollLeft 0, and not at all at -150, -1000 or -4774, while the numbers of lines 2 and 3 are found at x 317 to 359 at every one of those positions. The tests check that no number is drawn over the caret, not that every number is drawn, so this does not fail them. The README notes it. ## Test plan ### Before With the new tests in place, and packages/lexical-code-core/src, packages/lexical-code/src and packages/lexical-playground/src from the branch this one is stacked on, apart from the browser tests: VITEST_BROWSER=chromium pnpm vitest run --project browser packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts FAIL packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts Error: Failed to import test file Caused by: SyntaxError: The requested module '/packages/lexical-code-core/src/index.ts' does not provide an export named 'CodeLineNumbersExtension' Tests no tests CodeBlockWordWrap.test.ts fails to import the same way. The e2e runs need the playground dev server on port 3000 (pnpm run dev). pnpm run test-e2e-chromium --retries=0 packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs ✓ 1 Home, End and Enter keep the caret visible in long lines (data-gutter float with Prism) ✘ 2 Home, End and Enter keep the caret visible in long lines (per line numbers with Shiki) Error: expect(locator).toHaveAttribute(expected) failed 1 failed 1 passed pnpm run test-e2e-chromium --retries=0 packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs ✘ 1 The wrap button soft wraps a code block and numbers its lines (Prism) TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. ✘ 2 The wrap button soft wraps a code block and numbers its lines (Shiki) TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. 2 failed With everything in place except the per line rule's scroll-padding-left: 0 and scroll-padding-inline-start: 52px in PlaygroundEditorTheme.css, only the new right to left case fails, in all three browsers. The caret stops at the right edge, under the numbers: VITEST_BROWSER=chromium pnpm vitest run --project browser packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts × caret scrolling in a code block with per line numbers > moving back to the end of a shorter right to left line stops the caret clear of the gutter AssertionError: {"caretBottom":52.875,"caretLeft":358.09375,"caretRight":358.09375,"caretTop":37.875,"lineStartX":307.984375,"scrollLeft":-2392,"scrollportLeft":0,"scrollportRight":360}: expected true to be false Tests 1 failed | 16 passed (17) Firefox and WebKit fail the same case, with the caret at x 359 and 352.1. With PlaygroundEditorTheme.css from the branch this one is stacked on, the block never stops overflowing: VITEST_BROWSER=chromium pnpm vitest run --project browser packages/lexical-playground/src/__tests__/browser/CodeBlockWordWrap.test.ts × word wrap in a code block with line numbers only while wrapped > wraps long lines and numbers the first row of every line × word wrap in a code block with line numbers on every block > wraps long lines and numbers the first row of every line AssertionError: expected 5187 to be less than or equal to 361 Tests 2 failed | 2 passed (4) ### After VITEST_BROWSER=chromium pnpm vitest run --project browser --reporter=verbose packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts ✓ caret scrolling in a code block with the data-gutter float > End reveals the end of a long line, and Home scrolls all the way back ✓ caret scrolling in a code block with the data-gutter float > moving back to the end of a shorter line stops the caret at the gutter, not under it ✓ caret scrolling in a code block with the data-gutter float > Enter after typing at the end of a long line scrolls back to the start of the new line ✓ caret scrolling in a code block with the data-gutter float > smart Home on an indented line scrolls all the way back and shows the indentation ✓ caret scrolling in a code block with the data-gutter float > Enter on an indented line scrolls all the way back and shows the copied indentation ✓ caret scrolling in a code block with the data-gutter float > right to left lines scroll the other way and keep the caret visible ✓ caret scrolling in a code block with the data-gutter float > moving back to the end of a shorter right to left line stops the caret clear of the gutter ✓ caret scrolling in a code block with per line numbers > End reveals the end of a long line, and Home scrolls all the way back ✓ caret scrolling in a code block with per line numbers > moving back to the end of a shorter line stops the caret at the gutter, not under it ✓ caret scrolling in a code block with per line numbers > Enter after typing at the end of a long line scrolls back to the start of the new line ✓ caret scrolling in a code block with per line numbers > smart Home on an indented line scrolls all the way back and shows the indentation ✓ caret scrolling in a code block with per line numbers > Enter on an indented line scrolls all the way back and shows the copied indentation ✓ caret scrolling in a code block with per line numbers > right to left lines scroll the other way and keep the caret visible ✓ caret scrolling in a code block with per line numbers > moving back to the end of a shorter right to left line stops the caret clear of the gutter ✓ caret scrolling in a code block scaled by 0.5 with CSS transform > End and smart Home keep the caret visible and clear of the gutter ✓ caret scrolling in a code block scaled by 2 with CSS transform > End and smart Home keep the caret visible and clear of the gutter ✓ caret scrolling in a code block scaled by 2 with CSS zoom > End and smart Home keep the caret visible and clear of the gutter Tests 17 passed (17) VITEST_BROWSER=chromium pnpm vitest run --project browser --reporter=verbose packages/lexical-playground/src/__tests__/browser/CodeBlockWordWrap.test.ts ✓ word wrap in a code block with line numbers only while wrapped > wraps long lines and numbers the first row of every line ✓ word wrap in a code block with line numbers only while wrapped > unwrapping scrolls long lines again and brings back the gutter ✓ word wrap in a code block with line numbers on every block > wraps long lines and numbers the first row of every line ✓ word wrap in a code block with line numbers on every block > unwrapping scrolls long lines again and brings back the gutter Tests 4 passed (4) pnpm run test-e2e-chromium --retries=0 packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs ✓ 1 Code block caret scrolling › Home, End and Enter keep the caret visible in long lines (data-gutter float with Prism) (6.3s) ✓ 2 Code block caret scrolling › Home, End and Enter keep the caret visible in long lines (per line numbers with Shiki) (3.2s) 2 passed (11.1s) pnpm run test-e2e-chromium --retries=0 packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs ✓ 1 Code block word wrap › The wrap button soft wraps a code block and numbers its lines (Prism) (5.7s) ✓ 2 Code block word wrap › The wrap button soft wraps a code block and numbers its lines (Shiki) (2.6s) 2 passed (10.0s) Each of the four files was run twice in headless Chromium, Firefox and WebKit on macOS, green every time. On macOS the e2e line moves are Meta+ArrowLeft and Meta+ArrowRight. Linux, Windows and collab were not run. pnpm run tsc and pnpm run flow pass, and prettier and eslint pass on the changed files. --- .../e2e/CodeBlockCaretScroll.spec.mjs | 193 ++++++++---- .../__tests__/e2e/CodeBlockWordWrap.spec.mjs | 246 +++++++++++++++ .../__tests__/utils/index.mjs | 9 + .../browser/CodeBlockCaretScroll.test.ts | 84 ++++- .../browser/CodeBlockWordWrap.test.ts | 296 ++++++++++++++++++ 5 files changed, 750 insertions(+), 78 deletions(-) create mode 100644 packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs create mode 100644 packages/lexical-playground/src/__tests__/browser/CodeBlockWordWrap.test.ts diff --git a/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs index 581e256175e..8a5f8a14363 100644 --- a/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs @@ -45,10 +45,26 @@ async function createCodeBlock(page, lines) { if (lines[i] !== '') { await page.keyboard.insertText(lines[i]); } + if (i === 0) { + // Shiki loads the grammar and the theme asynchronously, and only then + // splits the text into tokens. When that lands after an Enter, the + // caret is put back by child index rather than by text offset, and + // the next line is typed into the first one. So wait for the tokens. + await expect.poll(() => countCodeChildren(page)).toBeGreaterThan(1); + } } await expect.poll(() => getCodeLines(page)).toEqual(lines); } +/** How many nodes the first code block has, from the editor state. */ +async function countCodeChildren(page) { + return await evaluate(page, () => { + const code = window.lexicalEditor.getEditorState().toJSON().root + .children[0]; + return code.type === 'code' ? code.children.length : 0; + }); +} + /** The lines of the first code block, read from the editor state. */ async function getCodeLines(page) { return await evaluate(page, () => { @@ -81,11 +97,18 @@ async function measure(page) { caret.right === 0 ) { // A caret between two elements has no rect of its own. Use the - // element after it. - const child = selection.focusNode.childNodes[selection.focusOffset]; + // element after it, or the
of a line break wrapper, whose own + // rect also covers the number drawn on the next line. + let child = selection.focusNode.childNodes[selection.focusOffset]; if (!child || child.nodeType !== Node.ELEMENT_NODE) { return null; } + if (child.hasAttribute('data-lexical-code-line-break')) { + child = child.firstElementChild; + if (child === null) { + return null; + } + } caret = child.getBoundingClientRect(); } const rect = code.getBoundingClientRect(); @@ -97,9 +120,19 @@ async function measure(page) { return null; } range.setStart(firstText, 0); + const {focusNode} = selection; + const focusElement = + focusNode.nodeType === Node.ELEMENT_NODE + ? focusNode + : focusNode.parentElement; return { caretLeft: caret.left, caretRight: caret.right, + // The caret must never be inside a line break wrapper, where typed + // text would land next to the number. + focusInWrapper: + focusElement !== null && + focusElement.closest('[data-lexical-code-line-break]') !== null, // Where the text of a line starts with the block scrolled all the way // back: just after the gutter, whatever its width. lineStartX: range.getBoundingClientRect().left + code.scrollLeft, @@ -132,74 +165,100 @@ test.skip( ); test.describe('Code block caret scrolling', () => { - test('Home, End and Enter keep the caret visible in long lines', async ({ - page, - isCollab, - browserName, - }) => { - await initialize({isCollab, page}); - await createCodeBlock(page, [LONG_LINE, INDENTED_LINE]); - // The data-gutter float numbers the lines. - await expect(locate(page, CODE)).toHaveAttribute('data-gutter', '1\n2'); - - // The caret is at the end of the indented line, and the block is - // scrolled to show it. Smart Home stops after the indentation, and the - // block scrolls all the way back. - await pollGeometry( - page, - m => m.scrollLeft > 0 && m.caretRight <= m.scrollportRight, - ); - await moveToLineBeginning(page); - await pollGeometry( + for (const [name, settings] of [ + ['data-gutter float with Prism', {}], + [ + 'per line numbers with Shiki', + {isCodeLineNumbers: true, isCodeShiki: true}, + ], + ]) { + test(`Home, End and Enter keep the caret visible in long lines (${name})`, async ({ page, - m => m.scrollLeft === 0 && m.caretLeft > m.lineStartX + 1, - ); + isCollab, + browserName, + }) => { + await initialize({isCollab, page, ...settings}); + await createCodeBlock(page, [LONG_LINE, INDENTED_LINE]); + if (settings.isCodeLineNumbers) { + // Each line has its own number element. + await expect(locate(page, CODE)).toHaveAttribute( + 'data-lexical-code-line-numbers', + 'true', + ); + await expect( + locate(page, `${CODE} > [data-lexical-code-line-break]`), + ).toHaveCount(1); + } else { + // The data-gutter float numbers the lines. + await expect(locate(page, CODE)).toHaveAttribute('data-gutter', '1\n2'); + await expect(locate(page, CODE)).not.toHaveAttribute( + 'data-lexical-code-line-numbers', + ); + } - await moveToLineEnd(page); - await pollGeometry( - page, - m => - m.scrollLeft > 0 && - m.caretLeft >= m.lineStartX - 1 && - m.caretRight <= m.scrollportRight, - ); + // The caret is at the end of the indented line, and the block is + // scrolled to show it. Smart Home stops after the indentation, and the + // block scrolls all the way back. + await pollGeometry( + page, + m => m.scrollLeft > 0 && m.caretRight <= m.scrollportRight, + ); + await moveToLineBeginning(page); + await pollGeometry( + page, + m => m.scrollLeft === 0 && m.caretLeft > m.lineStartX + 1, + ); - // The new line copies the 12 spaces of indentation. - await page.keyboard.type('abc'); - await page.keyboard.press('Enter'); - await pollGeometry( - page, - m => m.scrollLeft === 0 && m.caretLeft > m.lineStartX + 1, - ); + await moveToLineEnd(page); + await pollGeometry( + page, + m => + m.scrollLeft > 0 && + m.caretLeft >= m.lineStartX - 1 && + m.caretRight <= m.scrollportRight, + ); - for (let i = 0; i < 12; i++) { - await page.keyboard.press('Backspace'); - } - await expect - .poll(() => getCodeLines(page)) - .toEqual([LONG_LINE, INDENTED_LINE + 'abc', '']); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); + // The new line copies the 12 spaces of indentation. + await page.keyboard.type('abc'); + await page.keyboard.press('Enter'); + await pollGeometry( + page, + m => + m.scrollLeft === 0 && + m.caretLeft > m.lineStartX + 1 && + !m.focusInWrapper, + ); - // The end of the longest line, with the block scrolled as far as it goes. - // Firefox leaves the theme's inline end padding out of the scroll range - // while a sticky box is in the block, like the data-gutter float. On the - // Windows CI runners it drew the caret here 0.25px past the right edge: - // at x 1143.25, with scrollLeft 3699 and the scrollport ending at 1143. - // So only this step allows 0.5px past the edge, and only in Firefox. - const endSlack = browserName === 'firefox' ? 0.5 : 0; - await moveToLineEnd(page); - await pollGeometry( - page, - m => - m.scrollLeft > 0 && - m.caretLeft >= m.lineStartX - 1 && - m.caretRight <= m.scrollportRight + endSlack, - ); - await moveToLineBeginning(page); - await pollGeometry( - page, - m => m.scrollLeft === 0 && Math.abs(m.caretLeft - m.lineStartX) <= 1, - ); - }); + for (let i = 0; i < 12; i++) { + await page.keyboard.press('Backspace'); + } + await expect + .poll(() => getCodeLines(page)) + .toEqual([LONG_LINE, INDENTED_LINE + 'abc', '']); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + + // The end of the longest line, with the block scrolled as far as it + // goes. Firefox leaves the theme's inline end padding out of the scroll + // range while a sticky box is in the block, like the data-gutter float. + // On the Windows CI runners it drew the caret here 0.25px past the + // right edge: at x 1143.25, with scrollLeft 3699 and the scrollport + // ending at 1143. So only this step allows 0.5px past the edge, and + // only in Firefox. + const endSlack = browserName === 'firefox' ? 0.5 : 0; + await moveToLineEnd(page); + await pollGeometry( + page, + m => + m.scrollLeft > 0 && + m.caretLeft >= m.lineStartX - 1 && + m.caretRight <= m.scrollportRight + endSlack, + ); + await moveToLineBeginning(page); + await pollGeometry( + page, + m => m.scrollLeft === 0 && Math.abs(m.caretLeft - m.lineStartX) <= 1, + ); + }); + } }); diff --git a/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs new file mode 100644 index 00000000000..f95030f8e0c --- /dev/null +++ b/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs @@ -0,0 +1,246 @@ +/** + * 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 {selectAll} from '../keyboardShortcuts/index.mjs'; +import { + click, + copyToClipboard, + evaluate, + expect, + focusEditor, + initialize, + locate, + mouseMoveToSelector, + test, + waitForSelector, +} from '../utils/index.mjs'; + +const CODE = 'code.PlaygroundEditorTheme__code'; +const WRAP_BUTTON = 'button[aria-label="wrap"]'; + +const LONG_LINE = Array.from( + {length: 40}, + (_, i) => `${i}`, +) + .join('') + .slice(0, 655); +const LONG_TOKEN = 'https://example.com/' + 'a'.repeat(380); + +// Makes the line numbers the only hit targets inside a code block, so +// elementFromPoint tells which number is drawn next to a row. +const NUMBER_HIT_TEST_CSS = [ + '.PlaygroundEditorTheme__code, .PlaygroundEditorTheme__code * { pointer-events: none !important; }', + '.PlaygroundEditorTheme__code::before, .PlaygroundEditorTheme__code [data-lexical-code-line-break]::after { pointer-events: auto !important; }', +].join('\n'); + +/** + * Types an html code block with these lines. The text is inserted rather + * than typed key by key, which would be slow for lines this long. + */ +async function createCodeBlock(page, lines) { + await focusEditor(page); + await page.keyboard.type('```html '); + for (let i = 0; i < lines.length; i++) { + if (i > 0) { + await page.keyboard.press('Enter'); + } + if (lines[i] !== '') { + await page.keyboard.insertText(lines[i]); + } + if (i === 0) { + // Shiki loads the grammar and the theme asynchronously, and only then + // splits the text into tokens. When that lands after an Enter, the + // caret is put back by child index rather than by text offset, and + // the next line is typed into the first one. So wait for the tokens. + await expect.poll(() => countCodeChildren(page)).toBeGreaterThan(1); + } + } + await expect.poll(() => getCodeLines(page)).toEqual(lines); +} + +/** How many nodes the first code block has, from the editor state. */ +async function countCodeChildren(page) { + return await evaluate(page, () => { + const code = window.lexicalEditor.getEditorState().toJSON().root + .children[0]; + return code.type === 'code' ? code.children.length : 0; + }); +} + +/** The lines of the first code block, read from the editor state. */ +async function getCodeLines(page) { + return await evaluate(page, () => { + const code = window.lexicalEditor.getEditorState().toJSON().root + .children[0]; + if (code.type !== 'code') { + return null; + } + return code.children + .map(child => (child.type === 'linebreak' ? '\n' : child.text)) + .join('') + .split('\n'); + }); +} + +async function getWordWrapJSON(page) { + return await evaluate( + page, + () => + window.lexicalEditor.getEditorState().toJSON().root.children[0].wordWrap, + ); +} + +/** + * For each line after the first: which line break wrapper's number is drawn + * at the start of the line's first row, as an index into the wrappers. + */ +async function getFirstRowNumbers(page) { + return await evaluate( + page, + ([selector, css]) => { + const code = document.querySelector(selector); + if (code === null) { + return null; + } + const wrappers = Array.from( + code.querySelectorAll(':scope > [data-lexical-code-line-break]'), + ); + const style = document.createElement('style'); + style.textContent = css; + document.head.appendChild(style); + try { + // The middle of the 42px number box. + const x = code.getBoundingClientRect().left + 21; + return wrappers.map((wrapper, i) => { + const next = wrappers[i + 1]; + const range = document.createRange(); + range.setStartAfter(wrapper); + if (next) { + range.setEndBefore(next); + } else { + range.setEnd(code, code.childNodes.length); + } + const rects = Array.from(range.getClientRects()).filter( + rect => rect.height > 0, + ); + // An empty line has only the
that ends it. + const row = + rects.length > 0 + ? rects.reduce((a, b) => (b.top < a.top ? b : a)) + : next.firstElementChild.getBoundingClientRect(); + const hit = document.elementFromPoint(x, (row.top + row.bottom) / 2); + return wrappers.indexOf(hit); + }); + } finally { + document.head.removeChild(style); + } + }, + [CODE, NUMBER_HIT_TEST_CSS], + ); +} + +/** How far the code block can scroll sideways. */ +async function getOverflowWidth(page) { + return await evaluate( + page, + selector => { + const code = document.querySelector(selector); + return code === null ? null : code.scrollWidth - code.clientWidth; + }, + CODE, + ); +} + +async function countLineBreaks(page) { + return await evaluate( + page, + selector => + document.querySelectorAll( + `${selector} br:not([data-lexical-managed-linebreak])`, + ).length, + CODE, + ); +} + +async function copyAll(page) { + await focusEditor(page); + await selectAll(page); + return await copyToClipboard(page); +} + +async function toggleWrap(page) { + await mouseMoveToSelector(page, CODE); + await click(page, WRAP_BUTTON); +} + +test.skip( + ({isCollab, isPlainText}) => isPlainText || isCollab, + 'Requires rich text without collaboration', +); + +test.describe('Code block word wrap', () => { + for (const [name, settings] of [ + ['Prism', {}], + ['Shiki', {isCodeShiki: true}], + ]) { + test(`The wrap button soft wraps a code block and numbers its lines (${name})`, async ({ + page, + isCollab, + }) => { + await initialize({isCollab, page, ...settings}); + await createCodeBlock(page, [LONG_LINE, '', LONG_TOKEN, 'end']); + // Add a paragraph after the block. A copy of nothing but the block's + // text exports its lines without the
.
+      await page.keyboard.press('ArrowDown');
+      await page.keyboard.type('after');
+      const before = await copyAll(page);
+      expect(before['text/html']).toContain(' getOverflowWidth(page)).toBeGreaterThan(0);
+
+      await toggleWrap(page);
+      await expect(locate(page, WRAP_BUTTON)).toHaveAttribute(
+        'aria-pressed',
+        'true',
+      );
+      await waitForSelector(page, 'code[data-lexical-code-word-wrap]');
+      await expect(locate(page, CODE)).toHaveAttribute(
+        'data-lexical-code-line-numbers',
+        'true',
+      );
+      await expect.poll(() => getOverflowWidth(page)).toBeLessThanOrEqual(1);
+      expect(await getWordWrapJSON(page)).toBe(true);
+      await expect.poll(() => getFirstRowNumbers(page)).toEqual([0, 1, 2]);
+
+      const after = await copyAll(page);
+      expect(after['text/plain']).toBe(before['text/plain']);
+      expect(after['text/html']).toContain(
+        'data-lexical-code-word-wrap="true"',
+      );
+      expect(await countLineBreaks(page)).toBe(3);
+
+      await toggleWrap(page);
+      await expect(locate(page, WRAP_BUTTON)).toHaveAttribute(
+        'aria-pressed',
+        'false',
+      );
+      await expect(locate(page, CODE)).not.toHaveAttribute(
+        'data-lexical-code-word-wrap',
+      );
+      await expect(locate(page, CODE)).not.toHaveAttribute(
+        'data-lexical-code-line-numbers',
+      );
+      expect(await getWordWrapJSON(page)).toBeUndefined();
+      await expect.poll(() => getOverflowWidth(page)).toBeGreaterThan(0);
+    });
+  }
+});
diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs
index d98728ab8c1..668dd898e08 100644
--- a/packages/lexical-playground/__tests__/utils/index.mjs
+++ b/packages/lexical-playground/__tests__/utils/index.mjs
@@ -106,6 +106,8 @@ async function initializePlayground({
   shouldAllowHighlightingWithBrackets,
   selectionAlwaysOnDisplay,
   isShadowDOM,
+  isCodeLineNumbers,
+  isCodeShiki,
 }) {
   const appSettings = {};
   appSettings.isRichText = IS_RICH_TEXT;
@@ -151,6 +153,13 @@ async function initializePlayground({
 
   appSettings.isShadowDOM = !!isShadowDOM;
 
+  if (isCodeLineNumbers !== undefined) {
+    appSettings.isCodeLineNumbers = isCodeLineNumbers;
+  }
+  if (isCodeShiki !== undefined) {
+    appSettings.isCodeShiki = isCodeShiki;
+  }
+
   const urlParams = appSettingsToURLParams(appSettings);
   const url = `http://localhost:${E2E_PORT}/${
     isCollab ? 'split/' : ''
diff --git a/packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts b/packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts
index 75bb4387683..f272d95296f 100644
--- a/packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts
+++ b/packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts
@@ -10,6 +10,7 @@ import {
   $createCodeHighlightNode,
   $createCodeNode,
   $isCodeNode,
+  CodeLineNumbersExtension,
 } from '@lexical/code-core';
 import {CodePrismExtension} from '@lexical/code-prism';
 import {buildEditorFromExtensions} from '@lexical/extension';
@@ -21,6 +22,7 @@ import {
   $isRangeSelection,
   $isTextNode,
   type AnyLexicalExtensionArgument,
+  configExtension,
   defineExtension,
   isDOMTextNode,
   isHTMLElement,
@@ -71,6 +73,7 @@ const LONG_LINE = Array.from(
   .slice(0, 655);
 const INDENTED_LINE = ' '.repeat(12) + LONG_LINE.slice(0, 400);
 const RTL_LINE = 'שלום '.repeat(130);
+const SHORT_RTL_LINE = 'שלום '.repeat(60);
 
 /** A way for the theme to draw line numbers next to a code block. */
 interface Gutter {
@@ -82,10 +85,15 @@ interface Gutter {
 }
 
 const DATA_GUTTER_FLOAT: Gutter = {
-  dependencies: [],
+  // The playground's default: line numbers only on blocks with word wrap on,
+  // so this block keeps the float.
+  dependencies: [
+    configExtension(CodeLineNumbersExtension, {onlyWordWrapped: true}),
+  ],
   // The highlighter lists the line numbers in data-gutter, and the theme
   // draws them with the code element's ::before, floated left and sticky.
   expectGutter(code, lineCount) {
+    expect(code.hasAttribute('data-lexical-code-line-numbers')).toBe(false);
     expect(code.getAttribute('data-gutter')).toBe(
       Array.from({length: lineCount}, (_, i) => i + 1).join('\n'),
     );
@@ -94,8 +102,27 @@ const DATA_GUTTER_FLOAT: Gutter = {
   name: 'the data-gutter float',
 };
 
+const PER_LINE_NUMBERS: Gutter = {
+  dependencies: [
+    configExtension(CodeLineNumbersExtension, {onlyWordWrapped: false}),
+  ],
+  // CodeLineNumbersExtension wraps the 
of every line break in a span. + // The theme numbers line 1 with the code element's ::before and every + // other line with the ::after of its wrapper, sticky at the inline start. + expectGutter(code, lineCount) { + expect(code.getAttribute('data-lexical-code-line-numbers')).toBe('true'); + expect( + code.querySelectorAll(':scope > [data-lexical-code-line-break]'), + ).toHaveLength(lineCount - 1); + const number = getComputedStyle(code, '::before'); + expect(number.float).toBe('none'); + expect(number.position).toBe('sticky'); + }, + name: 'per line numbers', +}; + // Every scroll case below runs once for each of these gutters. -const GUTTERS: Gutter[] = [DATA_GUTTER_FLOAT]; +const GUTTERS: Gutter[] = [DATA_GUTTER_FLOAT, PER_LINE_NUMBERS]; // Makes the pseudo elements the theme draws line numbers with the only hit // targets inside a code block, so elementFromPoint tells whether a number is @@ -244,10 +271,12 @@ function isEmptyRect(rect: DOMRect): boolean { /** * The rect the caret is drawn at. A collapsed range between two elements - * has no rect, so that falls back to the element after the caret. WebKit - * gives no rect for a caret at the logical end of right to left text - * either, so that falls back to the character before the caret. The caret - * is at one of its edges. + * has no rect, so that falls back to the element after the caret. For a + * line break wrapper that is its
, because the wrapper's own rect also + * covers the number drawn on the next line. WebKit gives no rect for a + * caret at the logical end of right to left text either, so that falls + * back to the character before the caret. The caret is at one of its + * edges. */ function caretRect(): DOMRect { const selection = window.getSelection()!; @@ -263,7 +292,13 @@ function caretRect(): DOMRect { range.setEnd(focusNode, focusOffset); return range.getBoundingClientRect(); } - const child: Node | null = focusNode!.childNodes[focusOffset] || null; + let child: Node | null = focusNode!.childNodes[focusOffset] || null; + if ( + isHTMLElement(child) && + child.hasAttribute('data-lexical-code-line-break') + ) { + child = child.firstElementChild; + } if (!isHTMLElement(child)) { throw new Error('Expected an element after the caret'); } @@ -460,10 +495,11 @@ describe.each(GUTTERS)('caret scrolling in a code block with $name', gutter => { await selectInLine(editor, 0, 'start'); expect(code.scrollLeft).toBe(0); - // The gutter isn't always on the same side of the view here. The float - // is on the left, but as a sticky box it can't leave the code element's - // content box, so it scrolls out of view when the block scrolls toward - // the end of a line. WebKit draws it about 10px from the right edge + // The gutter isn't always on the same side of the view here. Per line + // numbers are at the inline start, on the right. The float is on the + // left, but as a sticky box it can't leave the code element's content + // box, so it scrolls out of view when the block scrolls toward the end + // of a line. WebKit draws the float about 10px from the right edge // instead, over the first characters of the line, and hit tests it there // too. So the checks look for a number drawn at the caret instead of at // a fixed side. @@ -488,6 +524,32 @@ describe.each(GUTTERS)('caret scrolling in a code block with $name', gutter => { expect(code.scrollLeft).toBe(0); expectCaretVisible(code); }); + + test('moving back to the end of a shorter right to left line stops the caret clear of the gutter', async () => { + const {code, editor, rootElement} = setUpEditor(gutter, [ + RTL_LINE, + SHORT_RTL_LINE, + ]); + expect(getComputedStyle(code).direction).toBe('rtl'); + await focus(rootElement); + await selectInLine(editor, 0, 'start'); + // The logical end of the long line, on the left. + await moveTo(editor, MOVE_TO_START); + const endScrollLeft = code.scrollLeft; + expect(endScrollLeft).toBeLessThan(0); + + // The end of the shorter line is right of the view, but too far along + // the line to fit with the block scrolled all the way back. So the block + // only scrolls back part of the way, and the caret stops next to the + // side of the view that per line numbers are drawn on. The float has + // scrolled out of view by then in all three browsers, so for the float + // this only checks that the caret is in view. + await selectInLine(editor, 1, 'end'); + const m = measure(code); + expect(m.scrollLeft, JSON.stringify(m)).toBeLessThan(0); + expect(m.scrollLeft, JSON.stringify(m)).toBeGreaterThan(endScrollLeft); + expectCaretVisible(code); + }); }); describe.each([ diff --git a/packages/lexical-playground/src/__tests__/browser/CodeBlockWordWrap.test.ts b/packages/lexical-playground/src/__tests__/browser/CodeBlockWordWrap.test.ts new file mode 100644 index 00000000000..9d5fa88f478 --- /dev/null +++ b/packages/lexical-playground/src/__tests__/browser/CodeBlockWordWrap.test.ts @@ -0,0 +1,296 @@ +/** + * 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, + CodeLineNumbersExtension, +} from '@lexical/code-core'; +import {CodePrismExtension} from '@lexical/code-prism'; +import {buildEditorFromExtensions} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createLineBreakNode, + $getRoot, + configExtension, + defineExtension, + type LexicalEditor, +} from 'lexical'; +import {describe, expect, onTestFinished, test} from 'vitest'; + +import theme from '../../themes/PlaygroundEditorTheme'; + +// A code block with word wrap on soft wraps its long lines, and the +// playground theme numbers it per line, so a number is drawn on the first +// row of each line and not on the rows it wraps onto. Where the rows and +// the numbers land depends on real layout, which jsdom doesn't have, so +// these tests run in a real browser against the theme's own CSS. See the +// `browser` project in vitest.config.mts. +// +// Note: browser tests dispose with onTestFinished instead of `using`. See +// AGENTS.md. + +const CONTAINER_WIDTH = 360; + +const LONG_LINE = Array.from( + {length: 40}, + (_, i) => `${i}`, +) + .join('') + .slice(0, 655); +// One token with no break opportunity, which wraps only because of +// overflow-wrap: anywhere. +const LONG_TOKEN = 'https://example.com/' + 'a'.repeat(380); +const LINES = [LONG_LINE, '', LONG_TOKEN, 'end']; + +// Makes the line numbers the only hit targets inside a code block, so +// elementFromPoint tells whether a number is drawn at a point, and which. +const NUMBER_HIT_TEST_CSS = [ + '.PlaygroundEditorTheme__code, .PlaygroundEditorTheme__code * { pointer-events: none !important; }', + '.PlaygroundEditorTheme__code::before, .PlaygroundEditorTheme__code [data-lexical-code-line-break]::after { pointer-events: auto !important; }', +].join('\n'); + +interface Mode { + name: string; + /** How the playground configures CodeLineNumbersExtension. */ + onlyWordWrapped: boolean; +} + +const MODES: Mode[] = [ + // The playground's default: only wrapped blocks get per line numbers, and + // the other blocks keep the data-gutter float. + {name: 'line numbers only while wrapped', onlyWordWrapped: true}, + // The playground's line numbers setting: every block is numbered per line. + {name: 'line numbers on every block', onlyWordWrapped: false}, +]; + +function $createCodeBlock(lines: string[]) { + const code = $createCodeNode('html'); + lines.forEach((line, i) => { + if (i > 0) { + code.append($createLineBreakNode()); + } + if (line !== '') { + code.append($createCodeHighlightNode(line)); + } + }); + return code; +} + +function setUpEditor(mode: Mode) { + const container = document.createElement('div'); + container.style.width = `${CONTAINER_WIDTH}px`; + document.body.appendChild(container); + const editor = buildEditorFromExtensions( + defineExtension({ + afterRegistration(builtEditor) { + const rootElement = document.createElement('div'); + rootElement.contentEditable = 'true'; + container.appendChild(rootElement); + builtEditor.setRootElement(rootElement); + return () => { + document.body.removeChild(container); + }; + }, + dependencies: [ + RichTextExtension, + CodePrismExtension, + configExtension(CodeLineNumbersExtension, { + onlyWordWrapped: mode.onlyWordWrapped, + }), + ], + name: '[code-word-wrap]', + onError: (error: Error) => { + throw error; + }, + theme, + }), + ); + onTestFinished(() => editor.dispose()); + // Build the block in an update rather than as the initial state, so Prism + // tokenizes it as it would in the app. + editor.update( + () => { + $getRoot().clear().append($createCodeBlock(LINES)); + }, + {discrete: true}, + ); + const code = getCode(editor); + expect(code.hasAttribute('data-lexical-code-word-wrap')).toBe(false); + expect(code.hasAttribute('data-lexical-code-line-numbers')).toBe( + !mode.onlyWordWrapped, + ); + expect(code.scrollWidth).toBeGreaterThan(code.clientWidth); + return editor; +} + +function getCode(editor: LexicalEditor): HTMLElement { + const code = editor.getRootElement()!.querySelector('code'); + expect(code).not.toBeNull(); + return code!; +} + +/** + * Turns word wrap on or off and returns the code element. With + * onlyWordWrapped, a toggle replaces the code element. + */ +function setWordWrap(editor: LexicalEditor, wordWrap: boolean): HTMLElement { + editor.update( + () => { + const node = $getRoot().getFirstChildOrThrow(); + if (!$isCodeNode(node)) { + throw new Error('Expected a code block'); + } + node.setWordWrap(wordWrap); + }, + {discrete: true}, + ); + return getCode(editor); +} + +/** What elementFromPoint hits when only the line numbers are hit targets. */ +function hitTestNumbers(x: number, y: number): Element | null { + const style = document.createElement('style'); + style.textContent = NUMBER_HIT_TEST_CSS; + document.head.appendChild(style); + try { + return document.elementFromPoint(x, y); + } finally { + document.head.removeChild(style); + } +} + +function isNumber(code: HTMLElement, element: Element | null): boolean { + return ( + element === code || + (element !== null && element.hasAttribute('data-lexical-code-line-break')) + ); +} + +interface Row { + bottom: number; + left: number; + top: number; +} + +/** The rows a range is laid out on, top to bottom. */ +function rowsOf(range: Range): Row[] { + const rows: Row[] = []; + for (const rect of range.getClientRects()) { + if (rect.height <= 0) { + continue; + } + const row = rows.find(({top}) => Math.abs(top - rect.top) <= 2); + if (row) { + row.bottom = Math.max(row.bottom, rect.bottom); + row.left = Math.min(row.left, rect.left); + } else { + rows.push({bottom: rect.bottom, left: rect.left, top: rect.top}); + } + } + return rows.sort((a, b) => a.top - b.top); +} + +/** The rows of each line, found between the line break wrappers. */ +function rowsOfLines(code: HTMLElement, wrappers: Element[]): Row[][] { + const lineRows: Row[][] = []; + for (let i = 0; i <= wrappers.length; i++) { + const range = document.createRange(); + if (i === 0) { + range.setStart(code, 0); + } else { + range.setStartAfter(wrappers[i - 1]); + } + if (i === wrappers.length) { + range.setEnd(code, code.childNodes.length); + } else { + range.setEndBefore(wrappers[i]); + } + let rows = rowsOf(range); + if (rows.length === 0) { + // An empty line. The
that ends it is all that is on it. + const {bottom, left, top} = + wrappers[i].firstElementChild!.getBoundingClientRect(); + rows = [{bottom, left, top}]; + } + lineRows.push(rows); + } + return lineRows; +} + +describe.each(MODES)('word wrap in a code block with $name', mode => { + test('wraps long lines and numbers the first row of every line', () => { + const editor = setUpEditor(mode); + + const code = setWordWrap(editor, true); + expect(code.getAttribute('data-lexical-code-word-wrap')).toBe('true'); + expect(code.getAttribute('data-lexical-code-line-numbers')).toBe('true'); + expect(code.scrollWidth).toBeLessThanOrEqual(code.clientWidth + 1); + const wrappers = Array.from( + code.querySelectorAll(':scope > [data-lexical-code-line-break]'), + ); + expect(wrappers).toHaveLength(LINES.length - 1); + + const lineRows = rowsOfLines(code, wrappers); + // The long line and the long token wrap. The empty line and 'end' don't. + expect(lineRows.map(rows => rows.length > 1)).toEqual([ + true, + false, + true, + false, + ]); + + // x in the middle of the 42px number box. + const numberX = code.getBoundingClientRect().left + 21; + lineRows.forEach((rows, i) => { + const number = i === 0 ? code : wrappers[i - 1]; + rows.forEach(({bottom, left, top}, row) => { + const where = `line ${i + 1}, row ${row + 1}`; + const middle = (top + bottom) / 2; + const hit = hitTestNumbers(numberX, middle); + if (row === 0) { + expect(hit, where).toBe(number); + } else { + expect(isNumber(code, hit), where).toBe(false); + } + // No number is drawn over the text of any row. + expect(isNumber(code, hitTestNumbers(left + 1, middle)), where).toBe( + false, + ); + }); + }); + }); + + test('unwrapping scrolls long lines again and brings back the gutter', () => { + const editor = setUpEditor(mode); + setWordWrap(editor, true); + + const code = setWordWrap(editor, false); + expect(code.hasAttribute('data-lexical-code-word-wrap')).toBe(false); + expect(code.scrollWidth).toBeGreaterThan(code.clientWidth); + expect(code.getAttribute('data-gutter')).toBe('1\n2\n3\n4'); + if (mode.onlyWordWrapped) { + // The data-gutter float numbers it again. + expect(code.hasAttribute('data-lexical-code-line-numbers')).toBe(false); + expect( + code.querySelectorAll('[data-lexical-code-line-break]'), + ).toHaveLength(0); + const float = getComputedStyle(code, '::before'); + expect(float.content).not.toBe('none'); + expect(float.float).toBe('left'); + } else { + // Still numbered per line, with sticky numbers in the line. + expect(code.getAttribute('data-lexical-code-line-numbers')).toBe('true'); + expect( + code.querySelectorAll(':scope > [data-lexical-code-line-break]'), + ).toHaveLength(LINES.length - 1); + expect(getComputedStyle(code, '::before').position).toBe('sticky'); + } + }); +}); From b7dadbb3e3780e37b54b79e18a3da0653d9f4d42 Mon Sep 17 00:00:00 2001 From: Om Singhal Date: Thu, 24 Sep 2026 14:00:48 -0400 Subject: [PATCH 05/31] [lexical-playground] Chore: browser test for insertLineBreak(true) at the end of a long code line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description With per line numbers, CodeLineNumbersExtension wraps each
in a span whose ::after draws the next line's number at the start of that line. insertLineBreak(true), the macOS Ctrl+O, leaves the caret just before the new line break, as an element point. Lexical used to measure the whole span there to scroll the caret into view, and the span's rect reaches back to the start of the next line. So at the end of a long line in an unwrapped block, the block scrolled all the way back and left the caret far out of view. The merged core fix measures the
inside the span instead, through $getDOMSlot. CodeBlockCaretScroll.test.ts now has a case for this, run with both gutters: put the caret at the end of a 200 character line, call insertLineBreak(true), and check that the caret is still just before the new line break, that the block is still scrolled, and that the caret is visible and clear of the gutter. ## Test plan ### Before The new test with packages/lexical/src/LexicalSelection.ts and packages/lexical/src/LexicalUtils.ts from 416e87c6, the head of fix/code-block-caret-scroll before the fix: VITEST_BROWSER=chromium pnpm vitest run --project browser packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts -t insertLineBreak × caret scrolling in a code block with per line numbers > insertLineBreak(true) at the end of a long line keeps the caret in view AssertionError: {"caretBottom":33,"caretLeft":1617.59375,"caretRight":1617.59375,"caretTop":18,"lineStartX":52,"scrollLeft":0,"scrollportLeft":0,"scrollportRight":360}: expected 0 to be greater than 0 Tests 1 failed | 1 passed | 17 skipped (19) VITEST_BROWSER=webkit, the same command: AssertionError: {"caretBottom":33.50390625,"caretLeft":1617.33203125,"caretRight":1617.33203125,"caretTop":18.37109375,"lineStartX":52,"scrollLeft":0,"scrollportLeft":0,"scrollportRight":360}: expected 0 to be greater than 0 Tests 1 failed | 1 passed | 17 skipped (19) In Firefox both cases pass even then. The case with the data-gutter float, where the
has no wrapper, passes in all three. ### After VITEST_BROWSER=chromium pnpm vitest run --project browser --reporter=verbose packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts -t insertLineBreak ✓ caret scrolling in a code block with the data-gutter float > insertLineBreak(true) at the end of a long line keeps the caret in view ✓ caret scrolling in a code block with per line numbers > insertLineBreak(true) at the end of a long line keeps the caret in view Tests 2 passed | 17 skipped (19) The same with firefox and webkit. VITEST_BROWSER=chromium pnpm vitest run --project browser packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts packages/lexical-playground/src/__tests__/browser/CodeBlockWordWrap.test.ts Test Files 2 passed (2) Tests 23 passed (23) The same with firefox and webkit. All of these ran headless on macOS. prettier and eslint pass on the changed file, and pnpm run tsc passes. --- .../browser/CodeBlockCaretScroll.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts b/packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts index f272d95296f..184a9d93900 100644 --- a/packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts +++ b/packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts @@ -19,6 +19,8 @@ import { $createLineBreakNode, $getRoot, $getSelection, + $isElementNode, + $isLineBreakNode, $isRangeSelection, $isTextNode, type AnyLexicalExtensionArgument, @@ -72,6 +74,7 @@ const LONG_LINE = Array.from( .join('') .slice(0, 655); const INDENTED_LINE = ' '.repeat(12) + LONG_LINE.slice(0, 400); +const MEDIUM_LINE = LONG_LINE.slice(0, 200); const RTL_LINE = 'שלום '.repeat(130); const SHORT_RTL_LINE = 'שלום '.repeat(60); @@ -487,6 +490,49 @@ describe.each(GUTTERS)('caret scrolling in a code block with $name', gutter => { expectCaretVisible(code); }); + test('insertLineBreak(true) at the end of a long line keeps the caret in view', async () => { + const {code, editor, rootElement} = setUpEditor(gutter, [ + MEDIUM_LINE, + 'abc', + ]); + await focus(rootElement); + await selectInLine(editor, 0, 'end'); + expect(code.scrollLeft).toBeGreaterThan(0); + + // Ctrl+O on macOS: a line break goes in after the caret, and the caret + // stays at the end of the line, just before it. With per line numbers + // the new
is wrapped in a span that draws the next line's number + // at the start of that line. + editor.update( + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error('Expected a range selection'); + } + selection.insertLineBreak(true); + }, + {discrete: true}, + ); + await settle(); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return null; + } + const {anchor} = selection; + const node = anchor.getNode(); + return $isElementNode(node) && + $isLineBreakNode(node.getChildAtIndex(anchor.offset)) + ? 'before the new line break' + : `${anchor.type} point`; + }), + ).toBe('before the new line break'); + const m = measure(code); + expect(m.scrollLeft, JSON.stringify(m)).toBeGreaterThan(0); + expectCaretVisible(code); + }); + test('right to left lines scroll the other way and keep the caret visible', async () => { const {code, editor, rootElement} = setUpEditor(gutter, [RTL_LINE]); expect(getComputedStyle(code).direction).toBe('rtl'); From a1671dbc12a606248dce94e6b6a305e8436ad049 Mon Sep 17 00:00:00 2001 From: Om Singhal Date: Sun, 27 Sep 2026 20:01:04 -0400 Subject: [PATCH 06/31] [lexical-code-core][lexical-playground] Bug Fix: keep line break wrappers out of exported HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description main's #9231 routes exportDOM through the render overrides. The default LexicalNode.exportDOM now builds its element with the editor's $createDOM hook, and LineBreakNode has no exportDOM of its own. So the $createDOM override in CodeLineNumbersExtension ran on export too. Exported HTML and the clipboard got
for every line break in a numbered code block, mounted or headless. A trailing line break also lost its managed
, because the exporter only adds one when the last child is a
. So the LineBreakNode $createDOM override now returns the plain
while RenderContextExport is set, which is how the DOM render docs say to tell an export from the editor render. The check sits in the override that adds the wrapper, so it's on exactly when the wrapper is, in both modes. An $exportDOM override that unwraps the result would also hide the wrapper, but it would drop whatever a LineBreakNode or wildcard $createDOM override composed after this one did to it. With the check, those overrides get the
itself, so their changes reach the export. CodeNode's own exportDOM builds its
 directly, so it never had the problem and isn't changed. The 
 still gets data-lexical-code-word-wrap.

The doc comment on CodeLineNumbersExtension and the README said export doesn't use the render pipeline. They now say what actually happens.

In CodeLineNumbers.test.ts, an HTML export block replaces the test "never reaches exported HTML, mounted or not". For both modes, with word wrap off and on, it checks that $generateHtmlFromNodes and a copy of the whole document give exactly the HTML of an editor without the extension, mounted and headless. Two more tests add a LineBreakNode override and a wildcard override after the extension and check that the export and the copy still match an editor that has the same override but no extension. Another test pastes a copied block into a new editor and checks that it keeps all its lines and word wrap. CodeBlockWordWrap.spec.mjs now checks that the copy made with wrap on has no line number markup and matches the copy made before it, apart from the word wrap attribute.

## Test plan

### Before

The new tests with CodeLineNumbers.ts from the parent commit:

pnpm vitest run --project unit packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts

 FAIL  |unit| packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts > CodeLineNumbersExtension > HTML export > numbering every code block > exports and copies the same HTML as without the extension, mounted or not (word wrap false)
AssertionError: expected '
a

b

z

" Tests 5 failed | 37 passed (42) The five that fail are the three cases where the block is numbered and the two with a later override. On the parent commit itself, the old export test fails the same way: × never reaches exported HTML, mounted or not 7ms AssertionError: expected '
 CodeLineNumbersExtension > HTML export > export keeps what a later LineBreakNode override does to the 
Expected: "
a

b

z

" Received: "
a

b

z

" Tests 2 failed | 40 passed (42) ### After pnpm vitest run --project unit packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts Test Files 1 passed (1) Tests 42 passed (42) E2E_BROWSER=chromium pnpm exec playwright test --project=chromium packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs 2 passed The same with firefox and webkit. pnpm run tsc and pnpm run flow pass, and prettier and eslint are clean on the changed files. All of this ran headless on macOS. --- packages/lexical-code-core/README.md | 10 +- .../lexical-code-core/src/CodeLineNumbers.ts | 20 +- .../__tests__/unit/CodeLineNumbers.test.ts | 182 ++++++++++++++++-- .../__tests__/e2e/CodeBlockWordWrap.spec.mjs | 7 + 4 files changed, 197 insertions(+), 22 deletions(-) diff --git a/packages/lexical-code-core/README.md b/packages/lexical-code-core/README.md index a2bdbe6b00f..1b7cd149b0e 100644 --- a/packages/lexical-code-core/README.md +++ b/packages/lexical-code-core/README.md @@ -33,10 +33,12 @@ code[data-lexical-code-line-numbers] [data-lexical-code-line-break]::after { } ``` -The numbers are generated content, so they never end up in the editor's text, the clipboard or exported HTML, which -still gets a plain `
`. The highlighters keep writing `data-gutter` while the extension is enabled, so hide an -existing `attr(data-gutter)` gutter under `[data-lexical-code-line-numbers]`. The playground theme -(`PlaygroundEditorTheme.css`) has a complete example. +The numbers are generated content, so they never end up in the editor's text, the clipboard or exported HTML. Neither do +the wrappers: the default `exportDOM` of a `LineBreakNode` goes through the same `$createDOM` overrides, so the +extension leaves the `
` unwrapped while `RenderContextExport` is set. Exported and copied HTML is the same as without +the extension, including what other `$createDOM` overrides do to the `
`. The highlighters keep writing `data-gutter` +while the extension is enabled, so hide an existing `attr(data-gutter)` gutter under `[data-lexical-code-line-numbers]`. +The playground theme (`PlaygroundEditorTheme.css`) has a complete example. WebKit does not draw line 1's number, the code element's `::before`, while a right to left block is scrolled sideways. The numbers drawn by the wrappers stay. The playground theme has this limitation too. diff --git a/packages/lexical-code-core/src/CodeLineNumbers.ts b/packages/lexical-code-core/src/CodeLineNumbers.ts index f43c7f36818..f50a1bfbd46 100644 --- a/packages/lexical-code-core/src/CodeLineNumbers.ts +++ b/packages/lexical-code-core/src/CodeLineNumbers.ts @@ -8,11 +8,13 @@ import {effect, namedSignals} from '@lexical/extension'; import { + $getRenderContextValue, $setRenderContextValue, createRenderState, domOverride, type DOMOverrideOptions, DOMRenderExtension, + RenderContextExport, } from '@lexical/html'; import { $getDocument, @@ -83,7 +85,14 @@ function lineNumberOverrides(mode: 'all' | 'wordWrapped') { { $createDOM: (node, $next) => { const dom = $next(); - if (!$hasLineNumbers(node.getParent(), mode)) { + if ( + // The default exportDOM builds its element with $createDOM, so + // HTML export and the clipboard come through here too. They get + // the plain
, and overrides composed after this one change + // that
rather than the wrapper. + $getRenderContextValue(RenderContextExport) || + !$hasLineNumbers(node.getParent(), mode) + ) { return dom; } const wrapper = $getDocument().createElement('span'); @@ -171,9 +180,12 @@ function allLineNumberOverrides() { * * The wrapper is the keyed DOM of the `LineBreakNode` and the inner `
` * is exposed through `$getDOMSlot`, so a DOM selection inside the wrapper - * resolves to a point just before or just after the `LineBreakNode`. HTML - * export and the clipboard don't use this render pipeline, so they still - * get a plain `
`. + * resolves to a point just before or just after the `LineBreakNode`. The + * default `exportDOM` of a `LineBreakNode` builds its element with the same + * `$createDOM` overrides, so the override skips the wrapper while + * `RenderContextExport` is set. HTML export and the clipboard get the same + * HTML as without the extension, including what other `$createDOM` + * overrides do to the `
`. * * When `disabled`, the overrides are left out of the render pipeline and * code blocks render exactly as they do without the extension. Toggling it diff --git a/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts index 0e1fbd8e5a3..4adba7d179e 100644 --- a/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts +++ b/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts @@ -6,7 +6,10 @@ * */ -import {$insertDataTransferForRichText} from '@lexical/clipboard'; +import { + $getClipboardDataFromSelection, + $insertDataTransferForRichText, +} from '@lexical/clipboard'; import { $createCodeHighlightNode, $createCodeNode, @@ -27,7 +30,12 @@ import { type LexicalEditorWithDispose, } from '@lexical/extension'; import {HistoryExtension} from '@lexical/history'; -import {$generateHtmlFromNodes} from '@lexical/html'; +import { + $generateHtmlFromNodes, + domOverride, + DOMRenderExtension, + type DOMRenderMatchConfig, +} from '@lexical/html'; import {RichTextExtension} from '@lexical/rich-text'; import {$setBlocksType} from '@lexical/selection'; import { @@ -53,6 +61,8 @@ import { INSERT_PARAGRAPH_COMMAND, isHTMLElement, type LexicalEditor, + type LexicalNode, + LineBreakNode, type NodeKey, type PointType, REDO_COMMAND, @@ -410,23 +420,167 @@ describe('CodeLineNumbersExtension', () => { ); expectWrappedLineBreaks(editor, 4); }); + }); - test('never reaches exported HTML, mounted or not', () => { - using mounted = createEditor(() => $appendCode(['a', '', 'b'])); - using headless = buildEditorFromExtensions( + describe('HTML export', () => { + /** An empty line in the middle and an empty last line. */ + const LINES = ['a', '', 'b', '']; + + const $initialCodeAndParagraph = () => { + $appendCode(LINES); + $getRoot().append($createParagraphNode().append($createTextNode('z'))); + }; + + function createHeadlessEditor( + dependencies: AnyLexicalExtensionArgument[], + ): LexicalEditorWithDispose { + return buildEditorFromExtensions( defineExtension({ - $initialEditorState: () => $appendCode(['a', '', 'b']), - dependencies: [RichTextExtension, CodeLineNumbersExtension], + $initialEditorState: $initialCodeAndParagraph, + dependencies: [RichTextExtension, ...dependencies], name: '[code-line-numbers-headless]', }), ); - expect(getWrappers(mounted)).toHaveLength(2); - for (const editor of [mounted, headless]) { - const html = editor.read(() => $generateHtmlFromNodes(editor, null)); - expect(html).toContain('
'); - expect(html).not.toContain(LINE_BREAK_ATTR); - expect(html).not.toContain(LINE_NUMBERS_ATTR); - } + } + + function exportHTML(editor: LexicalEditor): string { + return editor.read(() => $generateHtmlFromNodes(editor, null)); + } + + /** The text/html that copying the whole document puts on the clipboard. */ + function copyHTML(editor: LexicalEditor): string { + let html: string | undefined; + editor.update( + () => { + const root = $getRoot(); + root.select(0, root.getChildrenSize()); + html = $getClipboardDataFromSelection()['text/html']; + }, + {discrete: true}, + ); + assert(html !== undefined, 'expected text/html on the clipboard'); + return html; + } + + describe.each([ + ['every code block', CodeLineNumbersExtension], + ['onlyWordWrapped', OnlyWordWrappedLineNumbers], + ])('numbering %s', (_name, lineNumbers) => { + test.each([false, true])( + 'exports and copies the same HTML as without the extension, mounted or not (word wrap %s)', + wordWrap => { + using baseline = createEditor($initialCodeAndParagraph, [ + CodeExtension, + ]); + using mounted = createEditor($initialCodeAndParagraph, [lineNumbers]); + using headless = createHeadlessEditor([lineNumbers]); + for (const editor of [baseline, mounted, headless]) { + setWordWrap(editor, wordWrap); + } + // The block on screen has the wrappers whenever it is numbered. + expect(getWrappers(mounted)).toHaveLength( + wordWrap || lineNumbers === CodeLineNumbersExtension ? 3 : 0, + ); + + const expected = exportHTML(baseline); + const doc = new DOMParser().parseFromString(expected, 'text/html'); + const pre = doc.querySelector('pre'); + assert(pre !== null, 'expected an exported
');
+          expect(pre.getAttribute(WORD_WRAP_ATTR)).toBe(
+            wordWrap ? 'true' : null,
+          );
+          // A plain 
for each of the three line breaks, and the managed + // one after the last, all directly in the
.
+          expect(pre.querySelectorAll('br')).toHaveLength(4);
+          expect(pre.querySelectorAll(':scope > br')).toHaveLength(4);
+          expect(expected).not.toContain(LINE_BREAK_ATTR);
+          expect(expected).not.toContain(LINE_NUMBERS_ATTR);
+          expect(exportHTML(mounted)).toBe(expected);
+          expect(exportHTML(headless)).toBe(expected);
+
+          const copied = copyHTML(baseline);
+          expect(copied).toContain('['$createDOM'] = (
+      _node,
+      $next,
+    ) => {
+      const dom = $next();
+      dom.setAttribute(MARK_ATTR, 'true');
+      return dom;
+    };
+
+    test.each([
+      ['LineBreakNode', domOverride([LineBreakNode], {$createDOM: $mark})],
+      ['wildcard', domOverride('*', {$createDOM: $mark})],
+    ])(
+      'export keeps what a later %s override does to the 
', + (_name, override) => { + // Composed after the extension's overrides, so its $next() returns + // the wrapper when the extension adds one. + const Mark = defineExtension({ + dependencies: [ + configExtension(DOMRenderExtension, {overrides: [override]}), + ], + name: '[mark]', + }); + using baseline = createEditor($initialCodeAndParagraph, [ + CodeExtension, + Mark, + ]); + using mounted = createEditor($initialCodeAndParagraph, [ + CodeLineNumbersExtension, + Mark, + ]); + using headless = createHeadlessEditor([CodeLineNumbersExtension, Mark]); + // On screen the mark lands on the wrapper. + const wrappers = getWrappers(mounted); + expect(wrappers).toHaveLength(3); + for (const wrapper of wrappers) { + expect(wrapper.getAttribute(MARK_ATTR)).toBe('true'); + } + + const expected = exportHTML(baseline); + const doc = new DOMParser().parseFromString(expected, 'text/html'); + expect(doc.querySelectorAll(`pre > br[${MARK_ATTR}]`)).toHaveLength(3); + expect(exportHTML(mounted)).toBe(expected); + expect(exportHTML(headless)).toBe(expected); + + const copied = copyHTML(baseline); + expect(copyHTML(mounted)).toBe(copied); + expect(copyHTML(headless)).toBe(copied); + }, + ); + + test('a copied block pastes back with all its lines and word wrap', () => { + using source = createEditor($initialCodeAndParagraph); + setWordWrap(source, true); + const html = copyHTML(source); + using target = createEditor(() => { + $getRoot().clear().append($createParagraphNode()); + }); + target.update( + () => { + $getRoot().selectStart(); + const selection = $getSelection(); + assert($isRangeSelection(selection)); + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/html', html); + $insertDataTransferForRichText(dataTransfer, selection, target); + }, + {discrete: true}, + ); + target.read(() => { + expect($getCode().getTextContent()).toBe(LINES.join('\n')); + expect($getCode().getWordWrap()).toBe(true); + }); + expectWrappedLineBreaks(target, 3); }); }); diff --git a/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs index f95030f8e0c..ed3a5dcc12b 100644 --- a/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs @@ -226,6 +226,13 @@ test.describe('Code block word wrap', () => { expect(after['text/html']).toContain( 'data-lexical-code-word-wrap="true"', ); + // The line numbers stay out of the copy. Apart from the word wrap + // attribute, it has the same HTML as before, with a plain
for + // each line break. + expect(after['text/html']).not.toContain('data-lexical-code-line'); + expect( + after['text/html'].replace(' data-lexical-code-word-wrap="true"', ''), + ).toBe(before['text/html']); expect(await countLineBreaks(page)).toBe(3); await toggleWrap(page); From 58a6a1c08edb491203c171ccc1ab5252fc83f680 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:57:30 +0000 Subject: [PATCH 07/31] [lexical-code-core] Refactor: Store the code block word wrap flag in the node schema ## Description The word wrap flag was a flat NodeState on `CodeNode`, next to `language` and `theme`, which are declared in the node's JSON schema. Word wrap is a property inherent to a code block, so it is now a schema field too (`withField(optional(booleanValue(), {omitDefault: true}), {field: '__wordWrap'})`), and the generated clone and JSON code carries it. `setWordWrap` keeps its value-or-updater signature and only marks the node dirty when the value changes. The JSON shape is unchanged: `"wordWrap": true` while on, no key while off. Also adds the PR's changeset for `@lexical/code-core` and `@lexical/code`. ## Test plan ### Before `pnpm run check-changesets` had no entry for the word wrap and line numbers feature. ### After ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code-core packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx packages/lexical-playground/__tests__/unit/VisibleNonPrintingCodeLineNumbers.test.ts Test Files 10 passed (10) $ pnpm run check-changesets Changeset check passed: 7 pending changeset(s) valid. ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 10 ++++ packages/lexical-code-core/src/CodeNode.ts | 54 ++++++++++--------- .../src/LexicalCodeCoreGeneratedJSON.ts | 11 ++++ .../unit/CodeImportExtension.test.ts | 2 +- .../src/__tests__/unit/CodeNode.test.ts | 24 ++++----- 5 files changed, 61 insertions(+), 40 deletions(-) create mode 100644 .changeset/code-word-wrap-line-numbers.md diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md new file mode 100644 index 00000000000..805fc95571f --- /dev/null +++ b/.changeset/code-word-wrap-line-numbers.md @@ -0,0 +1,10 @@ +--- +'@lexical/code-core': patch +'@lexical/code': patch +--- + +[feature] Add a word wrap flag to `CodeNode` (`setWordWrap`/`getWordWrap`) and an experimental `CodeLineNumbersExtension` that numbers each logical line of a code block so the numbers follow wrapped lines + +A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-lexical-code-word-wrap="true"` to its DOM and exported HTML, and HTML import reads the attribute back; the key and the attribute are left out while it is off. The package adds no CSS: the theme wraps the lines under the attribute. + +`CodeLineNumbersExtension` sets `data-lexical-code-line-numbers` on each code block and wraps the `
` of each of its line breaks in a ``, so a theme can draw the numbers with a CSS counter. Exported and copied HTML is unchanged. Set `onlyWordWrapped` to number only blocks with word wrap on. The package README has the CSS recipe. diff --git a/packages/lexical-code-core/src/CodeNode.ts b/packages/lexical-code-core/src/CodeNode.ts index 00d29632da1..4940db8e76f 100644 --- a/packages/lexical-code-core/src/CodeNode.ts +++ b/packages/lexical-code-core/src/CodeNode.ts @@ -17,15 +17,11 @@ import { $createTabNode, $getDocument, $getEditor, - $getState, - $getStateChange, $isLineBreakNode, $isTabNode, $isTextNode, - $setState, addClassNamesToElement, booleanValue, - createState, type DOMConversionOutput, type DOMExportOutput, type EditorConfig, @@ -44,10 +40,11 @@ import { type SerializedPartial, setDOMStyleFromCSS, type Spread, - type StateValueOrUpdater, stringValue, type TabNode, + type ValueOrUpdater, withAccessors, + withField, } from 'lexical'; import { @@ -86,6 +83,11 @@ const codeNodeSchema = nodeSchema()({ theme: withAccessors(optional(stringValue()), { getter: {field: '__theme'}, }), + // Left out of the JSON while it is off, so other code blocks' JSON is + // unchanged. + wordWrap: withField(optional(booleanValue(), {omitDefault: true}), { + field: '__wordWrap', + }), }); export const DEFAULT_CODE_LANGUAGE = 'javascript'; @@ -109,13 +111,6 @@ const HIGHLIGHT_LANGUAGE_DATA_ATTRIBUTE = 'data-highlight-language'; const THEME_DATA_ATTRIBUTE = 'data-theme'; const WORD_WRAP_DATA_ATTRIBUTE = 'data-lexical-code-word-wrap'; -/** - * Whether a code block soft wraps long lines. It is flat, so it serializes - * as a top level `wordWrap` key next to `language` and `theme`, and only - * while it is true. - */ -const codeWordWrapState = createState('wordWrap', {parse: booleanValue()}); - const noExtensionDeprecation = warnOnlyOnce( 'Using CodeNode without CodeExtension is deprecated', ); @@ -135,6 +130,8 @@ export class CodeNode extends ElementNode { /** @internal */ __theme: string | undefined; /** @internal */ + __wordWrap: boolean | undefined = undefined; + /** @internal */ __isSyntaxHighlightSupported: boolean; $config() { @@ -207,7 +204,6 @@ export class CodeNode extends ElementNode { }, }, json: codeNodeSchema, - stateConfigs: [{flat: true, stateConfig: codeWordWrapState}], }); } @@ -306,9 +302,8 @@ export class CodeNode extends ElementNode { // Only the attribute changes. Keep the element, with its scroll // position and line break wrappers, instead of recreating it. - const wordWrap = $getStateChange(this, prevNode, codeWordWrapState); - if (wordWrap !== null) { - if (wordWrap[0]) { + if (this.__wordWrap !== prevNode.__wordWrap) { + if (this.__wordWrap) { dom.setAttribute(WORD_WRAP_DATA_ATTRIBUTE, 'true'); } else { dom.removeAttribute(WORD_WRAP_DATA_ATTRIBUTE); @@ -476,19 +471,27 @@ export class CodeNode extends ElementNode { * on the `data-lexical-code-word-wrap` attribute (see the package README). */ getWordWrap(): boolean { - return $getState(this, codeWordWrapState); + return this.getLatest().__wordWrap === true; } /** - * Turns soft wrapping of long lines on or off. With an updater function - * the node is only marked dirty when the value changes. This only sets the - * flag and the `data-lexical-code-word-wrap` attribute; the theme's CSS - * wraps the lines. + * Turns soft wrapping of long lines on or off, or toggles it with an + * updater such as `wordWrap => !wordWrap`. The node is only marked dirty + * when the value changes. This only sets the flag and the + * `data-lexical-code-word-wrap` attribute; the theme's CSS wraps the lines. */ - setWordWrap( - valueOrUpdater: StateValueOrUpdater, - ): this { - return $setState(this, codeWordWrapState, valueOrUpdater); + setWordWrap(valueOrUpdater: ValueOrUpdater): this { + const prevWordWrap = this.getWordWrap(); + const wordWrap = + typeof valueOrUpdater === 'function' + ? valueOrUpdater(prevWordWrap) + : valueOrUpdater; + if (wordWrap === prevWordWrap) { + return this; + } + const writable = this.getWritable(); + writable.__wordWrap = wordWrap || undefined; + return writable; } } @@ -516,7 +519,6 @@ export function $createCodeNodeFromDOM(element: HTMLElement): CodeNode { element.getAttribute(THEME_DATA_ATTRIBUTE), ); const wordWrap = element.getAttribute(WORD_WRAP_DATA_ATTRIBUTE); - // Set only when on, so other code blocks don't allocate a NodeState. return wordWrap !== null && wordWrap !== 'false' ? node.setWordWrap(true) : node; diff --git a/packages/lexical-code-core/src/LexicalCodeCoreGeneratedJSON.ts b/packages/lexical-code-core/src/LexicalCodeCoreGeneratedJSON.ts index 0499d4297e8..238be006a97 100644 --- a/packages/lexical-code-core/src/LexicalCodeCoreGeneratedJSON.ts +++ b/packages/lexical-code-core/src/LexicalCodeCoreGeneratedJSON.ts @@ -61,6 +61,7 @@ function numC( export function afterCloneCodeNode(node: CodeNode, prevNode: CodeNode): void { node.__language = prevNode.__language; node.__theme = prevNode.__theme; + node.__wordWrap = prevNode.__wordWrap; } /** CodeNode's generated implementations, for its `$config`. @internal */ @@ -100,6 +101,7 @@ export const GENERATED_CODE: GeneratedJSONFactory = fields => { children: [], language: node.__language, theme: node.__theme, + wordWrap: node.__wordWrap, direction: node.__dir, format: CODE_FORMAT_GETTER[node.__format], indent: node.__indent, @@ -128,6 +130,10 @@ export const GENERATED_CODE: GeneratedJSONFactory = fields => { if (theme !== undefined) { json.theme = theme; } + const wordWrap = node.__wordWrap; + if (wordWrap !== undefined) { + json.wordWrap = wordWrap; + } const direction = node.__dir; if (direction != null) { json.direction = direction; @@ -188,6 +194,11 @@ export const GENERATED_CODE: GeneratedJSONFactory = fields => { node.setTheme( theme === undefined ? undefined : typeof theme === 'string' ? theme : '', ); + const wordWrap = json.wordWrap; + node.__wordWrap = + typeof wordWrap === 'boolean' && wordWrap !== false + ? wordWrap + : undefined; return node; } 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 0dc6af7f541..cab99a0b109 100644 --- a/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts +++ b/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts @@ -132,7 +132,7 @@ describe('CodeImportExtension', () => { editor.read(() => { const node = $rootCode(); expect(node.getWordWrap()).toBe(false); - expect(node.exportJSON()).not.toHaveProperty('wordWrap'); + expect(node.exportJSON(true)).not.toHaveProperty('wordWrap'); }); }); 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 93c2255b518..8e0b74f4f45 100644 --- a/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts +++ b/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts @@ -159,8 +159,9 @@ describe('CodeNode', () => { const node = $createCodeNode('javascript'); expect(node.getWordWrap()).toBe(false); const off = node.exportJSON(); - expect(off).not.toHaveProperty('wordWrap'); - expect(off).not.toHaveProperty('$'); + expect(off.wordWrap).toBeUndefined(); + expect(JSON.stringify(off)).not.toContain('wordWrap'); + expect(node.exportJSON(true)).not.toHaveProperty('wordWrap'); node.setWordWrap(true); expect(node.getWordWrap()).toBe(true); @@ -175,19 +176,16 @@ describe('CodeNode', () => { ); }); - it('imports wordWrap from the top level or from $', () => { + it('imports wordWrap', () => { testEnv.editor.update( () => { const off = $createCodeNode('javascript').exportJSON(); - const topLevel = $importCodeJSON({...off, wordWrap: true}); - expect(topLevel.getWordWrap()).toBe(true); - - const nested = $importCodeJSON({...off, $: {wordWrap: true}}); - expect(nested.getWordWrap()).toBe(true); - expect(nested.exportJSON()).toStrictEqual({ - ...off, - wordWrap: true, - }); + const on = $importCodeJSON({...off, wordWrap: true}); + expect(on.getWordWrap()).toBe(true); + expect(on.exportJSON()).toStrictEqual({...off, wordWrap: true}); + expect( + $importCodeJSON({...off, wordWrap: false}).exportJSON(true), + ).not.toHaveProperty('wordWrap'); const invalid = $importCodeJSON({ ...off, @@ -303,7 +301,7 @@ describe('CodeNode', () => { const [node] = $generateNodesFromDOM(editor, doc); assert($isCodeNode(node), 'expected a CodeNode'); expect(node.getWordWrap()).toBe(false); - expect(node.exportJSON()).not.toHaveProperty('wordWrap'); + expect(node.exportJSON(true)).not.toHaveProperty('wordWrap'); }, {discrete: true}, ); From dc26969b0c1127c0295c94f56673197662b01270 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:16:02 +0000 Subject: [PATCH 08/31] [lexical][lexical-code-core][lexical-playground] Bug Fix: Move a caret out of a code line break wrapper, and keep line numbers out of plain text mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description With `CodeLineNumbersExtension`, the `
` of a code line break is wrapped in a ``. A DOM caret the browser puts inside that span, at either side of the `
`, resolved to the right Lexical point but was not marked dirty, so the DOM caret stayed inside the wrapper and text the browser inserts itself (composition) would land in it. `$internalResolveSelectionPoint` now marks a caret inside a wrapped leaf's scaffolding dirty, so the reconciler writes the caret back at the code element offset it resolves to. The playground added `CodeLineNumbersExtension` to the always-on `AppExtension`, which pulled `CodeExtension` into plain text mode. It now sits with the other code extensions in the rich text set, and the settings sync resolves it as optional. The line break override also checks the parent before looking up the export context, so line breaks outside code blocks skip the lookup. ## Test plan ### Before ``` $ ./node_modules/.bin/vitest run --project browser packages/lexical-code-core/src/__tests__/browser/CodeLineNumbersSelection.test.ts × a caret at offset 0 of the line break wrapper moves to code offset 1 × a caret at offset 1 of the line break wrapper moves to code offset 2 AssertionError: expected

to be …(4) // Object.is equality ``` ### After ``` $ VITEST_BROWSER=chromium,firefox,webkit ./node_modules/.bin/vitest run --project browser packages/lexical-code-core/src/__tests__/browser/CodeLineNumbersSelection.test.ts Test Files 3 passed (3) Tests 6 passed (6) ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 3 + .../lexical-code-core/src/CodeLineNumbers.ts | 4 +- .../browser/CodeLineNumbersSelection.test.ts | 81 +++++++++++++++++++ .../__tests__/unit/CodeLineNumbers.test.ts | 16 ++-- packages/lexical-playground/src/App.tsx | 2 +- .../src/hooks/useSynchronizeSettings.ts | 12 +-- packages/lexical/src/LexicalSelection.ts | 5 ++ 7 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 packages/lexical-code-core/src/__tests__/browser/CodeLineNumbersSelection.test.ts diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index 805fc95571f..d4bb8798dd6 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -1,4 +1,5 @@ --- +'lexical': patch '@lexical/code-core': patch '@lexical/code': patch --- @@ -8,3 +9,5 @@ A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-lexical-code-word-wrap="true"` to its DOM and exported HTML, and HTML import reads the attribute back; the key and the attribute are left out while it is off. The package adds no CSS: the theme wraps the lines under the attribute. `CodeLineNumbersExtension` sets `data-lexical-code-line-numbers` on each code block and wraps the `
` of each of its line breaks in a ``, so a theme can draw the numbers with a CSS counter. Exported and copied HTML is unchanged. Set `onlyWordWrapped` to number only blocks with word wrap on. The package README has the CSS recipe. + +In `lexical`, a DOM caret inside the wrapper of a leaf node whose `$getDOMSlot` exposes an inner element (such as that ``) now marks the selection dirty, so the caret is written back outside the wrapper and text the browser inserts itself, as during composition, no longer lands inside it. diff --git a/packages/lexical-code-core/src/CodeLineNumbers.ts b/packages/lexical-code-core/src/CodeLineNumbers.ts index f50a1bfbd46..c77f1a35eba 100644 --- a/packages/lexical-code-core/src/CodeLineNumbers.ts +++ b/packages/lexical-code-core/src/CodeLineNumbers.ts @@ -86,12 +86,12 @@ function lineNumberOverrides(mode: 'all' | 'wordWrapped') { $createDOM: (node, $next) => { const dom = $next(); if ( + !$hasLineNumbers(node.getParent(), mode) || // The default exportDOM builds its element with $createDOM, so // HTML export and the clipboard come through here too. They get // the plain
, and overrides composed after this one change // that
rather than the wrapper. - $getRenderContextValue(RenderContextExport) || - !$hasLineNumbers(node.getParent(), mode) + $getRenderContextValue(RenderContextExport) ) { return dom; } diff --git a/packages/lexical-code-core/src/__tests__/browser/CodeLineNumbersSelection.test.ts b/packages/lexical-code-core/src/__tests__/browser/CodeLineNumbersSelection.test.ts new file mode 100644 index 00000000000..d2ab3b5abb7 --- /dev/null +++ b/packages/lexical-code-core/src/__tests__/browser/CodeLineNumbersSelection.test.ts @@ -0,0 +1,81 @@ +/** + * 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, + CodeLineNumbersExtension, +} from '@lexical/code-core'; +import {buildEditorFromExtensions} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createLineBreakNode, + $getRoot, + $getSelection, + $isRangeSelection, +} from 'lexical'; +import {assert, expect, onTestFinished, test} from 'vitest'; + +// The browser can put the caret inside the that wraps a line break's +//
, e.g. on a click at the start of a line. Text the browser inserts +// itself (composition) would then land inside the wrapper, so the caret is +// written back outside it, at the position it resolves to. +test.each([ + [0, 1], + [1, 2], +])( + 'a caret at offset %i of the line break wrapper moves to code offset %i', + async (domOffset, codeOffset) => { + const contentEditable = document.createElement('div'); + contentEditable.contentEditable = 'true'; + document.body.appendChild(contentEditable); + const editor = buildEditorFromExtensions({ + dependencies: [RichTextExtension, CodeLineNumbersExtension], + name: 'code-line-numbers-selection', + }); + editor.setRootElement(contentEditable); + onTestFinished(() => { + editor.setRootElement(null); + contentEditable.remove(); + editor.dispose(); + }); + contentEditable.focus(); + editor.update( + () => { + $getRoot() + .clear() + .append( + $createCodeNode().append( + $createCodeHighlightNode('ab'), + $createLineBreakNode(), + $createLineBreakNode(), + ), + ); + }, + {discrete: true}, + ); + const code = contentEditable.querySelector('code'); + const wrapper = contentEditable.querySelector( + '[data-lexical-code-line-break]', + ); + assert(code !== null && wrapper !== null); + + const domSelection = window.getSelection(); + assert(domSelection !== null); + domSelection.setBaseAndExtent(wrapper, domOffset, wrapper, domOffset); + await expect.poll(() => domSelection.anchorNode).toBe(code); + expect(domSelection.anchorOffset).toBe(codeOffset); + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + expect(selection.anchor.type).toBe('element'); + expect(selection.anchor.offset).toBe(codeOffset); + expect(selection.isCollapsed()).toBe(true); + }); + }, +); diff --git a/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts index 4adba7d179e..1a677e6469e 100644 --- a/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts +++ b/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts @@ -859,20 +859,22 @@ describe('CodeLineNumbersExtension', () => { using _editor = editor; const emptyLine = {key: codeKey, offset: 2, type: 'element'}; const codeElement = getCodeElement(editor); - // After the first wrapper, between the wrappers, before the second - //
. These carets are on keyed DOM, so they aren't dirty and the - // DOM caret stays where the browser put it. - expect(resolveDOMCaret(editor, first, 1)).toEqual({ + // Between the wrappers is a caret position the reconciler writes, so + // it isn't dirty. + expect(resolveDOMCaret(editor, codeElement, 2)).toEqual({ ...emptyLine, dirty: false, }); - expect(resolveDOMCaret(editor, codeElement, 2)).toEqual({ + // After the first
or before the second, inside a wrapper. Text + // the browser inserts there would land in the wrapper, so these are + // dirty and get written back between the wrappers. + expect(resolveDOMCaret(editor, first, 1)).toEqual({ ...emptyLine, - dirty: false, + dirty: true, }); expect(resolveDOMCaret(editor, second, 0)).toEqual({ ...emptyLine, - dirty: false, + dirty: true, }); // A caret reported on the
itself counts as before the break. The //
has no key, so it's dirty and gets written back onto the code diff --git a/packages/lexical-playground/src/App.tsx b/packages/lexical-playground/src/App.tsx index 5d99fa0f17c..364bffe39f8 100644 --- a/packages/lexical-playground/src/App.tsx +++ b/packages/lexical-playground/src/App.tsx @@ -232,6 +232,7 @@ const PlaygroundRichTextExtension = defineExtension({ TabFocusExtension, CollapsibleExtension, CodeHighlightExtension, + configExtension(CodeLineNumbersExtension, {onlyWordWrapped: true}), configExtension(ListExtension, { shouldPreserveNumbering: false, }), @@ -290,7 +291,6 @@ const AppExtension = defineExtension({ configExtension(VisibleNonPrintingExtension, { disabled: true, }), - configExtension(CodeLineNumbersExtension, {onlyWordWrapped: true}), // DOMImportExtension pipeline — `PlaygroundImportExtension` bundles // the shared `CoreImportExtension` baseline, the playground-specific // inline-style overlay and the `ClipboardDOMImportExtension` paste diff --git a/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts b/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts index 8dd6885d0c9..bc361a3b04d 100644 --- a/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts +++ b/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts @@ -55,9 +55,9 @@ function output( /** * Output of an extension that may be absent: the rich-text-only extensions - * (`List`, `Table`, `CheckList`, and the playground `CodeHighlight`) don't - * exist in plain-text mode, so resolve them with the optional (peer) form and - * skip them when not built. + * (`List`, `Table`, `CheckList`, `CodeLineNumbers`, and the playground + * `CodeHighlight`) don't exist in plain-text mode, so resolve them with the + * optional (peer) form and skip them when not built. */ function peerOutput( editor: LexicalEditor, @@ -90,9 +90,11 @@ export function synchronizeSettingsToSignals( !settings.isAutocomplete; output(editor, VisibleNonPrintingExtension).disabled.value = !settings.isVisibleNonPrinting; - output(editor, CodeLineNumbersExtension).onlyWordWrapped.value = - !settings.isCodeLineNumbers; output(editor, MaxLengthExtension).disabled.value = !settings.isMaxLength; + const codeLineNumbers = peerOutput(editor, CodeLineNumbersExtension); + if (codeLineNumbers) { + codeLineNumbers.onlyWordWrapped.value = !settings.isCodeLineNumbers; + } const codeHighlight = peerOutput(editor, CodeHighlightExtension); if (codeHighlight) { codeHighlight.mode.value = !settings.isCodeHighlighted diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index b574c947d6d..4e0c69a4ccd 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -3187,6 +3187,11 @@ export function $internalResolveSelectionPoint( const slot = $getDOMSlot(resolvedElement, elementDOM, editor); if (slot.element !== elementDOM) { position = slot.resolveLeafPosition(elementDOM, dom, offset); + // The caret is inside the leaf's wrapper, which is not a caret + // position the reconciler writes. Write the resolved one back, + // so text the browser inserts (e.g. during composition) lands + // outside the wrapper. + dirty = true; } else if (offset === 0 && $isDecoratorNode(resolvedElement)) { position = 'before'; } From 6febdf6363227ac30c2c8f023de80b9647c6d628 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:33:16 +0000 Subject: [PATCH 09/31] [lexical][lexical-code-core] Bug Fix: Revert writing the caret back out of a code line break wrapper ## Description The previous commit marked a DOM caret inside a wrapped leaf (the `` around a code line break's `
`) dirty, so the reconciler would write it back outside the wrapper. That guarded against browser-inserted composition text landing inside the wrapper, but a Chromium IME composition (`Input.imeSetComposition` + `Input.insertText` over CDP) with the caret inside the wrapper already puts the text in the right code line without it. Meanwhile, in Firefox a mouse drag across numbered code lines went from 1 or 2 selection writes per drag to as many as 43. So this reverts the core change and its tests. `setWordWrap` now stores only `true` or nothing, whatever an untyped caller or updater returns, so `createDOM`, `updateDOM` and JSON agree. ## Test plan ### Before Firefox mouse drags across a numbered code block, `setBaseAndExtent` calls per drag with the core change: `7, 3, 43, 40, 1`. ### After The same drags without it: `2, 1, 2, 2, 1`. ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code-core Tests 135 passed (135) ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 3 - packages/lexical-code-core/src/CodeNode.ts | 4 +- .../browser/CodeLineNumbersSelection.test.ts | 81 ------------------- .../__tests__/unit/CodeLineNumbers.test.ts | 16 ++-- packages/lexical/src/LexicalSelection.ts | 5 -- 5 files changed, 9 insertions(+), 100 deletions(-) delete mode 100644 packages/lexical-code-core/src/__tests__/browser/CodeLineNumbersSelection.test.ts diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index d4bb8798dd6..805fc95571f 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -1,5 +1,4 @@ --- -'lexical': patch '@lexical/code-core': patch '@lexical/code': patch --- @@ -9,5 +8,3 @@ A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-lexical-code-word-wrap="true"` to its DOM and exported HTML, and HTML import reads the attribute back; the key and the attribute are left out while it is off. The package adds no CSS: the theme wraps the lines under the attribute. `CodeLineNumbersExtension` sets `data-lexical-code-line-numbers` on each code block and wraps the `
` of each of its line breaks in a ``, so a theme can draw the numbers with a CSS counter. Exported and copied HTML is unchanged. Set `onlyWordWrapped` to number only blocks with word wrap on. The package README has the CSS recipe. - -In `lexical`, a DOM caret inside the wrapper of a leaf node whose `$getDOMSlot` exposes an inner element (such as that ``) now marks the selection dirty, so the caret is written back outside the wrapper and text the browser inserts itself, as during composition, no longer lands inside it. diff --git a/packages/lexical-code-core/src/CodeNode.ts b/packages/lexical-code-core/src/CodeNode.ts index 4940db8e76f..5d568dd7101 100644 --- a/packages/lexical-code-core/src/CodeNode.ts +++ b/packages/lexical-code-core/src/CodeNode.ts @@ -483,9 +483,9 @@ export class CodeNode extends ElementNode { setWordWrap(valueOrUpdater: ValueOrUpdater): this { const prevWordWrap = this.getWordWrap(); const wordWrap = - typeof valueOrUpdater === 'function' + (typeof valueOrUpdater === 'function' ? valueOrUpdater(prevWordWrap) - : valueOrUpdater; + : valueOrUpdater) === true; if (wordWrap === prevWordWrap) { return this; } diff --git a/packages/lexical-code-core/src/__tests__/browser/CodeLineNumbersSelection.test.ts b/packages/lexical-code-core/src/__tests__/browser/CodeLineNumbersSelection.test.ts deleted file mode 100644 index d2ab3b5abb7..00000000000 --- a/packages/lexical-code-core/src/__tests__/browser/CodeLineNumbersSelection.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * 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, - CodeLineNumbersExtension, -} from '@lexical/code-core'; -import {buildEditorFromExtensions} from '@lexical/extension'; -import {RichTextExtension} from '@lexical/rich-text'; -import { - $createLineBreakNode, - $getRoot, - $getSelection, - $isRangeSelection, -} from 'lexical'; -import {assert, expect, onTestFinished, test} from 'vitest'; - -// The browser can put the caret inside the that wraps a line break's -//
, e.g. on a click at the start of a line. Text the browser inserts -// itself (composition) would then land inside the wrapper, so the caret is -// written back outside it, at the position it resolves to. -test.each([ - [0, 1], - [1, 2], -])( - 'a caret at offset %i of the line break wrapper moves to code offset %i', - async (domOffset, codeOffset) => { - const contentEditable = document.createElement('div'); - contentEditable.contentEditable = 'true'; - document.body.appendChild(contentEditable); - const editor = buildEditorFromExtensions({ - dependencies: [RichTextExtension, CodeLineNumbersExtension], - name: 'code-line-numbers-selection', - }); - editor.setRootElement(contentEditable); - onTestFinished(() => { - editor.setRootElement(null); - contentEditable.remove(); - editor.dispose(); - }); - contentEditable.focus(); - editor.update( - () => { - $getRoot() - .clear() - .append( - $createCodeNode().append( - $createCodeHighlightNode('ab'), - $createLineBreakNode(), - $createLineBreakNode(), - ), - ); - }, - {discrete: true}, - ); - const code = contentEditable.querySelector('code'); - const wrapper = contentEditable.querySelector( - '[data-lexical-code-line-break]', - ); - assert(code !== null && wrapper !== null); - - const domSelection = window.getSelection(); - assert(domSelection !== null); - domSelection.setBaseAndExtent(wrapper, domOffset, wrapper, domOffset); - await expect.poll(() => domSelection.anchorNode).toBe(code); - expect(domSelection.anchorOffset).toBe(codeOffset); - editor.read(() => { - const selection = $getSelection(); - assert($isRangeSelection(selection)); - expect(selection.anchor.type).toBe('element'); - expect(selection.anchor.offset).toBe(codeOffset); - expect(selection.isCollapsed()).toBe(true); - }); - }, -); diff --git a/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts index 1a677e6469e..4adba7d179e 100644 --- a/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts +++ b/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts @@ -859,22 +859,20 @@ describe('CodeLineNumbersExtension', () => { using _editor = editor; const emptyLine = {key: codeKey, offset: 2, type: 'element'}; const codeElement = getCodeElement(editor); - // Between the wrappers is a caret position the reconciler writes, so - // it isn't dirty. - expect(resolveDOMCaret(editor, codeElement, 2)).toEqual({ + // After the first wrapper, between the wrappers, before the second + //
. These carets are on keyed DOM, so they aren't dirty and the + // DOM caret stays where the browser put it. + expect(resolveDOMCaret(editor, first, 1)).toEqual({ ...emptyLine, dirty: false, }); - // After the first
or before the second, inside a wrapper. Text - // the browser inserts there would land in the wrapper, so these are - // dirty and get written back between the wrappers. - expect(resolveDOMCaret(editor, first, 1)).toEqual({ + expect(resolveDOMCaret(editor, codeElement, 2)).toEqual({ ...emptyLine, - dirty: true, + dirty: false, }); expect(resolveDOMCaret(editor, second, 0)).toEqual({ ...emptyLine, - dirty: true, + dirty: false, }); // A caret reported on the
itself counts as before the break. The //
has no key, so it's dirty and gets written back onto the code diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 4e0c69a4ccd..b574c947d6d 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -3187,11 +3187,6 @@ export function $internalResolveSelectionPoint( const slot = $getDOMSlot(resolvedElement, elementDOM, editor); if (slot.element !== elementDOM) { position = slot.resolveLeafPosition(elementDOM, dom, offset); - // The caret is inside the leaf's wrapper, which is not a caret - // position the reconciler writes. Write the resolved one back, - // so text the browser inserts (e.g. during composition) lands - // outside the wrapper. - dirty = true; } else if (offset === 0 && $isDecoratorNode(resolvedElement)) { position = 'before'; } From f847a7346c720ecbc84d86c0a99d047fa12734d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:47:09 +0000 Subject: [PATCH 10/31] [lexical-code-core][lexical-playground] Bug Fix: Keep render signals out of the line numbers effect, and hide the code menu when its block is removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `CodeLineNumbersExtension`'s register effect set the render context mode inside the effect, and a mode change re-renders the editor synchronously, so any signal an override or listener read during that render became a dependency of the effect. The mode is now computed from the extension's own signals and set inside `untracked`. In the playground, removing the code block the action menu was showing for left the menu (and its Wrap button) up for a node that no longer exists. The menu now hides when that block is destroyed. ## Test plan ### Before ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx × hides the menu when the hovered code block is removed Tests 1 failed | 2 passed (3) ``` ### After ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx packages/lexical-code-core Tests 138 passed (138) ``` Co-Authored-By: Claude Opus 5.5 --- packages/lexical-code-core/src/CodeLineNumbers.ts | 15 ++++++++++----- .../unit/CodeActionMenuWrapButton.test.tsx | 14 ++++++++++++++ .../src/plugins/CodeActionMenuPlugin/index.tsx | 4 ++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/lexical-code-core/src/CodeLineNumbers.ts b/packages/lexical-code-core/src/CodeLineNumbers.ts index c77f1a35eba..df7bde4d3fc 100644 --- a/packages/lexical-code-core/src/CodeLineNumbers.ts +++ b/packages/lexical-code-core/src/CodeLineNumbers.ts @@ -6,7 +6,7 @@ * */ -import {effect, namedSignals} from '@lexical/extension'; +import {effect, namedSignals, untracked} from '@lexical/extension'; import { $getRenderContextValue, $setRenderContextValue, @@ -217,10 +217,15 @@ export const CodeLineNumbersExtension = defineExtension({ register: (editor, _config, state) => { const {disabled, onlyWordWrapped} = state.getOutput(); return effect(() => { - $setRenderContextValue( - CodeLineNumbersModeState, - disabled.value ? 'off' : onlyWordWrapped.value ? 'wordWrapped' : 'all', - editor, + const mode = disabled.value + ? 'off' + : onlyWordWrapped.value + ? 'wordWrapped' + : 'all'; + // A mode change re-renders the editor synchronously. Don't let the + // signals that render reads become dependencies of this effect. + untracked(() => + $setRenderContextValue(CodeLineNumbersModeState, mode, editor), ); }); }, diff --git a/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx b/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx index 22c9ad22358..5538ceec600 100644 --- a/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx +++ b/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx @@ -177,4 +177,18 @@ describe('CodeActionMenuPlugin wrap button', () => { expect(getWrapButton().getAttribute('aria-pressed')).toBe('false'); expect(getCodeElements()[1]).toBe(second); }); + + it('hides the menu when the hovered code block is removed', async () => { + const [first] = getCodeElements(); + await hover(first); + getWrapButton(); + + await act(async () => { + editor.update(() => $getRoot().getFirstChildOrThrow().remove(), { + discrete: true, + }); + }); + expect(anchorElem.querySelector('button[aria-label="wrap"]')).toBeNull(); + expect(getWordWraps()).toEqual([true]); + }); }); diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx index d877fb50fd5..df88b35cdcb 100644 --- a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx @@ -142,6 +142,10 @@ function CodeActionMenuContainer({ case 'destroyed': codeSetRef.current.delete(key); + if (key === codeKeyRef.current) { + codeKeyRef.current = null; + setShown(false); + } break; case 'updated': From 94a8ee75d913f0ee344272c8f7c5d6f8702650d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:04:05 +0000 Subject: [PATCH 11/31] [lexical-playground] Bug Fix: Refresh the code menu's language along with its wrap state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When the code block the action menu is showing for changed, the menu refreshed only its Wrap button, so a language change (from undo or a collaborator) left the old language label and Prettier eligibility until the next mouse move. Both now refresh together. ## Test plan ### Before ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx × follows changes to the hovered code block ``` ### After ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx Tests 4 passed (4) ``` Co-Authored-By: Claude Opus 5.5 --- .../unit/CodeActionMenuWrapButton.test.tsx | 21 +++++++++++++++++++ .../plugins/CodeActionMenuPlugin/index.tsx | 5 ++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx b/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx index 5538ceec600..0ae8313aaa0 100644 --- a/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx +++ b/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx @@ -178,6 +178,27 @@ describe('CodeActionMenuPlugin wrap button', () => { expect(getCodeElements()[1]).toBe(second); }); + it('follows changes to the hovered code block', async () => { + const [first] = getCodeElements(); + await hover(first); + const label = anchorElem.querySelector('.code-highlight-language'); + assert(label !== null, 'expected the menu to show the language'); + expect(label.textContent).toBe('JavaScript'); + + await act(async () => { + editor.update( + () => { + const code = $getRoot().getFirstChildOrThrow(); + assert($isCodeNode(code)); + code.setLanguage('python').setWordWrap(true); + }, + {discrete: true}, + ); + }); + expect(label.textContent).toBe('Python'); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('true'); + }); + it('hides the menu when the hovered code block is removed', async () => { const [first] = getCodeElements(); await hover(first); diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx index df88b35cdcb..31fe141584d 100644 --- a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx @@ -151,7 +151,10 @@ function CodeActionMenuContainer({ case 'updated': if (key === codeKeyRef.current) { const node = $getNodeByKey(key); - setWrapped($isCodeNode(node) && node.getWordWrap()); + if ($isCodeNode(node)) { + setLang(node.getLanguage() || ''); + setWrapped(node.getWordWrap()); + } } break; From 1f65ceb805e51de73136fb87009506f40d6b8ede Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:11:05 +0000 Subject: [PATCH 12/31] [lexical-code-core] Documentation Update: Say the line numbers extension marks lines rather than numbering them ## Description The changeset summary said `CodeLineNumbersExtension` numbers each line, but it only adds the elements a theme's CSS counter numbers. A comment in `CodeNode.updateDOM` said toggling word wrap keeps the code element and its line break wrappers, which isn't so with `onlyWordWrapped`, where the extension recreates the block. Both now say what happens. Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 4 ++-- packages/lexical-code-core/src/CodeNode.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index 805fc95571f..406931a2bc4 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -3,8 +3,8 @@ '@lexical/code': patch --- -[feature] Add a word wrap flag to `CodeNode` (`setWordWrap`/`getWordWrap`) and an experimental `CodeLineNumbersExtension` that numbers each logical line of a code block so the numbers follow wrapped lines +[feature] Add a word wrap flag to `CodeNode` (`setWordWrap`/`getWordWrap`) and an experimental `CodeLineNumbersExtension` that gives each logical line of a code block its own element, so a theme can draw line numbers that follow wrapped lines A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-lexical-code-word-wrap="true"` to its DOM and exported HTML, and HTML import reads the attribute back; the key and the attribute are left out while it is off. The package adds no CSS: the theme wraps the lines under the attribute. -`CodeLineNumbersExtension` sets `data-lexical-code-line-numbers` on each code block and wraps the `
` of each of its line breaks in a ``, so a theme can draw the numbers with a CSS counter. Exported and copied HTML is unchanged. Set `onlyWordWrapped` to number only blocks with word wrap on. The package README has the CSS recipe. +`CodeLineNumbersExtension` sets `data-lexical-code-line-numbers` on each code block and wraps the `
` of each of its line breaks in a ``, so a theme can draw the numbers with a CSS counter. Exported and copied HTML is unchanged. Set `onlyWordWrapped` to limit this to blocks with word wrap on. The package README has the CSS recipe. diff --git a/packages/lexical-code-core/src/CodeNode.ts b/packages/lexical-code-core/src/CodeNode.ts index 5d568dd7101..ddf8e69e0f8 100644 --- a/packages/lexical-code-core/src/CodeNode.ts +++ b/packages/lexical-code-core/src/CodeNode.ts @@ -301,7 +301,9 @@ export class CodeNode extends ElementNode { } // Only the attribute changes. Keep the element, with its scroll - // position and line break wrappers, instead of recreating it. + // position, instead of recreating it. (CodeLineNumbersExtension with + // onlyWordWrapped does recreate it, because its line break wrappers + // follow the flag.) if (this.__wordWrap !== prevNode.__wordWrap) { if (this.__wordWrap) { dom.setAttribute(WORD_WRAP_DATA_ATTRIBUTE, 'true'); From b031de87e65effd13adb56193faf34fc3ce8fd5e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:23:19 +0000 Subject: [PATCH 13/31] [lexical-code-core] Documentation Update: Point the line numbers docs at the code-core README and describe the markup accurately ## Description The JSDoc and changeset pointed at "the package README" for the CSS recipe, but most users import from `@lexical/code`, whose README has none; they now name the `@lexical/code-core` README. The changeset and README no longer say every line gets its own element (line 1 is numbered by the code element, the others by the wrapper of the line break before them), the caret scrolling section no longer calls the README's unsticky recipe sticky, and `$createCodeNodeFromDOM` says it reads the data attributes rather than all of them. Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 4 ++-- packages/lexical-code-core/README.md | 8 ++++---- packages/lexical-code-core/src/CodeLineNumbers.ts | 4 ++-- packages/lexical-code-core/src/CodeNode.ts | 5 +++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index 406931a2bc4..bbd1adb2bd9 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -3,8 +3,8 @@ '@lexical/code': patch --- -[feature] Add a word wrap flag to `CodeNode` (`setWordWrap`/`getWordWrap`) and an experimental `CodeLineNumbersExtension` that gives each logical line of a code block its own element, so a theme can draw line numbers that follow wrapped lines +[feature] Add a word wrap flag to `CodeNode` (`setWordWrap`/`getWordWrap`) and an experimental `CodeLineNumbersExtension` that marks where each logical line of a code block starts, so a theme can draw line numbers that follow wrapped lines A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-lexical-code-word-wrap="true"` to its DOM and exported HTML, and HTML import reads the attribute back; the key and the attribute are left out while it is off. The package adds no CSS: the theme wraps the lines under the attribute. -`CodeLineNumbersExtension` sets `data-lexical-code-line-numbers` on each code block and wraps the `
` of each of its line breaks in a ``, so a theme can draw the numbers with a CSS counter. Exported and copied HTML is unchanged. Set `onlyWordWrapped` to limit this to blocks with word wrap on. The package README has the CSS recipe. +`CodeLineNumbersExtension` sets `data-lexical-code-line-numbers` on each code block and wraps the `
` of each of its line breaks in a ``, so a theme can draw the numbers with a CSS counter. Exported and copied HTML is unchanged. Set `onlyWordWrapped` to limit this to blocks with word wrap on. The `@lexical/code-core` README has the CSS recipe. diff --git a/packages/lexical-code-core/README.md b/packages/lexical-code-core/README.md index 1b7cd149b0e..38ebf771773 100644 --- a/packages/lexical-code-core/README.md +++ b/packages/lexical-code-core/README.md @@ -12,8 +12,8 @@ functionality is still available in `@lexical/code`. attribute (`"1\n2\n3"`), which a theme can show with `content: attr(data-gutter)`. That gutter is a single block of text, so it can't follow a line that wraps. -`CodeLineNumbersExtension`, which `@lexical/code` also exports, is an opt in alternative that gives every logical line -its own element to hang a number on: +`CodeLineNumbersExtension`, which `@lexical/code` also exports, is an opt in alternative that gives a theme an element to +hang the number of every logical line on: - the DOM of every `CodeNode` gets `data-lexical-code-line-numbers` - the `
` of each `LineBreakNode` in a code block is wrapped in `` @@ -103,8 +103,8 @@ it goes all the way back. That shows the whole indentation, which Home and Enter scrolls just far enough, and keeps the caret out of the code element's `scroll-padding`. A gutter that stays in place while the code scrolls should set that padding to its width, so the caret isn't left under it. A `float: left` gutter, like the one the playground theme draws from `data-gutter`, is on the left in both directions, so use -`scroll-padding-left` for it. A sticky gutter at the inline start, like the line numbers above, is on the right in a -right to left block, so use `scroll-padding-inline-start` for it. +`scroll-padding-left` for it. A sticky gutter at the inline start, like the playground theme's per line numbers, is on +the right in a right to left block, so use `scroll-padding-inline-start` for it. At the end of the longest line the block is scrolled as far as it goes, and a browser can draw the caret a fraction of a pixel past that edge. A few pixels of `padding-inline-end` on the code element leave room for it. The playground theme diff --git a/packages/lexical-code-core/src/CodeLineNumbers.ts b/packages/lexical-code-core/src/CodeLineNumbers.ts index df7bde4d3fc..40a1b4c845c 100644 --- a/packages/lexical-code-core/src/CodeLineNumbers.ts +++ b/packages/lexical-code-core/src/CodeLineNumbers.ts @@ -198,8 +198,8 @@ function allLineNumberOverrides() { * * The `data-gutter` attribute written by the highlighter extensions is not * touched, so a theme that wants per line numbers has to hide its old - * gutter under `[data-lexical-code-line-numbers]`. See the package README - * for a CSS recipe. + * gutter under `[data-lexical-code-line-numbers]`. See the + * `@lexical/code-core` README for a CSS recipe. */ export const CodeLineNumbersExtension = defineExtension({ build: (editor, config) => namedSignals(config), diff --git a/packages/lexical-code-core/src/CodeNode.ts b/packages/lexical-code-core/src/CodeNode.ts index ddf8e69e0f8..04755f10520 100644 --- a/packages/lexical-code-core/src/CodeNode.ts +++ b/packages/lexical-code-core/src/CodeNode.ts @@ -470,7 +470,8 @@ export class CodeNode extends ElementNode { * `true` when this code block soft wraps lines that are wider than it, * instead of scrolling sideways. Wrapping is visual only: no LineBreakNode * is added and the text is unchanged. The theme does the wrapping, with CSS - * on the `data-lexical-code-word-wrap` attribute (see the package README). + * on the `data-lexical-code-word-wrap` attribute (see the + * `@lexical/code-core` README). */ getWordWrap(): boolean { return this.getLatest().__wordWrap === true; @@ -512,7 +513,7 @@ export function $isCodeNode( /** * @internal Creates the CodeNode for an imported
 or multi line 
- * from the attributes exportDOM writes, so none are dropped on an HTML
+ * from the data attributes exportDOM writes, so none are dropped on an HTML
  * round trip. Shared with CodeImportExtension.
  */
 export function $createCodeNodeFromDOM(element: HTMLElement): CodeNode {

From fe1757d6f2bc2abcce9e55acb386c9974546e126 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Thu, 29 Oct 2026 17:35:09 +0000
Subject: [PATCH 14/31] [lexical-code-core][lexical-playground] Documentation
 Update: Describe the line number markup the same way everywhere

## Description

The extension's JSDoc, the README's opening sentence, a playground theme comment and an e2e comment still described the markup as one element per line, or the wrapper as starting its line. They now say what the extension renders: line 1 is numbered on the code element and each later line on the wrapper of the line break that ends the line before it.

Co-Authored-By: Claude Opus 5.5 
---
 packages/lexical-code-core/README.md                       | 4 ++--
 packages/lexical-code-core/src/CodeLineNumbers.ts          | 7 ++++---
 .../__tests__/e2e/CodeBlockCaretScroll.spec.mjs            | 3 ++-
 .../src/themes/PlaygroundEditorTheme.css                   | 2 +-
 4 files changed, 9 insertions(+), 7 deletions(-)

diff --git a/packages/lexical-code-core/README.md b/packages/lexical-code-core/README.md
index 38ebf771773..6a3a0585e26 100644
--- a/packages/lexical-code-core/README.md
+++ b/packages/lexical-code-core/README.md
@@ -12,8 +12,8 @@ functionality is still available in `@lexical/code`.
 attribute (`"1\n2\n3"`), which a theme can show with `content: attr(data-gutter)`. That gutter is a single block of
 text, so it can't follow a line that wraps.
 
-`CodeLineNumbersExtension`, which `@lexical/code` also exports, is an opt in alternative that gives a theme an element to
-hang the number of every logical line on:
+`CodeLineNumbersExtension`, which `@lexical/code` also exports, is an opt in alternative that marks where each logical line
+starts, so a theme can hang a number on it:
 
 - the DOM of every `CodeNode` gets `data-lexical-code-line-numbers`
 - the `
` of each `LineBreakNode` in a code block is wrapped in `` diff --git a/packages/lexical-code-core/src/CodeLineNumbers.ts b/packages/lexical-code-core/src/CodeLineNumbers.ts index 40a1b4c845c..9e5ad6d8f0c 100644 --- a/packages/lexical-code-core/src/CodeLineNumbers.ts +++ b/packages/lexical-code-core/src/CodeLineNumbers.ts @@ -164,9 +164,10 @@ function allLineNumberOverrides() { /** * @experimental * - * Gives every logical line of a code block its own element for a line - * number, so a theme can number the lines with a CSS counter instead of - * the `data-gutter` attribute. The numbers stay in step with the lines when + * Marks where each logical line of a code block starts, so a theme can + * number the lines with a CSS counter instead of the `data-gutter` + * attribute: line 1 on the code element, and each later line on the + * wrapper of the line break before it. The numbers stay in step with the lines when * a line wraps, and no text is added to the DOM, so copy, find and IME are * unaffected. * diff --git a/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs index 8a5f8a14363..16c3f45cd32 100644 --- a/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs @@ -180,7 +180,8 @@ test.describe('Code block caret scrolling', () => { await initialize({isCollab, page, ...settings}); await createCodeBlock(page, [LONG_LINE, INDENTED_LINE]); if (settings.isCodeLineNumbers) { - // Each line has its own number element. + // Line 1 is numbered by the code element, and line 2 by the + // wrapper of the line break before it. await expect(locate(page, CODE)).toHaveAttribute( 'data-lexical-code-line-numbers', 'true', diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css index ef1b5e7303f..3bab23a404d 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css @@ -220,7 +220,7 @@ /* * Per line numbers from CodeLineNumbersExtension. Line 1 is drawn by the code * element's :before and each following line by the :after of the span wrapped - * around the
that starts it, so a number stays with its line when the + * around the
that ends the line before it, so a number stays with its line when the * line wraps and empty lines get one too. Each number advances the line by * zero (negative start margin), so it sits in the same 52px the float gutter * above takes up and the text doesn't move. The background draws the gutter From 4005248e65eba9922e97cd4679d76ce1a19395a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:43:11 +0000 Subject: [PATCH 15/31] [lexical-code-core][lexical-code] Bug Fix: Match the Flow CodeNode accessors to TypeScript, and finish the line number markup wording ## Description The Flow declarations of `CodeNode` gained `getWordWrap`/`setWordWrap` returning `this`, but next to them `setLanguage` was still typed `(language: string): void` (TypeScript takes `string | null | undefined` and returns the node) and `getTheme`/`setTheme` were missing, so Flow callers couldn't chain `setLanguage(...).setWordWrap(...)`. Both Flow files now match TypeScript. Two JSDoc comments and one README sentence still spoke of "line number elements", which the extension doesn't create; they now say "line number markup". ## Test plan ### After ``` $ pnpm run flow Found 0 errors ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 2 ++ packages/lexical-code-core/README.md | 2 +- packages/lexical-code-core/flow/LexicalCodeCore.js.flow | 6 ++++-- packages/lexical-code-core/src/CodeLineNumbers.ts | 4 ++-- packages/lexical-code/flow/LexicalCode.js.flow | 6 ++++-- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index bbd1adb2bd9..ae28fdc3118 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -8,3 +8,5 @@ A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-lexical-code-word-wrap="true"` to its DOM and exported HTML, and HTML import reads the attribute back; the key and the attribute are left out while it is off. The package adds no CSS: the theme wraps the lines under the attribute. `CodeLineNumbersExtension` sets `data-lexical-code-line-numbers` on each code block and wraps the `
` of each of its line breaks in a ``, so a theme can draw the numbers with a CSS counter. Exported and copied HTML is unchanged. Set `onlyWordWrapped` to limit this to blocks with word wrap on. The `@lexical/code-core` README has the CSS recipe. + +The Flow types of `CodeNode` now match TypeScript for the neighbouring accessors too: `setLanguage` takes a nullable language and returns the node, and `getTheme`/`setTheme` are declared. diff --git a/packages/lexical-code-core/README.md b/packages/lexical-code-core/README.md index 6a3a0585e26..8e9909d3e8e 100644 --- a/packages/lexical-code-core/README.md +++ b/packages/lexical-code-core/README.md @@ -46,7 +46,7 @@ The numbers drawn by the wrappers stay. The playground theme has this limitation The extension can be switched off at runtime through its `disabled` signal, which puts back exactly the DOM a code block has without it. -Set `onlyWordWrapped` to give line number elements only to code blocks with word wrap on (see below). The other blocks +Set `onlyWordWrapped` to give the line number markup only to code blocks with word wrap on (see below). The other blocks render exactly as they do without the extension, so a theme can keep numbering them with `data-gutter`. It can also be switched at runtime through the `onlyWordWrapped` signal. diff --git a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow index 41d1b9d7665..03e1fc78161 100644 --- a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow +++ b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow @@ -108,8 +108,10 @@ declare export class CodeNode extends ElementNode { restoreSelection?: boolean, ): null | ParagraphNode | CodeHighlightNode | TabNode; collapseAtStart(): true; - setLanguage(language: string): void; - getLanguage(): string | void; + setLanguage(language: ?string): this; + getLanguage(): ?string; + setTheme(theme: ?string): this; + getTheme(): string | void; getWordWrap(): boolean; setWordWrap(valueOrUpdater: ValueOrUpdater): this; } diff --git a/packages/lexical-code-core/src/CodeLineNumbers.ts b/packages/lexical-code-core/src/CodeLineNumbers.ts index 9e5ad6d8f0c..85f0cba366d 100644 --- a/packages/lexical-code-core/src/CodeLineNumbers.ts +++ b/packages/lexical-code-core/src/CodeLineNumbers.ts @@ -38,7 +38,7 @@ export interface CodeLineNumbersConfig { disabled: boolean; /** * When `true`, only code blocks with word wrap on - * ({@link CodeNode.getWordWrap}) get line number elements. Other blocks + * ({@link CodeNode.getWordWrap}) get the line number markup. Other blocks * render exactly as they do without the extension, so a theme can keep * numbering them with `data-gutter`, which can't follow wrapped rows. * Can be flipped at runtime through the `onlyWordWrapped` output signal. @@ -46,7 +46,7 @@ export interface CodeLineNumbersConfig { onlyWordWrapped: boolean; } -/** Set on the DOM of every CodeNode that has line number elements. */ +/** Set on the DOM of every CodeNode that has the line number markup. */ const CODE_LINE_NUMBERS_ATTR = 'data-lexical-code-line-numbers'; /** Set on the `` wrapped around the `
` of a code LineBreakNode. */ const CODE_LINE_BREAK_ATTR = 'data-lexical-code-line-break'; diff --git a/packages/lexical-code/flow/LexicalCode.js.flow b/packages/lexical-code/flow/LexicalCode.js.flow index e2a1faf74cf..e08260791e6 100644 --- a/packages/lexical-code/flow/LexicalCode.js.flow +++ b/packages/lexical-code/flow/LexicalCode.js.flow @@ -103,8 +103,10 @@ declare export class CodeNode extends ElementNode { restoreSelection?: boolean, ): null | ParagraphNode | CodeHighlightNode | TabNode; collapseAtStart(): true; - setLanguage(language: string): void; - getLanguage(): string | void; + setLanguage(language: ?string): this; + getLanguage(): ?string; + setTheme(theme: ?string): this; + getTheme(): string | void; getWordWrap(): boolean; setWordWrap(valueOrUpdater: ValueOrUpdater): this; } From 5a6eef7267cfc7d0f3551a1ede6a5b6f045b151e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:50:29 +0000 Subject: [PATCH 16/31] [lexical-code-core][lexical-code] Bug Fix: Declare the code block theme in the Flow types, and keep getLanguage's Flow type ## Description The previous commit widened the Flow return type of `CodeNode.getLanguage` to `?string`, which could break Flow callers typed against `string | void`, and the language is never stored as `null`. That is reverted. The Flow types also now declare the `theme` argument of `$createCodeNode` and the `theme` key of `SerializedCodeNode`, which TypeScript already has. Also rewraps a JSDoc paragraph and hyphenates "opt-in" in the README. ## Test plan ### After ``` $ pnpm run flow Found 0 errors ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 2 +- packages/lexical-code-core/README.md | 4 ++-- packages/lexical-code-core/flow/LexicalCodeCore.js.flow | 8 ++++++-- packages/lexical-code-core/src/CodeLineNumbers.ts | 6 +++--- packages/lexical-code/flow/LexicalCode.js.flow | 8 ++++++-- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index ae28fdc3118..908ec803393 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -9,4 +9,4 @@ A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-l `CodeLineNumbersExtension` sets `data-lexical-code-line-numbers` on each code block and wraps the `
` of each of its line breaks in a ``, so a theme can draw the numbers with a CSS counter. Exported and copied HTML is unchanged. Set `onlyWordWrapped` to limit this to blocks with word wrap on. The `@lexical/code-core` README has the CSS recipe. -The Flow types of `CodeNode` now match TypeScript for the neighbouring accessors too: `setLanguage` takes a nullable language and returns the node, and `getTheme`/`setTheme` are declared. +The Flow types now declare the theme next to the language as TypeScript does: `CodeNode.getTheme`/`setTheme`, the `theme` argument of `$createCodeNode` and the `theme` key of `SerializedCodeNode`. `CodeNode.setLanguage` takes a nullable language and returns the node. diff --git a/packages/lexical-code-core/README.md b/packages/lexical-code-core/README.md index 8e9909d3e8e..2f84e4efac1 100644 --- a/packages/lexical-code-core/README.md +++ b/packages/lexical-code-core/README.md @@ -12,8 +12,8 @@ functionality is still available in `@lexical/code`. attribute (`"1\n2\n3"`), which a theme can show with `content: attr(data-gutter)`. That gutter is a single block of text, so it can't follow a line that wraps. -`CodeLineNumbersExtension`, which `@lexical/code` also exports, is an opt in alternative that marks where each logical line -starts, so a theme can hang a number on it: +`CodeLineNumbersExtension`, which `@lexical/code` also exports, is an opt-in alternative that marks where each logical +line starts, so a theme can hang a number on it: - the DOM of every `CodeNode` gets `data-lexical-code-line-numbers` - the `
` of each `LineBreakNode` in a code block is wrapped in `` diff --git a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow index 03e1fc78161..3477870fa4f 100644 --- a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow +++ b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow @@ -89,11 +89,15 @@ declare export function $plainifyCodeContent(text: string): LexicalNode[]; export type SerializedCodeNode = { ...SerializedElementNode, language: string | null | void, + theme?: string, wordWrap?: boolean, ... }; -declare export function $createCodeNode(language: ?string): CodeNode; +declare export function $createCodeNode( + language?: ?string, + theme?: ?string, +): CodeNode; declare export function $isCodeNode( node: ?LexicalNode, @@ -109,7 +113,7 @@ declare export class CodeNode extends ElementNode { ): null | ParagraphNode | CodeHighlightNode | TabNode; collapseAtStart(): true; setLanguage(language: ?string): this; - getLanguage(): ?string; + getLanguage(): string | void; setTheme(theme: ?string): this; getTheme(): string | void; getWordWrap(): boolean; diff --git a/packages/lexical-code-core/src/CodeLineNumbers.ts b/packages/lexical-code-core/src/CodeLineNumbers.ts index 85f0cba366d..963fa84c9bd 100644 --- a/packages/lexical-code-core/src/CodeLineNumbers.ts +++ b/packages/lexical-code-core/src/CodeLineNumbers.ts @@ -167,9 +167,9 @@ function allLineNumberOverrides() { * Marks where each logical line of a code block starts, so a theme can * number the lines with a CSS counter instead of the `data-gutter` * attribute: line 1 on the code element, and each later line on the - * wrapper of the line break before it. The numbers stay in step with the lines when - * a line wraps, and no text is added to the DOM, so copy, find and IME are - * unaffected. + * wrapper of the line break before it. The numbers stay in step with the + * lines when a line wraps, and no text is added to the DOM, so copy, find + * and IME are unaffected. * * While enabled: * - the DOM of every `CodeNode` gets `data-lexical-code-line-numbers`, where diff --git a/packages/lexical-code/flow/LexicalCode.js.flow b/packages/lexical-code/flow/LexicalCode.js.flow index e08260791e6..5d8b2b1b34e 100644 --- a/packages/lexical-code/flow/LexicalCode.js.flow +++ b/packages/lexical-code/flow/LexicalCode.js.flow @@ -84,11 +84,15 @@ declare export function $getStartOfCodeInLine( export type SerializedCodeNode = { ...SerializedElementNode, language: string | null | void, + theme?: string, wordWrap?: boolean, ... }; -declare export function $createCodeNode(language: ?string): CodeNode; +declare export function $createCodeNode( + language?: ?string, + theme?: ?string, +): CodeNode; declare export function $isCodeNode( node: ?LexicalNode, @@ -104,7 +108,7 @@ declare export class CodeNode extends ElementNode { ): null | ParagraphNode | CodeHighlightNode | TabNode; collapseAtStart(): true; setLanguage(language: ?string): this; - getLanguage(): ?string; + getLanguage(): string | void; setTheme(theme: ?string): this; getTheme(): string | void; getWordWrap(): boolean; From 9b8c8d40f7658f1f1a235bbf728230e14b62d014 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:59:32 +0000 Subject: [PATCH 17/31] [lexical-code-core] Documentation Update: Restyle rather than hide an old data-gutter pseudo element under line numbers ## Description The README and the extension's JSDoc said to hide an existing `attr(data-gutter)` gutter under `[data-lexical-code-line-numbers]`. A theme usually draws that gutter with the code element's `::before`, which the line numbers recipe uses for line 1, so hiding it drops line 1's number. Both now say to override the gutter's content and placement there instead, as the playground theme does. Co-Authored-By: Claude Opus 5.5 --- packages/lexical-code-core/README.md | 6 ++++-- packages/lexical-code-core/src/CodeLineNumbers.ts | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/lexical-code-core/README.md b/packages/lexical-code-core/README.md index 2f84e4efac1..abd5d0664a3 100644 --- a/packages/lexical-code-core/README.md +++ b/packages/lexical-code-core/README.md @@ -37,8 +37,10 @@ The numbers are generated content, so they never end up in the editor's text, th the wrappers: the default `exportDOM` of a `LineBreakNode` goes through the same `$createDOM` overrides, so the extension leaves the `
` unwrapped while `RenderContextExport` is set. Exported and copied HTML is the same as without the extension, including what other `$createDOM` overrides do to the `
`. The highlighters keep writing `data-gutter` -while the extension is enabled, so hide an existing `attr(data-gutter)` gutter under `[data-lexical-code-line-numbers]`. -The playground theme (`PlaygroundEditorTheme.css`) has a complete example. +while the extension is enabled. If a theme draws an `attr(data-gutter)` gutter with the code element's `::before`, which +now draws line 1's number, override that gutter's `content` and placement (such as a float or full height) under +`[data-lexical-code-line-numbers]` rather than hiding the pseudo element. The playground theme +(`PlaygroundEditorTheme.css`) has a complete example. WebKit does not draw line 1's number, the code element's `::before`, while a right to left block is scrolled sideways. The numbers drawn by the wrappers stay. The playground theme has this limitation too. diff --git a/packages/lexical-code-core/src/CodeLineNumbers.ts b/packages/lexical-code-core/src/CodeLineNumbers.ts index 963fa84c9bd..2d323ae3feb 100644 --- a/packages/lexical-code-core/src/CodeLineNumbers.ts +++ b/packages/lexical-code-core/src/CodeLineNumbers.ts @@ -198,9 +198,10 @@ function allLineNumberOverrides() { * then recreates that block's DOM. * * The `data-gutter` attribute written by the highlighter extensions is not - * touched, so a theme that wants per line numbers has to hide its old - * gutter under `[data-lexical-code-line-numbers]`. See the - * `@lexical/code-core` README for a CSS recipe. + * touched, so a theme that draws it in the code element's `::before` has + * to restyle that pseudo element under `[data-lexical-code-line-numbers]`, + * where it draws line 1's number instead. See the `@lexical/code-core` + * README for a CSS recipe. */ export const CodeLineNumbersExtension = defineExtension({ build: (editor, config) => namedSignals(config), From c00a30dfb1291f49be1c54269344e2c512bdb1fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 18:27:20 +0000 Subject: [PATCH 18/31] [lexical-code] Chore: Expect the wordWrap key in CodeNode's exportJSON shape test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description With word wrap a schema field, `CodeNode.exportJSON()` has an own `wordWrap` key (`undefined` while off), like `theme`, and the existing shape test in `@lexical/code` compared the object with `toStrictEqual` without it. The test now expects the key, and the changeset says so under `Verify:`. ## Test plan ### Before ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts × CodeNode.exportJSON() should return and object conforming to the expected schema + "wordWrap": undefined, ``` ### After ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts Tests 57 passed | 1 skipped (58) ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 2 ++ .../lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index 908ec803393..b75ae1f034f 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -7,6 +7,8 @@ A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-lexical-code-word-wrap="true"` to its DOM and exported HTML, and HTML import reads the attribute back; the key and the attribute are left out while it is off. The package adds no CSS: the theme wraps the lines under the attribute. +Verify: `CodeNode.exportJSON()` now has an own `wordWrap` key on every code block, `undefined` while word wrap is off, as it already has for `theme`. Serialized JSON is unchanged, but a test that compares the returned object with `toStrictEqual` needs the key. + `CodeLineNumbersExtension` sets `data-lexical-code-line-numbers` on each code block and wraps the `
` of each of its line breaks in a ``, so a theme can draw the numbers with a CSS counter. Exported and copied HTML is unchanged. Set `onlyWordWrapped` to limit this to blocks with word wrap on. The `@lexical/code-core` README has the CSS recipe. The Flow types now declare the theme next to the language as TypeScript does: `CodeNode.getTheme`/`setTheme`, the `theme` argument of `$createCodeNode` and the `theme` key of `SerializedCodeNode`. `CodeNode.setLanguage` takes a nullable language and returns the node. diff --git a/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts b/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts index 4a628b14a09..d0a6ce6e4fa 100644 --- a/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts +++ b/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts @@ -131,6 +131,7 @@ describe('LexicalCodeNode tests', () => { theme: undefined, type: 'code', version: 1, + wordWrap: undefined, }); }); }); From e3aacabe527b6114f47378d78edda882d3ccba43 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:32:07 +0000 Subject: [PATCH 19/31] [lexical-code-prism][lexical-code-shiki] Bug Fix: Keep a caret after a line break on its line when an earlier line is retokenized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When the highlighters retokenize a code block, `$updateAndRetainSelection` converts a text caret to a text offset and back, but an element caret right after a line break was restored by its child index. If the retokenize split an earlier line into more tokens (Shiki's grammar landing just after Enter, or an Enter that splits a token), that index pointed into the earlier line, and the next characters typed went there. An element caret elsewhere added the code block's previous siblings' text to its child index. Element carets are now converted to a text offset like text carets, by summing the text of the children before them, and restored by the same walk. ## Test plan ### Before ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts × keeps an element caret on its line when an earlier line is split: ["const a = 1","\n","b"] at 2 - const a = 1 + const Xa = 1 - Xb + b Tests 6 failed | 8 passed (14) ``` ### After ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts Tests 14 passed (14) ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 4 ++ .../src/CodeHighlighterPrism.ts | 42 ++++++---------- ...odeHighlighterPrismRetainSelection.test.ts | 45 +++++++++++++++++ .../src/CodeHighlighterShiki.ts | 42 ++++++---------- .../unit/CodeShikiRetainSelection.test.ts | 50 +++++++++++++++++++ 5 files changed, 129 insertions(+), 54 deletions(-) diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index b75ae1f034f..baa182f10f2 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -1,6 +1,8 @@ --- '@lexical/code-core': patch '@lexical/code': patch +'@lexical/code-prism': patch +'@lexical/code-shiki': patch --- [feature] Add a word wrap flag to `CodeNode` (`setWordWrap`/`getWordWrap`) and an experimental `CodeLineNumbersExtension` that marks where each logical line of a code block starts, so a theme can draw line numbers that follow wrapped lines @@ -12,3 +14,5 @@ Verify: `CodeNode.exportJSON()` now has an own `wordWrap` key on every code bloc `CodeLineNumbersExtension` sets `data-lexical-code-line-numbers` on each code block and wraps the `
` of each of its line breaks in a ``, so a theme can draw the numbers with a CSS counter. Exported and copied HTML is unchanged. Set `onlyWordWrapped` to limit this to blocks with word wrap on. The `@lexical/code-core` README has the CSS recipe. The Flow types now declare the theme next to the language as TypeScript does: `CodeNode.getTheme`/`setTheme`, the `theme` argument of `$createCodeNode` and the `theme` key of `SerializedCodeNode`. `CodeNode.setLanguage` takes a nullable language and returns the node. + +The Prism and Shiki highlighters no longer move a caret that sits right after a line break into an earlier line when they split that line into more tokens (for example when Shiki's grammar finishes loading just after Enter), so the next characters typed land on the right line. diff --git a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts index a9913b87d46..a14993218ea 100644 --- a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts +++ b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts @@ -209,39 +209,27 @@ function $updateAndRetainSelection( return; } - const anchorOffset = anchor.offset; - const isNewLineAnchor = - anchor.type === 'element' && - $isLineBreakNode(node.getChildAtIndex(anchor.offset - 1)); - let textOffset = 0; - - // Calculating previous text offset (all text node prior to anchor + anchor own text offset) - if (!isNewLineAnchor) { - textOffset = - anchorOffset + - anchorNode.getPreviousSiblings().reduce((offset, _node) => { - return offset + _node.getTextContentSize(); - }, 0); - } + // The text offset of the anchor in the code block: the text of everything + // before it, plus its offset in its own text node. An element point (on the + // code node, e.g. after a line break) is a child index, which goes stale + // once an earlier line is split into more tokens, so it is converted too. + const textOffsetBefore = (nodes: LexicalNode[]) => + nodes.reduce((offset, child) => offset + child.getTextContentSize(), 0); + let textOffset = + anchor.type === 'element' + ? textOffsetBefore(node.getChildren().slice(0, anchor.offset)) + : anchor.offset + textOffsetBefore(anchorNode.getPreviousSiblings()); const hasChanges = updateFn(); if (!hasChanges) { return; } - // Non-text anchors only happen for line breaks, otherwise - // selection will be within text node (code highlight node) - if (isNewLineAnchor) { - anchor.getNode().select(anchorOffset, anchorOffset); - return; - } - - // If it was non-element anchor then we walk through child nodes - // 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. + // Walk through the child nodes looking for the 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]; diff --git a/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts index 140e3cdfc17..88861af3406 100644 --- a/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts +++ b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts @@ -9,10 +9,12 @@ import {$createCodeNode} from '@lexical/code'; import {registerCodeHighlighting} from '@lexical/code-prism'; import { + $createLineBreakNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection, + type ElementNode, } from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; import {describe, expect, test} from 'vitest'; @@ -67,5 +69,48 @@ describe('CodeHighlighterPrism $updateAndRetainSelection', () => { ).toBe(expectedText); }, ); + + // An element point after a line break is a child index, which goes + // stale when the highlighter splits an earlier line into more tokens. + // The caret must stay on its own line. + test.each([ + [['const a = 1', '\n', 'b'], 2, 'const a = 1\nXb'], + [['const a = 1', '\n'], 2, 'const a = 1\nX'], + [['const a = 1', '\n', '\n', 'b'], 3, 'const a = 1\n\nXb'], + ])( + 'keeps an element caret on its line when an earlier line is split: %j at %i', + async (parts, offset, expectedText) => { + const {editor} = testEnv; + registerCodeHighlighting(editor); + + await editor.update(() => { + const code = $createCodeNode('javascript'); + $getRoot().clear().append(code); + code.append( + ...parts.map(part => + part === '\n' ? $createLineBreakNode() : $createTextNode(part), + ), + ); + code.select(offset, offset); + }); + expect( + editor.read(() => + $getRoot().getFirstChildOrThrow().getChildrenSize(), + ), + ).toBeGreaterThan(parts.length); + + 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-shiki/src/CodeHighlighterShiki.ts b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts index 5dad4045bd5..031680add34 100644 --- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts +++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts @@ -239,39 +239,27 @@ function $updateAndRetainSelection( return; } - const anchorOffset = anchor.offset; - const isNewLineAnchor = - anchor.type === 'element' && - $isLineBreakNode(node.getChildAtIndex(anchor.offset - 1)); - let textOffset = 0; - - // Calculating previous text offset (all text node prior to anchor + anchor own text offset) - if (!isNewLineAnchor) { - textOffset = - anchorOffset + - anchorNode.getPreviousSiblings().reduce((offset, _node) => { - return offset + _node.getTextContentSize(); - }, 0); - } + // The text offset of the anchor in the code block: the text of everything + // before it, plus its offset in its own text node. An element point (on the + // code node, e.g. after a line break) is a child index, which goes stale + // once an earlier line is split into more tokens, so it is converted too. + const textOffsetBefore = (nodes: LexicalNode[]) => + nodes.reduce((offset, child) => offset + child.getTextContentSize(), 0); + let textOffset = + anchor.type === 'element' + ? textOffsetBefore(node.getChildren().slice(0, anchor.offset)) + : anchor.offset + textOffsetBefore(anchorNode.getPreviousSiblings()); const hasChanges = updateFn(); if (!hasChanges) { return; } - // Non-text anchors only happen for line breaks, otherwise - // selection will be within text node (code highlight node) - if (isNewLineAnchor) { - anchor.getNode().select(anchorOffset, anchorOffset); - return; - } - - // If it was non-element anchor then we walk through child nodes - // 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. + // Walk through the child nodes looking for the 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]; diff --git a/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts index 74d932103c3..83f78fb2c32 100644 --- a/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts +++ b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts @@ -19,12 +19,14 @@ import {buildEditorFromExtensions} from '@lexical/extension'; import {$convertFromMarkdownString, TRANSFORMERS} from '@lexical/markdown'; import {RichTextExtension} from '@lexical/rich-text'; import { + $createLineBreakNode, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection, defineExtension, + type ElementNode, } from 'lexical'; import {beforeAll, describe, expect, test} from 'vitest'; @@ -98,6 +100,54 @@ describe('CodeHighlighterShiki $updateAndRetainSelection', () => { ).toBe(expectedText); }, ); + + // An element point after a line break is a child index, which goes stale + // when the highlighter splits an earlier line into more tokens, e.g. when + // the grammar loads after Enter. The caret must stay on its own line. + test.each([ + [['const a = 1', '\n', 'b'], 2, 'const a = 1\nXb'], + [['const a = 1', '\n'], 2, 'const a = 1\nX'], + [['const a = 1', '\n', '\n', 'b'], 3, 'const a = 1\n\nXb'], + ])( + 'keeps an element caret on its line when an earlier line is split: %j at %i', + (parts, offset, expectedText) => { + using editor = createEditor(); + + editor.update( + () => { + const code = $createCodeNode('javascript'); + $getRoot().clear().append(code); + code.append( + ...parts.map(part => + part === '\n' ? $createLineBreakNode() : $createTextNode(part), + ), + ); + code.select(offset, offset); + }, + {discrete: true}, + ); + expect( + editor.read(() => + $getRoot().getFirstChildOrThrow().getChildrenSize(), + ), + ).toBeGreaterThan(parts.length); + + 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); + }, + ); }); /** From 99584d2ffd8a86560902b36dc80c4cf0838b9aa6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:35:37 +0000 Subject: [PATCH 20/31] [lexical-code-prism][lexical-code-shiki] Chore: Type the code block lookup in the retain-selection tests ## Description The new retain-selection tests read the code block's child count through `getFirstChildOrThrow()`, which `RootNode` doesn't take a type argument for, so `tsc -p ./tsconfig.test.json` failed. They now narrow with `$isElementNode`. ## Test plan ### After ``` $ ./node_modules/.bin/tsc -p ./tsconfig.test.json $ ./node_modules/.bin/vitest run --project unit packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts Tests 14 passed (14) ``` Co-Authored-By: Claude Opus 5.5 --- .../unit/CodeHighlighterPrismRetainSelection.test.ts | 9 +++++---- .../src/__tests__/unit/CodeShikiRetainSelection.test.ts | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts index 88861af3406..9304b5c7d48 100644 --- a/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts +++ b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts @@ -13,8 +13,8 @@ import { $createTextNode, $getRoot, $getSelection, + $isElementNode, $isRangeSelection, - type ElementNode, } from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; import {describe, expect, test} from 'vitest'; @@ -94,9 +94,10 @@ describe('CodeHighlighterPrism $updateAndRetainSelection', () => { code.select(offset, offset); }); expect( - editor.read(() => - $getRoot().getFirstChildOrThrow().getChildrenSize(), - ), + editor.read(() => { + const code = $getRoot().getFirstChild(); + return $isElementNode(code) ? code.getChildrenSize() : 0; + }), ).toBeGreaterThan(parts.length); await editor.update(() => { diff --git a/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts index 83f78fb2c32..0a3e8c84227 100644 --- a/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts +++ b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts @@ -24,9 +24,9 @@ import { $createTextNode, $getRoot, $getSelection, + $isElementNode, $isRangeSelection, defineExtension, - type ElementNode, } from 'lexical'; import {beforeAll, describe, expect, test} from 'vitest'; @@ -127,9 +127,10 @@ describe('CodeHighlighterShiki $updateAndRetainSelection', () => { {discrete: true}, ); expect( - editor.read(() => - $getRoot().getFirstChildOrThrow().getChildrenSize(), - ), + editor.read(() => { + const code = $getRoot().getFirstChild(); + return $isElementNode(code) ? code.getChildrenSize() : 0; + }), ).toBeGreaterThan(parts.length); editor.update( From 450a5ff3afabbb648f4e0511d3d14214cae4b55e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:35:40 +0000 Subject: [PATCH 21/31] [lexical-code-core][lexical-code][lexical-playground] Refactor: Make setWordWrap a plain boolean setter ## Description `CodeNode.setWordWrap` took a value or an updater and skipped marking the node dirty when the value didn't change. Its only updater caller was the playground's Wrap button, so it is now a plain `setWordWrap(wordWrap: boolean): this` like `setTheme`, and the button passes `!codeNode.getWordWrap()`. The README, Flow types and tests drop the updater form. ## Test plan ### After ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code-core packages/lexical-code packages/lexical-playground/__tests__/unit Tests 658 passed | 1 skipped (659) $ pnpm run flow Found 0 errors ``` Co-Authored-By: Claude Opus 5.5 --- packages/lexical-code-core/README.md | 3 +-- .../flow/LexicalCodeCore.js.flow | 3 +-- packages/lexical-code-core/src/CodeNode.ts | 20 ++++----------- .../src/__tests__/unit/CodeNode.test.ts | 25 ++----------------- .../lexical-code/flow/LexicalCode.js.flow | 3 +-- .../components/WrapButton/index.tsx | 2 +- 6 files changed, 11 insertions(+), 45 deletions(-) diff --git a/packages/lexical-code-core/README.md b/packages/lexical-code-core/README.md index abd5d0664a3..8e0a18df225 100644 --- a/packages/lexical-code-core/README.md +++ b/packages/lexical-code-core/README.md @@ -55,8 +55,7 @@ switched at runtime through the `onlyWordWrapped` signal. ## Word wrap `CodeNode.setWordWrap(true)` makes a code block soft wrap lines that are wider than it, instead of scrolling sideways, -and `getWordWrap()` reads it back. `setWordWrap` also takes an updater, for example `wordWrap => !wordWrap`. Wrapping is -visual only: no `LineBreakNode` is added and the text is unchanged. +and `getWordWrap()` reads it back. Wrapping is visual only: no `LineBreakNode` is added and the text is unchanged. While it is on: diff --git a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow index 3477870fa4f..cfa685ab959 100644 --- a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow +++ b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow @@ -21,7 +21,6 @@ import type { TabNode, LexicalExtension, ExtensionConfigBase, - ValueOrUpdater, } from 'lexical'; import type {NamedSignalsOutput} from '@lexical/extension'; @@ -117,7 +116,7 @@ declare export class CodeNode extends ElementNode { setTheme(theme: ?string): this; getTheme(): string | void; getWordWrap(): boolean; - setWordWrap(valueOrUpdater: ValueOrUpdater): this; + setWordWrap(wordWrap: boolean): this; } /** diff --git a/packages/lexical-code-core/src/CodeNode.ts b/packages/lexical-code-core/src/CodeNode.ts index 04755f10520..f3de1f8d287 100644 --- a/packages/lexical-code-core/src/CodeNode.ts +++ b/packages/lexical-code-core/src/CodeNode.ts @@ -42,7 +42,6 @@ import { type Spread, stringValue, type TabNode, - type ValueOrUpdater, withAccessors, withField, } from 'lexical'; @@ -478,22 +477,13 @@ export class CodeNode extends ElementNode { } /** - * Turns soft wrapping of long lines on or off, or toggles it with an - * updater such as `wordWrap => !wordWrap`. The node is only marked dirty - * when the value changes. This only sets the flag and the - * `data-lexical-code-word-wrap` attribute; the theme's CSS wraps the lines. + * Turns soft wrapping of long lines on or off. This only sets the flag and + * the `data-lexical-code-word-wrap` attribute; the theme's CSS wraps the + * lines. */ - setWordWrap(valueOrUpdater: ValueOrUpdater): this { - const prevWordWrap = this.getWordWrap(); - const wordWrap = - (typeof valueOrUpdater === 'function' - ? valueOrUpdater(prevWordWrap) - : valueOrUpdater) === true; - if (wordWrap === prevWordWrap) { - return this; - } + setWordWrap(wordWrap: boolean): this { const writable = this.getWritable(); - writable.__wordWrap = wordWrap || undefined; + writable.__wordWrap = wordWrap === true || undefined; return writable; } } 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 8e0b74f4f45..1187e8b694d 100644 --- a/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts +++ b/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts @@ -21,7 +21,6 @@ import { type EditorConfig, type LexicalEditor, type NodeKey, - type NodeMutation, } from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; import {assert, describe, expect, it} from 'vitest'; @@ -62,13 +61,13 @@ function $importCodeJSON(json: SerializedCodeNode): CodeNode { function setWordWrap( editor: LexicalEditor, key: NodeKey, - valueOrUpdater: boolean | ((wordWrap: boolean) => boolean), + wordWrap: boolean, ): void { editor.update( () => { const node = $getNodeByKey(key); assert($isCodeNode(node), 'expected a CodeNode'); - node.setWordWrap(valueOrUpdater); + node.setWordWrap(wordWrap); }, {discrete: true}, ); @@ -247,26 +246,6 @@ describe('CodeNode', () => { expect(dom.textContent).toBe('ab'); }); - it('an updater that keeps the value leaves the node clean', () => { - const {editor} = testEnv; - const key = appendTwoLineCode(editor); - const mutations: NodeMutation[] = []; - const unregister = editor.registerMutationListener( - CodeNode, - nodes => { - mutations.push(...nodes.values()); - }, - {skipInitialization: true}, - ); - - setWordWrap(editor, key, wordWrap => wordWrap); - expect(mutations).toEqual([]); - - setWordWrap(editor, key, wordWrap => !wordWrap); - expect(mutations).toEqual(['updated']); - unregister(); - }); - it('round trips through exportDOM and importDOM', () => { const {editor} = testEnv; const key = appendTwoLineCode(editor); diff --git a/packages/lexical-code/flow/LexicalCode.js.flow b/packages/lexical-code/flow/LexicalCode.js.flow index 5d8b2b1b34e..0b8f7676f8d 100644 --- a/packages/lexical-code/flow/LexicalCode.js.flow +++ b/packages/lexical-code/flow/LexicalCode.js.flow @@ -20,7 +20,6 @@ import type { TabNode, LexicalExtension, ExtensionConfigBase, - ValueOrUpdater, } from 'lexical'; import type {NamedSignalsOutput} from '@lexical/extension'; @@ -112,7 +111,7 @@ declare export class CodeNode extends ElementNode { setTheme(theme: ?string): this; getTheme(): string | void; getWordWrap(): boolean; - setWordWrap(valueOrUpdater: ValueOrUpdater): this; + setWordWrap(wordWrap: boolean): this; } declare export var CodeExtension: LexicalExtension; diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WrapButton/index.tsx b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WrapButton/index.tsx index ecea0f2efdb..3422d2ef27c 100644 --- a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WrapButton/index.tsx +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WrapButton/index.tsx @@ -25,7 +25,7 @@ export function WrapButton({editor, getCodeKey, isWrapped}: Props) { editor.update(() => { const codeNode = $getNodeByKey(key); if ($isCodeNode(codeNode)) { - codeNode.setWordWrap(wordWrap => !wordWrap); + codeNode.setWordWrap(!codeNode.getWordWrap()); } }); } From 7f0b24cf3d846b68e37f8ac7a783e5054286e48b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:36:06 +0000 Subject: [PATCH 22/31] [lexical-playground] Chore: Drop the e2e wait for Shiki tokens before Enter ## Description The code block e2e specs waited for Shiki to split the first line into tokens before pressing Enter, because a retokenize landing after Enter put the caret back by child index and the next line was typed into the first one. The highlighters now restore that caret by text offset, so the wait and its helper are gone. ## Test plan ### After ``` $ E2E_PORT=4310 ./node_modules/.bin/playwright test --project=chromium packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs ``` Co-Authored-By: Claude Opus 5.5 --- .../__tests__/e2e/CodeBlockCaretScroll.spec.mjs | 16 ---------------- .../__tests__/e2e/CodeBlockWordWrap.spec.mjs | 16 ---------------- 2 files changed, 32 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs index 16c3f45cd32..9e5cae30a3b 100644 --- a/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs @@ -45,26 +45,10 @@ async function createCodeBlock(page, lines) { if (lines[i] !== '') { await page.keyboard.insertText(lines[i]); } - if (i === 0) { - // Shiki loads the grammar and the theme asynchronously, and only then - // splits the text into tokens. When that lands after an Enter, the - // caret is put back by child index rather than by text offset, and - // the next line is typed into the first one. So wait for the tokens. - await expect.poll(() => countCodeChildren(page)).toBeGreaterThan(1); - } } await expect.poll(() => getCodeLines(page)).toEqual(lines); } -/** How many nodes the first code block has, from the editor state. */ -async function countCodeChildren(page) { - return await evaluate(page, () => { - const code = window.lexicalEditor.getEditorState().toJSON().root - .children[0]; - return code.type === 'code' ? code.children.length : 0; - }); -} - /** The lines of the first code block, read from the editor state. */ async function getCodeLines(page) { return await evaluate(page, () => { diff --git a/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs index ed3a5dcc12b..1a8998cd221 100644 --- a/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs @@ -52,26 +52,10 @@ async function createCodeBlock(page, lines) { if (lines[i] !== '') { await page.keyboard.insertText(lines[i]); } - if (i === 0) { - // Shiki loads the grammar and the theme asynchronously, and only then - // splits the text into tokens. When that lands after an Enter, the - // caret is put back by child index rather than by text offset, and - // the next line is typed into the first one. So wait for the tokens. - await expect.poll(() => countCodeChildren(page)).toBeGreaterThan(1); - } } await expect.poll(() => getCodeLines(page)).toEqual(lines); } -/** How many nodes the first code block has, from the editor state. */ -async function countCodeChildren(page) { - return await evaluate(page, () => { - const code = window.lexicalEditor.getEditorState().toJSON().root - .children[0]; - return code.type === 'code' ? code.children.length : 0; - }); -} - /** The lines of the first code block, read from the editor state. */ async function getCodeLines(page) { return await evaluate(page, () => { From 6573075aed658937fe902e9d1c3fe39d66fe3515 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:44:29 +0000 Subject: [PATCH 23/31] [lexical-code-prism][lexical-code-shiki] Chore: Cover an element caret after earlier blocks, and move the changeset's Verify paragraph last MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The old retain-selection code also counted the text of the blocks before a code block toward an element caret that wasn't after a line break, so a caret at the start of a code block moved into it on retokenize. The new tests put a 50-character paragraph before the code block and add a caret at offset 0, which fail on the old code. The changeset mentions that fix, and its `Verify:` paragraph moves to the end so the changelog parser doesn't take the paragraphs after it as part of it. ## Test plan ### Before With `CodeHighlighterShiki.ts` from before the fix: ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts × keeps an element caret on its line when an earlier line is split: ["\n","const a = 1"] at 0 Tests 4 failed | 5 passed (9) ``` ### After ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts Tests 16 passed (16) ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 6 +++--- .../CodeHighlighterPrismRetainSelection.test.ts | 14 +++++++++++--- .../unit/CodeShikiRetainSelection.test.ts | 13 ++++++++++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index baa182f10f2..336c060f333 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -9,10 +9,10 @@ A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-lexical-code-word-wrap="true"` to its DOM and exported HTML, and HTML import reads the attribute back; the key and the attribute are left out while it is off. The package adds no CSS: the theme wraps the lines under the attribute. -Verify: `CodeNode.exportJSON()` now has an own `wordWrap` key on every code block, `undefined` while word wrap is off, as it already has for `theme`. Serialized JSON is unchanged, but a test that compares the returned object with `toStrictEqual` needs the key. - `CodeLineNumbersExtension` sets `data-lexical-code-line-numbers` on each code block and wraps the `
` of each of its line breaks in a ``, so a theme can draw the numbers with a CSS counter. Exported and copied HTML is unchanged. Set `onlyWordWrapped` to limit this to blocks with word wrap on. The `@lexical/code-core` README has the CSS recipe. The Flow types now declare the theme next to the language as TypeScript does: `CodeNode.getTheme`/`setTheme`, the `theme` argument of `$createCodeNode` and the `theme` key of `SerializedCodeNode`. `CodeNode.setLanguage` takes a nullable language and returns the node. -The Prism and Shiki highlighters no longer move a caret that sits right after a line break into an earlier line when they split that line into more tokens (for example when Shiki's grammar finishes loading just after Enter), so the next characters typed land on the right line. +The Prism and Shiki highlighters no longer move a caret that sits right after a line break into an earlier line when they split that line into more tokens (for example when Shiki's grammar finishes loading just after Enter), so the next characters typed land on the right line, and a caret at the start of a code block no longer moves by the length of the text before the block when it is retokenized. + +Verify: `CodeNode.exportJSON()` now has an own `wordWrap` key on every code block, `undefined` while word wrap is off, as it already has for `theme`. Serialized JSON is unchanged, but a test that compares the returned object with `toStrictEqual` needs the key. diff --git a/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts index 9304b5c7d48..a5615f1a690 100644 --- a/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts +++ b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts @@ -10,6 +10,7 @@ import {$createCodeNode} from '@lexical/code'; import {registerCodeHighlighting} from '@lexical/code-prism'; import { $createLineBreakNode, + $createParagraphNode, $createTextNode, $getRoot, $getSelection, @@ -77,6 +78,7 @@ describe('CodeHighlighterPrism $updateAndRetainSelection', () => { [['const a = 1', '\n', 'b'], 2, 'const a = 1\nXb'], [['const a = 1', '\n'], 2, 'const a = 1\nX'], [['const a = 1', '\n', '\n', 'b'], 3, 'const a = 1\n\nXb'], + [['\n', 'const a = 1'], 0, 'X\nconst a = 1'], ])( 'keeps an element caret on its line when an earlier line is split: %j at %i', async (parts, offset, expectedText) => { @@ -85,7 +87,13 @@ describe('CodeHighlighterPrism $updateAndRetainSelection', () => { await editor.update(() => { const code = $createCodeNode('javascript'); - $getRoot().clear().append(code); + // Text before the code block must not count toward the caret. + $getRoot() + .clear() + .append( + $createParagraphNode().append($createTextNode('x'.repeat(50))), + code, + ); code.append( ...parts.map(part => part === '\n' ? $createLineBreakNode() : $createTextNode(part), @@ -95,7 +103,7 @@ describe('CodeHighlighterPrism $updateAndRetainSelection', () => { }); expect( editor.read(() => { - const code = $getRoot().getFirstChild(); + const code = $getRoot().getLastChild(); return $isElementNode(code) ? code.getChildrenSize() : 0; }), ).toBeGreaterThan(parts.length); @@ -109,7 +117,7 @@ describe('CodeHighlighterPrism $updateAndRetainSelection', () => { }); expect( - editor.read(() => $getRoot().getFirstChild()!.getTextContent()), + editor.read(() => $getRoot().getLastChild()!.getTextContent()), ).toBe(expectedText); }, ); diff --git a/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts index 0a3e8c84227..5af6fac4d92 100644 --- a/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts +++ b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts @@ -108,6 +108,7 @@ describe('CodeHighlighterShiki $updateAndRetainSelection', () => { [['const a = 1', '\n', 'b'], 2, 'const a = 1\nXb'], [['const a = 1', '\n'], 2, 'const a = 1\nX'], [['const a = 1', '\n', '\n', 'b'], 3, 'const a = 1\n\nXb'], + [['\n', 'const a = 1'], 0, 'X\nconst a = 1'], ])( 'keeps an element caret on its line when an earlier line is split: %j at %i', (parts, offset, expectedText) => { @@ -116,7 +117,13 @@ describe('CodeHighlighterShiki $updateAndRetainSelection', () => { editor.update( () => { const code = $createCodeNode('javascript'); - $getRoot().clear().append(code); + // Text before the code block must not count toward the caret. + $getRoot() + .clear() + .append( + $createParagraphNode().append($createTextNode('x'.repeat(50))), + code, + ); code.append( ...parts.map(part => part === '\n' ? $createLineBreakNode() : $createTextNode(part), @@ -128,7 +135,7 @@ describe('CodeHighlighterShiki $updateAndRetainSelection', () => { ); expect( editor.read(() => { - const code = $getRoot().getFirstChild(); + const code = $getRoot().getLastChild(); return $isElementNode(code) ? code.getChildrenSize() : 0; }), ).toBeGreaterThan(parts.length); @@ -145,7 +152,7 @@ describe('CodeHighlighterShiki $updateAndRetainSelection', () => { ); expect( - editor.read(() => $getRoot().getFirstChild()!.getTextContent()), + editor.read(() => $getRoot().getLastChild()!.getTextContent()), ).toBe(expectedText); }, ); From 42a7bad412001190680abb71d8c871e3e06617bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:55:12 +0000 Subject: [PATCH 24/31] [lexical-code-core][lexical-code-prism][lexical-code-shiki][lexical-website] Chore: Hoist the retain-selection offset helper and correct two doc lines ## Description The highlighters' text offset helper was a closure created on every retokenize; it is a module function now, in both Prism and Shiki. The included-extensions entry said `onlyWordWrapped` numbers blocks, which the theme does, and the `setWordWrap` JSDoc didn't say that with `onlyWordWrapped` the toggle recreates the block's DOM. ## Test plan ### After ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code-shiki packages/lexical-code-prism Tests 199 passed (199) ``` Co-Authored-By: Claude Opus 5.5 --- packages/lexical-code-core/src/CodeNode.ts | 7 ++++--- packages/lexical-code-prism/src/CodeHighlighterPrism.ts | 6 ++++-- packages/lexical-code-shiki/src/CodeHighlighterShiki.ts | 6 ++++-- .../lexical-website/docs/extensions/included-extensions.md | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/lexical-code-core/src/CodeNode.ts b/packages/lexical-code-core/src/CodeNode.ts index f3de1f8d287..603d7fce902 100644 --- a/packages/lexical-code-core/src/CodeNode.ts +++ b/packages/lexical-code-core/src/CodeNode.ts @@ -477,9 +477,10 @@ export class CodeNode extends ElementNode { } /** - * Turns soft wrapping of long lines on or off. This only sets the flag and - * the `data-lexical-code-word-wrap` attribute; the theme's CSS wraps the - * lines. + * Turns soft wrapping of long lines on or off. This sets the flag and the + * `data-lexical-code-word-wrap` attribute; the theme's CSS wraps the lines. + * With `CodeLineNumbersExtension`'s `onlyWordWrapped`, it also recreates + * the block's DOM to add or remove the line number markup. */ setWordWrap(wordWrap: boolean): this { const writable = this.getWritable(); diff --git a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts index a14993218ea..1faecdc455d 100644 --- a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts +++ b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts @@ -179,6 +179,10 @@ function $codeNodeTransform( }); } +function textOffsetBefore(nodes: LexicalNode[]): number { + return nodes.reduce((offset, node) => offset + node.getTextContentSize(), 0); +} + // Wrapping update function into selection retainer, that tries to keep cursor at the same // position as before. function $updateAndRetainSelection( @@ -213,8 +217,6 @@ function $updateAndRetainSelection( // before it, plus its offset in its own text node. An element point (on the // code node, e.g. after a line break) is a child index, which goes stale // once an earlier line is split into more tokens, so it is converted too. - const textOffsetBefore = (nodes: LexicalNode[]) => - nodes.reduce((offset, child) => offset + child.getTextContentSize(), 0); let textOffset = anchor.type === 'element' ? textOffsetBefore(node.getChildren().slice(0, anchor.offset)) diff --git a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts index 031680add34..c581fd11cb5 100644 --- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts +++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts @@ -209,6 +209,10 @@ function $codeNodeTransform( }); } +function textOffsetBefore(nodes: LexicalNode[]): number { + return nodes.reduce((offset, node) => offset + node.getTextContentSize(), 0); +} + // Wrapping update function into selection retainer, that tries to keep cursor at the same // position as before. function $updateAndRetainSelection( @@ -243,8 +247,6 @@ function $updateAndRetainSelection( // before it, plus its offset in its own text node. An element point (on the // code node, e.g. after a line break) is a child index, which goes stale // once an earlier line is split into more tokens, so it is converted too. - const textOffsetBefore = (nodes: LexicalNode[]) => - nodes.reduce((offset, child) => offset + child.getTextContentSize(), 0); let textOffset = anchor.type === 'element' ? textOffsetBefore(node.getChildren().slice(0, anchor.offset)) diff --git a/packages/lexical-website/docs/extensions/included-extensions.md b/packages/lexical-website/docs/extensions/included-extensions.md index dc0af43e931..6ac961f624a 100644 --- a/packages/lexical-website/docs/extensions/included-extensions.md +++ b/packages/lexical-website/docs/extensions/included-extensions.md @@ -21,7 +21,7 @@ Framework-agnostic accessibility extensions. See [Keyboard Accessibility](/docs/ - [CodeExtension](/docs/api/modules/lexical_code#codeextension) - CodeNode (code blocks) - [CodeIndentExtension](/docs/api/modules/lexical_code#codeindentextension) - CodeNode tab key indentation (code blocks) -- [CodeLineNumbersExtension](/docs/api/modules/lexical_code#codelinenumbersextension) - Marks each line of a CodeNode so a theme can number lines with a CSS counter that follows wrapped lines; set `onlyWordWrapped` to number only code blocks with word wrap on (experimental) +- [CodeLineNumbersExtension](/docs/api/modules/lexical_code#codelinenumbersextension) - Marks each line of a CodeNode so a theme can number lines with a CSS counter that follows wrapped lines; set `onlyWordWrapped` to mark only code blocks with word wrap on (experimental) [@lexical/code-prism](/docs/api/modules/lexical_code-prism) From 11eb3a956fe5702b28972af88df6d381faac3d10 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:04:39 +0000 Subject: [PATCH 25/31] [lexical-code-prism][lexical-code-shiki] Documentation Update: Note the restored caret's point type in the changeset's Verify paragraph ## Description After the highlighters' caret fix, an element caret next to a line break can come back from a retokenize as a text point at the same text position. The changeset's `Verify:` paragraph now says so. Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index 336c060f333..08d6b831dc4 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -15,4 +15,4 @@ The Flow types now declare the theme next to the language as TypeScript does: `C The Prism and Shiki highlighters no longer move a caret that sits right after a line break into an earlier line when they split that line into more tokens (for example when Shiki's grammar finishes loading just after Enter), so the next characters typed land on the right line, and a caret at the start of a code block no longer moves by the length of the text before the block when it is retokenized. -Verify: `CodeNode.exportJSON()` now has an own `wordWrap` key on every code block, `undefined` while word wrap is off, as it already has for `theme`. Serialized JSON is unchanged, but a test that compares the returned object with `toStrictEqual` needs the key. +Verify: `CodeNode.exportJSON()` now has an own `wordWrap` key on every code block, `undefined` while word wrap is off, as it already has for `theme`. Serialized JSON is unchanged, but a test that compares the returned object with `toStrictEqual` needs the key. After Prism or Shiki retokenizes a block, a caret that was an element point next to a line break may come back as a text point at the same position in the text. From 6b45eabd1e9929421dc0ac35ac0c088943bbd55f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:31:34 +0000 Subject: [PATCH 26/31] [lexical-code-prism][lexical-code-shiki] Bug Fix: Keep a range selection when a code block is retokenized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `$updateAndRetainSelection` recorded and restored only the anchor, by selecting a collapsed caret, so any retokenize that changed a code block (a language or theme change, Shiki's grammar loading, an edit that splits a token) collapsed a range selection to its anchor. When the anchor was outside the block it skipped restoring altogether, and the focus moved to the edge of the replaced tokens. Both points are now converted to text offsets in the block before the update and moved back with `PointType.set` after it, each only if it was inside the block, so the direction is kept too. ## Test plan ### Before With the highlighters' previous `$updateAndRetainSelection`: ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts × inside the code block (forward) × inside the code block (backward) × from a paragraph above into the code block AssertionError: expected '' to be 'nst a = 1\nlet' // Object.is equality AssertionError: expected 'ove\nconst a = 1\nlet b = 2' to be 'ove\nconst a = 1\nlet' // Object.is equality Tests 3 failed | 9 passed (12) ``` and the same three cases fail in `CodeHighlighterPrismRetainSelection.test.ts`. ### After ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code-shiki packages/lexical-code-prism packages/lexical-code packages/lexical-code-core packages/lexical-markdown packages/lexical-playground/__tests__/unit Tests 1446 passed | 1 skipped (1447) ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 2 +- .../src/CodeHighlighterPrism.ts | 115 ++++++++++-------- ...odeHighlighterPrismRetainSelection.test.ts | 66 +++++++++- .../src/CodeHighlighterShiki.ts | 115 ++++++++++-------- .../unit/CodeShikiRetainSelection.test.ts | 74 ++++++++++- 5 files changed, 269 insertions(+), 103 deletions(-) diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index 08d6b831dc4..5638c27848d 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -13,6 +13,6 @@ A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-l The Flow types now declare the theme next to the language as TypeScript does: `CodeNode.getTheme`/`setTheme`, the `theme` argument of `$createCodeNode` and the `theme` key of `SerializedCodeNode`. `CodeNode.setLanguage` takes a nullable language and returns the node. -The Prism and Shiki highlighters no longer move a caret that sits right after a line break into an earlier line when they split that line into more tokens (for example when Shiki's grammar finishes loading just after Enter), so the next characters typed land on the right line, and a caret at the start of a code block no longer moves by the length of the text before the block when it is retokenized. +When they retokenize a code block, the Prism and Shiki highlighters no longer move a caret that sits right after a line break into an earlier line that was split into more tokens (for example when Shiki's grammar finishes loading just after Enter), so the next characters typed land on the right line, a caret at the start of a code block no longer moves by the length of the text before the block, and a range selection that starts or ends in a code block is kept instead of collapsing to its anchor. Verify: `CodeNode.exportJSON()` now has an own `wordWrap` key on every code block, `undefined` while word wrap is off, as it already has for `theme`. Serialized JSON is unchanged, but a test that compares the returned object with `toStrictEqual` needs the key. After Prism or Shiki retokenizes a block, a caret that was an element point next to a line break may come back as a text point at the same position in the text. diff --git a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts index 1faecdc455d..9ff0f9f809e 100644 --- a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts +++ b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts @@ -33,6 +33,7 @@ import { type LexicalNode, mergeRegister, type NodeKey, + type PointType, safeCast, TextNode, } from 'lexical'; @@ -179,12 +180,62 @@ function $codeNodeTransform( }); } -function textOffsetBefore(nodes: LexicalNode[]): number { - return nodes.reduce((offset, node) => offset + node.getTextContentSize(), 0); +// The text offset of a point in a code block: the text of everything before +// it, plus its offset in its own text node, or null when the point is outside +// the block. An element point (e.g. after a line break) is a child index, +// which goes stale once an earlier line is split into more tokens, so it is +// converted too. +function $getCodeTextOffset(code: CodeNode, point: PointType): number | null { + const pointNode = point.getNode(); + if (pointNode === code) { + return code + .getChildren() + .slice(0, point.offset) + .reduce((offset, child) => offset + child.getTextContentSize(), 0); + } + if (!code.isParentOf(pointNode)) { + return null; + } + return pointNode + .getPreviousSiblings() + .reduce( + (offset, sibling) => offset + sibling.getTextContentSize(), + point.offset, + ); +} + +// Moves a point to a text offset in a code block. A LineBreakNode consumes +// one unit of the offset but can't host a text point, so when the offset +// lands on one this uses an element point on the code node instead of letting +// the offset go negative. +function $setCodeTextOffset( + code: CodeNode, + point: PointType, + textOffset: number, +): void { + const children = code.getChildren(); + for (let index = 0; index < children.length; index++) { + const child = children[index]; + if ($isTextNode(child)) { + const textContentSize = child.getTextContentSize(); + if (textContentSize >= textOffset) { + point.set(child.getKey(), textOffset, 'text'); + return; + } + textOffset -= textContentSize; + } else if ($isLineBreakNode(child)) { + if (textOffset === 0) { + point.set(code.getKey(), index, 'element'); + return; + } + textOffset -= 1; + } + } + point.set(code.getKey(), children.length, 'element'); } -// Wrapping update function into selection retainer, that tries to keep cursor at the same -// position as before. +// Wrapping update function into selection retainer, that tries to keep the +// selection at the same text position as before. function $updateAndRetainSelection( nodeKey: NodeKey, updateFn: () => boolean, @@ -201,56 +252,20 @@ function $updateAndRetainSelection( return; } - 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; - } + // Only the points inside this code node are restored. A point elsewhere is + // left where the user put it, rather than dragged into the code block. + const anchorOffset = $getCodeTextOffset(node, selection.anchor); + const focusOffset = $getCodeTextOffset(node, selection.focus); - // The text offset of the anchor in the code block: the text of everything - // before it, plus its offset in its own text node. An element point (on the - // code node, e.g. after a line break) is a child index, which goes stale - // once an earlier line is split into more tokens, so it is converted too. - let textOffset = - anchor.type === 'element' - ? textOffsetBefore(node.getChildren().slice(0, anchor.offset)) - : anchor.offset + textOffsetBefore(anchorNode.getPreviousSiblings()); - - const hasChanges = updateFn(); - if (!hasChanges) { + if (!updateFn()) { return; } - - // Walk through the child nodes looking for the 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; - } + if (anchorOffset !== null) { + $setCodeTextOffset(node, selection.anchor, anchorOffset); + } + if (focusOffset !== null) { + $setCodeTextOffset(node, selection.focus, focusOffset); } - 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 index a5615f1a690..7b5c2c09c92 100644 --- a/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts +++ b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts @@ -18,7 +18,7 @@ import { $isRangeSelection, } from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; -import {describe, expect, test} from 'vitest'; +import {assert, describe, expect, test} from 'vitest'; describe('CodeHighlighterPrism $updateAndRetainSelection', () => { initializeUnitTest(testEnv => { @@ -121,5 +121,69 @@ describe('CodeHighlighterPrism $updateAndRetainSelection', () => { ).toBe(expectedText); }, ); + + test.each([ + ['forward', false], + ['backward', true], + ])( + 'keeps a range selection inside the code block (%s)', + async (_name, isBackward) => { + const {editor} = testEnv; + registerCodeHighlighting(editor); + + await editor.update(() => { + const first = $createTextNode('const a = 1'); + const last = $createTextNode('let b = 2'); + $getRoot() + .clear() + .append( + $createCodeNode('javascript').append( + first, + $createLineBreakNode(), + last, + ), + ); + const selection = first.select(2, 2); + selection.focus.set(last.getKey(), 3, 'text'); + if (isBackward) { + selection.setTextNodeRange(last, 3, first, 2); + } + }); + + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + expect(selection.getTextContent()).toBe('nst a = 1\nlet'); + expect(selection.isBackward()).toBe(isBackward); + }); + }, + ); + + test('keeps a range selection from a paragraph above into the code block', async () => { + const {editor} = testEnv; + registerCodeHighlighting(editor); + + await editor.update(() => { + const above = $createTextNode('above'); + const last = $createTextNode('let b = 2'); + $getRoot() + .clear() + .append( + $createParagraphNode().append(above), + $createCodeNode('javascript').append( + $createTextNode('const a = 1'), + $createLineBreakNode(), + last, + ), + ); + above.select(2, 2).focus.set(last.getKey(), 3, 'text'); + }); + + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + expect(selection.getTextContent()).toBe('ove\nconst a = 1\nlet'); + }); + }); }); }); diff --git a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts index c581fd11cb5..940fe27ad56 100644 --- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts +++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts @@ -33,6 +33,7 @@ import { type LexicalNode, mergeRegister, type NodeKey, + type PointType, safeCast, TextNode, } from 'lexical'; @@ -209,12 +210,62 @@ function $codeNodeTransform( }); } -function textOffsetBefore(nodes: LexicalNode[]): number { - return nodes.reduce((offset, node) => offset + node.getTextContentSize(), 0); +// The text offset of a point in a code block: the text of everything before +// it, plus its offset in its own text node, or null when the point is outside +// the block. An element point (e.g. after a line break) is a child index, +// which goes stale once an earlier line is split into more tokens, so it is +// converted too. +function $getCodeTextOffset(code: CodeNode, point: PointType): number | null { + const pointNode = point.getNode(); + if (pointNode === code) { + return code + .getChildren() + .slice(0, point.offset) + .reduce((offset, child) => offset + child.getTextContentSize(), 0); + } + if (!code.isParentOf(pointNode)) { + return null; + } + return pointNode + .getPreviousSiblings() + .reduce( + (offset, sibling) => offset + sibling.getTextContentSize(), + point.offset, + ); +} + +// Moves a point to a text offset in a code block. A LineBreakNode consumes +// one unit of the offset but can't host a text point, so when the offset +// lands on one this uses an element point on the code node instead of letting +// the offset go negative. +function $setCodeTextOffset( + code: CodeNode, + point: PointType, + textOffset: number, +): void { + const children = code.getChildren(); + for (let index = 0; index < children.length; index++) { + const child = children[index]; + if ($isTextNode(child)) { + const textContentSize = child.getTextContentSize(); + if (textContentSize >= textOffset) { + point.set(child.getKey(), textOffset, 'text'); + return; + } + textOffset -= textContentSize; + } else if ($isLineBreakNode(child)) { + if (textOffset === 0) { + point.set(code.getKey(), index, 'element'); + return; + } + textOffset -= 1; + } + } + point.set(code.getKey(), children.length, 'element'); } -// Wrapping update function into selection retainer, that tries to keep cursor at the same -// position as before. +// Wrapping update function into selection retainer, that tries to keep the +// selection at the same text position as before. function $updateAndRetainSelection( nodeKey: NodeKey, updateFn: () => boolean, @@ -231,56 +282,20 @@ function $updateAndRetainSelection( return; } - 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; - } + // Only the points inside this code node are restored. A point elsewhere is + // left where the user put it, rather than dragged into the code block. + const anchorOffset = $getCodeTextOffset(node, selection.anchor); + const focusOffset = $getCodeTextOffset(node, selection.focus); - // The text offset of the anchor in the code block: the text of everything - // before it, plus its offset in its own text node. An element point (on the - // code node, e.g. after a line break) is a child index, which goes stale - // once an earlier line is split into more tokens, so it is converted too. - let textOffset = - anchor.type === 'element' - ? textOffsetBefore(node.getChildren().slice(0, anchor.offset)) - : anchor.offset + textOffsetBefore(anchorNode.getPreviousSiblings()); - - const hasChanges = updateFn(); - if (!hasChanges) { + if (!updateFn()) { return; } - - // Walk through the child nodes looking for the 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; - } + if (anchorOffset !== null) { + $setCodeTextOffset(node, selection.anchor, anchorOffset); + } + if (focusOffset !== null) { + $setCodeTextOffset(node, selection.focus, focusOffset); } - 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 index 5af6fac4d92..cb31eac4e71 100644 --- a/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts +++ b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts @@ -28,7 +28,7 @@ import { $isRangeSelection, defineExtension, } from 'lexical'; -import {beforeAll, describe, expect, test} from 'vitest'; +import {assert, beforeAll, describe, expect, test} from 'vitest'; function createEditor() { return buildEditorFromExtensions( @@ -158,6 +158,78 @@ describe('CodeHighlighterShiki $updateAndRetainSelection', () => { ); }); +describe('CodeHighlighterShiki keeps a range selection across a retokenize', () => { + beforeAll(async () => { + await loadCodeLanguage('javascript'); + await loadCodeTheme('one-light'); + }); + + test.each([ + ['forward', false], + ['backward', true], + ])('inside the code block (%s)', (_name, isBackward) => { + using editor = createEditor(); + + editor.update( + () => { + const first = $createTextNode('const a = 1'); + const last = $createTextNode('let b = 2'); + $getRoot() + .clear() + .append( + $createCodeNode('javascript').append( + first, + $createLineBreakNode(), + last, + ), + ); + const selection = first.select(2, 2); + selection.focus.set(last.getKey(), 3, 'text'); + if (isBackward) { + selection.setTextNodeRange(last, 3, first, 2); + } + }, + {discrete: true}, + ); + + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + expect(selection.getTextContent()).toBe('nst a = 1\nlet'); + expect(selection.isBackward()).toBe(isBackward); + }); + }); + + test('from a paragraph above into the code block', () => { + using editor = createEditor(); + + editor.update( + () => { + const above = $createTextNode('above'); + const last = $createTextNode('let b = 2'); + $getRoot() + .clear() + .append( + $createParagraphNode().append(above), + $createCodeNode('javascript').append( + $createTextNode('const a = 1'), + $createLineBreakNode(), + last, + ), + ); + above.select(2, 2).focus.set(last.getKey(), 3, 'text'); + }, + {discrete: true}, + ); + + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + expect(selection.getTextContent()).toBe('ove\nconst a = 1\nlet'); + }); + }); +}); + /** * Shiki loads grammars and themes asynchronously, so the highlight transform * that rewrites the code block's children only runs once those settle. From 4e9a3e0ab241219f8ea46a90417a338d03da5ed6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:37:05 +0000 Subject: [PATCH 27/31] [lexical-code-prism][lexical-code-shiki] Documentation Update: Describe the highlighters' selection fixes accurately in the changeset ## Description The changeset said only carets at the start of a block shifted and that ranges collapsed when they started or ended in a block. In fact any element caret on the code node not after a line break shifted, a range that started outside the block lost its focus rather than collapsing, and after the fix any point on the code node can come back as a text point. The note and its `Verify:` paragraph now say so. ## Test plan ### After ``` $ E2E_PORT=4310 ./node_modules/.bin/playwright test --project=chromium packages/lexical-playground/__tests__/e2e/CodeActionMenu.spec.mjs packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs 43 passed (42.3s) ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index 5638c27848d..e09052155bb 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -13,6 +13,6 @@ A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-l The Flow types now declare the theme next to the language as TypeScript does: `CodeNode.getTheme`/`setTheme`, the `theme` argument of `$createCodeNode` and the `theme` key of `SerializedCodeNode`. `CodeNode.setLanguage` takes a nullable language and returns the node. -When they retokenize a code block, the Prism and Shiki highlighters no longer move a caret that sits right after a line break into an earlier line that was split into more tokens (for example when Shiki's grammar finishes loading just after Enter), so the next characters typed land on the right line, a caret at the start of a code block no longer moves by the length of the text before the block, and a range selection that starts or ends in a code block is kept instead of collapsing to its anchor. +When they retokenize a code block, the Prism and Shiki highlighters now keep the selection at the same text position: a caret after a line break no longer moves into an earlier line that was split into more tokens (for example when Shiki's grammar finishes loading just after Enter), other carets on the code block no longer shift by the length of the text before the block, and both ends of a range selection are kept, so it no longer collapses or loses its focus. -Verify: `CodeNode.exportJSON()` now has an own `wordWrap` key on every code block, `undefined` while word wrap is off, as it already has for `theme`. Serialized JSON is unchanged, but a test that compares the returned object with `toStrictEqual` needs the key. After Prism or Shiki retokenizes a block, a caret that was an element point next to a line break may come back as a text point at the same position in the text. +Verify: `CodeNode.exportJSON()` now has an own `wordWrap` key on every code block, `undefined` while word wrap is off, as it already has for `theme`. Serialized JSON is unchanged, but a test that compares the returned object with `toStrictEqual` needs the key. After Prism or Shiki retokenizes a block, a selection point on the code node itself may come back as a text point at the same position in the text, and a range selection is kept rather than collapsed. From 943f982f766801b0025da0dc68f9519ce8509a7c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:01:17 +0000 Subject: [PATCH 28/31] [lexical-code-core] Documentation Update: Reserve the gutter in the README's wrapped line numbers recipe ## Description The README's recipe for numbering word-wrapped blocks takes the numbers out of the line box but didn't say to reserve their space, so a theme following it drew the numbers over the start of each line. It now says to give the code element a `padding-inline-start` as wide as the numbers, as the playground theme does. Co-Authored-By: Claude Opus 5.5 --- packages/lexical-code-core/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/lexical-code-core/README.md b/packages/lexical-code-core/README.md index 8e0a18df225..4013e10598e 100644 --- a/packages/lexical-code-core/README.md +++ b/packages/lexical-code-core/README.md @@ -87,8 +87,9 @@ code[data-lexical-code-word-wrap][data-lexical-code-line-numbers] [data-lexical- ``` Keep `top: auto` and an inline `display` such as `inline-block`, so each number stays on the first row of its line, and -give the code element `position: relative`. `margin-inline: 0` drops any margin that placed the number in the line, which -would now move it out of the gutter. Keep `overflow-x: auto` rather than `visible` on the code element, because +give the code element `position: relative` and a `padding-inline-start` as wide as the numbers, so the text starts after +them. `margin-inline: 0` drops any margin that placed the number in the line, which would now move it out of the +gutter. Keep `overflow-x: auto` rather than `visible` on the code element, because `visible` would change the scroll container the numbers are placed against. ## Caret scrolling From d6ad0a5cc1839b3c2a93c5e853655e9e4fa879e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:22:21 +0000 Subject: [PATCH 29/31] [lexical-code-prism][lexical-code-shiki] Bug Fix: Restore a retokenized code block's selection as a caret again, so comment ids are kept ## Description The previous fix kept both ends of a range selection across a highlighter retokenize. Upstream's #9503, merged in through the PR head, has `MarkStateExtension` carry comment ids onto the new tokens only while the selection is collapsed, so with the range kept, converting selected commented text to a code block dropped its comments. The highlighters restore the selection as a caret at the anchor again, as before, still by text offset, and the range tests are dropped. ## Test plan ### Before ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-mark/src/__tests__/unit/MarkStateBlockConversion.test.ts Tests 19 failed | 170 passed (189) ``` ### After ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-mark packages/lexical-code-shiki packages/lexical-code-prism packages/lexical-code packages/lexical-code-core packages/lexical-playground/__tests__/unit Tests 1821 passed | 1 skipped (1822) ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 4 +- .../src/CodeHighlighterPrism.ts | 25 ++++--- ...odeHighlighterPrismRetainSelection.test.ts | 66 +---------------- .../src/CodeHighlighterShiki.ts | 25 ++++--- .../unit/CodeShikiRetainSelection.test.ts | 74 +------------------ 5 files changed, 30 insertions(+), 164 deletions(-) diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index e09052155bb..17f033741fb 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -13,6 +13,6 @@ A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-l The Flow types now declare the theme next to the language as TypeScript does: `CodeNode.getTheme`/`setTheme`, the `theme` argument of `$createCodeNode` and the `theme` key of `SerializedCodeNode`. `CodeNode.setLanguage` takes a nullable language and returns the node. -When they retokenize a code block, the Prism and Shiki highlighters now keep the selection at the same text position: a caret after a line break no longer moves into an earlier line that was split into more tokens (for example when Shiki's grammar finishes loading just after Enter), other carets on the code block no longer shift by the length of the text before the block, and both ends of a range selection are kept, so it no longer collapses or loses its focus. +When they retokenize a code block, the Prism and Shiki highlighters keep the caret at the same text position: a caret after a line break no longer moves into an earlier line that was split into more tokens (for example when Shiki's grammar finishes loading just after Enter), and other carets on the code block no longer shift by the length of the text before the block. -Verify: `CodeNode.exportJSON()` now has an own `wordWrap` key on every code block, `undefined` while word wrap is off, as it already has for `theme`. Serialized JSON is unchanged, but a test that compares the returned object with `toStrictEqual` needs the key. After Prism or Shiki retokenizes a block, a selection point on the code node itself may come back as a text point at the same position in the text, and a range selection is kept rather than collapsed. +Verify: `CodeNode.exportJSON()` now has an own `wordWrap` key on every code block, `undefined` while word wrap is off, as it already has for `theme`. Serialized JSON is unchanged, but a test that compares the returned object with `toStrictEqual` needs the key. After Prism or Shiki retokenizes a block, a selection point on the code node itself may come back as a text point at the same position in the text. diff --git a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts index 3e1eff8a0b3..73d824d9da2 100644 --- a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts +++ b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts @@ -268,7 +268,7 @@ function $setCodeTextOffset( } // Wrapping update function into selection retainer, that tries to keep the -// selection at the same text position as before. +// caret at the same text position as before. function $updateAndRetainSelection( nodeKey: NodeKey, updateFn: () => boolean, @@ -285,20 +285,21 @@ function $updateAndRetainSelection( return; } - // Only the points inside this code node are restored. A point elsewhere is - // left where the user put it, rather than dragged into the code block. - const anchorOffset = $getCodeTextOffset(node, selection.anchor); - const focusOffset = $getCodeTextOffset(node, selection.focus); - - if (!updateFn()) { + // The selection is restored as a caret at the anchor, and only when the + // anchor is inside this code node: a selection elsewhere is left where the + // user put it, rather than dragged into the code block. MarkStateExtension + // relies on that collapsed caret to carry comment ids onto the new tokens. + const textOffset = $getCodeTextOffset(node, selection.anchor); + if (textOffset === null) { + updateFn(); return; } - if (anchorOffset !== null) { - $setCodeTextOffset(node, selection.anchor, anchorOffset); - } - if (focusOffset !== null) { - $setCodeTextOffset(node, selection.focus, focusOffset); + if (!updateFn()) { + return; } + const {anchor, focus} = selection; + $setCodeTextOffset(node, anchor, textOffset); + focus.set(anchor.key, anchor.offset, anchor.type); } // 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 index 7b5c2c09c92..a5615f1a690 100644 --- a/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts +++ b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts @@ -18,7 +18,7 @@ import { $isRangeSelection, } from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; -import {assert, describe, expect, test} from 'vitest'; +import {describe, expect, test} from 'vitest'; describe('CodeHighlighterPrism $updateAndRetainSelection', () => { initializeUnitTest(testEnv => { @@ -121,69 +121,5 @@ describe('CodeHighlighterPrism $updateAndRetainSelection', () => { ).toBe(expectedText); }, ); - - test.each([ - ['forward', false], - ['backward', true], - ])( - 'keeps a range selection inside the code block (%s)', - async (_name, isBackward) => { - const {editor} = testEnv; - registerCodeHighlighting(editor); - - await editor.update(() => { - const first = $createTextNode('const a = 1'); - const last = $createTextNode('let b = 2'); - $getRoot() - .clear() - .append( - $createCodeNode('javascript').append( - first, - $createLineBreakNode(), - last, - ), - ); - const selection = first.select(2, 2); - selection.focus.set(last.getKey(), 3, 'text'); - if (isBackward) { - selection.setTextNodeRange(last, 3, first, 2); - } - }); - - editor.read(() => { - const selection = $getSelection(); - assert($isRangeSelection(selection)); - expect(selection.getTextContent()).toBe('nst a = 1\nlet'); - expect(selection.isBackward()).toBe(isBackward); - }); - }, - ); - - test('keeps a range selection from a paragraph above into the code block', async () => { - const {editor} = testEnv; - registerCodeHighlighting(editor); - - await editor.update(() => { - const above = $createTextNode('above'); - const last = $createTextNode('let b = 2'); - $getRoot() - .clear() - .append( - $createParagraphNode().append(above), - $createCodeNode('javascript').append( - $createTextNode('const a = 1'), - $createLineBreakNode(), - last, - ), - ); - above.select(2, 2).focus.set(last.getKey(), 3, 'text'); - }); - - editor.read(() => { - const selection = $getSelection(); - assert($isRangeSelection(selection)); - expect(selection.getTextContent()).toBe('ove\nconst a = 1\nlet'); - }); - }); }); }); diff --git a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts index cf2e4cb3277..16751bb25f2 100644 --- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts +++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts @@ -298,7 +298,7 @@ function $setCodeTextOffset( } // Wrapping update function into selection retainer, that tries to keep the -// selection at the same text position as before. +// caret at the same text position as before. function $updateAndRetainSelection( nodeKey: NodeKey, updateFn: () => boolean, @@ -315,20 +315,21 @@ function $updateAndRetainSelection( return; } - // Only the points inside this code node are restored. A point elsewhere is - // left where the user put it, rather than dragged into the code block. - const anchorOffset = $getCodeTextOffset(node, selection.anchor); - const focusOffset = $getCodeTextOffset(node, selection.focus); - - if (!updateFn()) { + // The selection is restored as a caret at the anchor, and only when the + // anchor is inside this code node: a selection elsewhere is left where the + // user put it, rather than dragged into the code block. MarkStateExtension + // relies on that collapsed caret to carry comment ids onto the new tokens. + const textOffset = $getCodeTextOffset(node, selection.anchor); + if (textOffset === null) { + updateFn(); return; } - if (anchorOffset !== null) { - $setCodeTextOffset(node, selection.anchor, anchorOffset); - } - if (focusOffset !== null) { - $setCodeTextOffset(node, selection.focus, focusOffset); + if (!updateFn()) { + return; } + const {anchor, focus} = selection; + $setCodeTextOffset(node, anchor, textOffset); + focus.set(anchor.key, anchor.offset, anchor.type); } // 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 index cb31eac4e71..5af6fac4d92 100644 --- a/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts +++ b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts @@ -28,7 +28,7 @@ import { $isRangeSelection, defineExtension, } from 'lexical'; -import {assert, beforeAll, describe, expect, test} from 'vitest'; +import {beforeAll, describe, expect, test} from 'vitest'; function createEditor() { return buildEditorFromExtensions( @@ -158,78 +158,6 @@ describe('CodeHighlighterShiki $updateAndRetainSelection', () => { ); }); -describe('CodeHighlighterShiki keeps a range selection across a retokenize', () => { - beforeAll(async () => { - await loadCodeLanguage('javascript'); - await loadCodeTheme('one-light'); - }); - - test.each([ - ['forward', false], - ['backward', true], - ])('inside the code block (%s)', (_name, isBackward) => { - using editor = createEditor(); - - editor.update( - () => { - const first = $createTextNode('const a = 1'); - const last = $createTextNode('let b = 2'); - $getRoot() - .clear() - .append( - $createCodeNode('javascript').append( - first, - $createLineBreakNode(), - last, - ), - ); - const selection = first.select(2, 2); - selection.focus.set(last.getKey(), 3, 'text'); - if (isBackward) { - selection.setTextNodeRange(last, 3, first, 2); - } - }, - {discrete: true}, - ); - - editor.read(() => { - const selection = $getSelection(); - assert($isRangeSelection(selection)); - expect(selection.getTextContent()).toBe('nst a = 1\nlet'); - expect(selection.isBackward()).toBe(isBackward); - }); - }); - - test('from a paragraph above into the code block', () => { - using editor = createEditor(); - - editor.update( - () => { - const above = $createTextNode('above'); - const last = $createTextNode('let b = 2'); - $getRoot() - .clear() - .append( - $createParagraphNode().append(above), - $createCodeNode('javascript').append( - $createTextNode('const a = 1'), - $createLineBreakNode(), - last, - ), - ); - above.select(2, 2).focus.set(last.getKey(), 3, 'text'); - }, - {discrete: true}, - ); - - editor.read(() => { - const selection = $getSelection(); - assert($isRangeSelection(selection)); - expect(selection.getTextContent()).toBe('ove\nconst a = 1\nlet'); - }); - }); -}); - /** * Shiki loads grammars and themes asynchronously, so the highlight transform * that rewrites the code block's children only runs once those settle. From 6b8c0f5911edeb31015cd8a6b6c69e983965ed8e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:22:39 +0000 Subject: [PATCH 30/31] [lexical-code-core] Documentation Update: Keep line numbers out of screen readers in the README recipe ## Description The README's line number recipe used `content: counter(code-line)` alone, so screen readers would read a number before every line. It now adds `content: counter(code-line) / ''` after it, as the playground theme does, with the plain form kept as a fallback for browsers without alternative text. Co-Authored-By: Claude Opus 5.5 --- packages/lexical-code-core/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/lexical-code-core/README.md b/packages/lexical-code-core/README.md index 4013e10598e..e2bd2cc9b0a 100644 --- a/packages/lexical-code-core/README.md +++ b/packages/lexical-code-core/README.md @@ -29,6 +29,8 @@ code[data-lexical-code-line-numbers]::before, code[data-lexical-code-line-numbers] [data-lexical-code-line-break]::after { counter-increment: code-line; content: counter(code-line); + /* empty alternative text, so screen readers don't read the numbers */ + content: counter(code-line) / ''; /* size and place the number in a gutter */ } ``` From cefa5d15f44b6d9cf647d48a7bca67794dc74eb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:46:17 +0000 Subject: [PATCH 31/31] [lexical-code-prism][lexical-code-shiki] Bug Fix: Keep the focus of a range that reaches into a retokenized code block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When a range selection started before a code block and ended inside it, the highlighters left restoring to `ElementNode.splice`, which moved the focus to the edge of a neighbouring token once its token was replaced, so the selection grew or shrank. The focus is now converted to a text offset and moved back after the retokenize. A selection anchored inside the block is still restored as a caret, which `MarkStateExtension` relies on. ## Test plan ### Before ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts × CodeHighlighterShiki keeps the focus of a range from a paragraph above × keeps the focus of a range from a paragraph above AssertionError: expected 'ove\nconst a = 1\nlet b = 2' to be 'ove\nconst a = 1\nlet' // Object.is equality ``` ### After ``` $ ./node_modules/.bin/vitest run --project unit packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts Tests 18 passed (18) $ ./node_modules/.bin/vitest run --project unit packages/lexical-mark packages/lexical-code-shiki packages/lexical-code-prism packages/lexical-code Tests 1551 passed | 1 skipped (1552) ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/code-word-wrap-line-numbers.md | 2 +- .../src/CodeHighlighterPrism.ts | 15 ++++++--- ...odeHighlighterPrismRetainSelection.test.ts | 27 ++++++++++++++++ .../src/CodeHighlighterShiki.ts | 15 ++++++--- .../unit/CodeShikiRetainSelection.test.ts | 31 +++++++++++++++++++ 5 files changed, 79 insertions(+), 11 deletions(-) diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md index 17f033741fb..a915e7f96e1 100644 --- a/.changeset/code-word-wrap-line-numbers.md +++ b/.changeset/code-word-wrap-line-numbers.md @@ -13,6 +13,6 @@ A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-l The Flow types now declare the theme next to the language as TypeScript does: `CodeNode.getTheme`/`setTheme`, the `theme` argument of `$createCodeNode` and the `theme` key of `SerializedCodeNode`. `CodeNode.setLanguage` takes a nullable language and returns the node. -When they retokenize a code block, the Prism and Shiki highlighters keep the caret at the same text position: a caret after a line break no longer moves into an earlier line that was split into more tokens (for example when Shiki's grammar finishes loading just after Enter), and other carets on the code block no longer shift by the length of the text before the block. +When they retokenize a code block, the Prism and Shiki highlighters keep the caret at the same text position: a caret after a line break no longer moves into an earlier line that was split into more tokens (for example when Shiki's grammar finishes loading just after Enter), other carets on the code block no longer shift by the length of the text before the block, and a range selected from before the block into it keeps its focus instead of moving it to a token edge. Verify: `CodeNode.exportJSON()` now has an own `wordWrap` key on every code block, `undefined` while word wrap is off, as it already has for `theme`. Serialized JSON is unchanged, but a test that compares the returned object with `toStrictEqual` needs the key. After Prism or Shiki retokenizes a block, a selection point on the code node itself may come back as a text point at the same position in the text. diff --git a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts index 73d824d9da2..c9aaf044681 100644 --- a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts +++ b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts @@ -285,13 +285,18 @@ function $updateAndRetainSelection( return; } - // The selection is restored as a caret at the anchor, and only when the - // anchor is inside this code node: a selection elsewhere is left where the - // user put it, rather than dragged into the code block. MarkStateExtension - // relies on that collapsed caret to carry comment ids onto the new tokens. + // When the anchor is inside this code node, the selection is restored as a + // caret at the anchor: MarkStateExtension relies on that collapsed caret to + // carry comment ids onto the new tokens. A selection elsewhere is left where + // the user put it, rather than dragged into the code block. const textOffset = $getCodeTextOffset(node, selection.anchor); if (textOffset === null) { - updateFn(); + // A range from outside into this code node keeps its anchor, and its + // focus moves back to the same text position. + const focusOffset = $getCodeTextOffset(node, selection.focus); + if (updateFn() && focusOffset !== null) { + $setCodeTextOffset(node, selection.focus, focusOffset); + } return; } if (!updateFn()) { diff --git a/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts index a5615f1a690..53eb273d87f 100644 --- a/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts +++ b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts @@ -121,5 +121,32 @@ describe('CodeHighlighterPrism $updateAndRetainSelection', () => { ).toBe(expectedText); }, ); + + test('keeps the focus of a range from a paragraph above', async () => { + const {editor} = testEnv; + registerCodeHighlighting(editor); + + await editor.update(() => { + const above = $createTextNode('above'); + const last = $createTextNode('let b = 2'); + $getRoot() + .clear() + .append( + $createParagraphNode().append(above), + $createCodeNode('javascript').append( + $createTextNode('const a = 1'), + $createLineBreakNode(), + last, + ), + ); + above.select(2, 2).focus.set(last.getKey(), 3, 'text'); + }); + + editor.read(() => { + const selection = $getSelection(); + expect($isRangeSelection(selection)).toBe(true); + expect(selection!.getTextContent()).toBe('ove\nconst a = 1\nlet'); + }); + }); }); }); diff --git a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts index 16751bb25f2..003dcfaf603 100644 --- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts +++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts @@ -315,13 +315,18 @@ function $updateAndRetainSelection( return; } - // The selection is restored as a caret at the anchor, and only when the - // anchor is inside this code node: a selection elsewhere is left where the - // user put it, rather than dragged into the code block. MarkStateExtension - // relies on that collapsed caret to carry comment ids onto the new tokens. + // When the anchor is inside this code node, the selection is restored as a + // caret at the anchor: MarkStateExtension relies on that collapsed caret to + // carry comment ids onto the new tokens. A selection elsewhere is left where + // the user put it, rather than dragged into the code block. const textOffset = $getCodeTextOffset(node, selection.anchor); if (textOffset === null) { - updateFn(); + // A range from outside into this code node keeps its anchor, and its + // focus moves back to the same text position. + const focusOffset = $getCodeTextOffset(node, selection.focus); + if (updateFn() && focusOffset !== null) { + $setCodeTextOffset(node, selection.focus, focusOffset); + } return; } if (!updateFn()) { diff --git a/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts index 5af6fac4d92..5a5abf5975a 100644 --- a/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts +++ b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts @@ -158,6 +158,37 @@ describe('CodeHighlighterShiki $updateAndRetainSelection', () => { ); }); +test('CodeHighlighterShiki keeps the focus of a range from a paragraph above', async () => { + await loadCodeLanguage('javascript'); + await loadCodeTheme('one-light'); + using editor = createEditor(); + + editor.update( + () => { + const above = $createTextNode('above'); + const last = $createTextNode('let b = 2'); + $getRoot() + .clear() + .append( + $createParagraphNode().append(above), + $createCodeNode('javascript').append( + $createTextNode('const a = 1'), + $createLineBreakNode(), + last, + ), + ); + above.select(2, 2).focus.set(last.getKey(), 3, 'text'); + }, + {discrete: true}, + ); + + editor.read(() => { + const selection = $getSelection(); + expect($isRangeSelection(selection)).toBe(true); + expect(selection!.getTextContent()).toBe('ove\nconst a = 1\nlet'); + }); +}); + /** * Shiki loads grammars and themes asynchronously, so the highlight transform * that rewrites the code block's children only runs once those settle.