Repository navigation
[lexical-code-core][lexical-code][lexical-playground] Feature: Code block word wrap and per line numbers - #9216
Open
Om-singhaI wants to merge 36 commits into
Open
Om-singhaI wants to merge 36 commits into
Om-singhaI wants to merge 36 commits into
Conversation
Om-singhaI
requested review from
acywatson,
etrepum,
fantactuka,
ivailop7,
potatowagon and
zurfyx
as code owners
September 24, 2026 03:09
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
etrepum
reviewed
Sep 24, 2026
Collaborator
|
Because these PRs were stacked on each other and squash merge is used in this repo, there are now conflicts. Stacking PRs doesn't really make things much easier with this workflow. |
Om-singhaI
force-pushed
the
feature/code-block-wrap-and-scroll
branch
from
September 28, 2026 01:13
dcd4c98 to
166dd36
Compare
Contributor
Author
|
Sorry about that, and sorry for the extra work it caused you. Stacking this on #9215 was my mistake. I've rebased it onto main, so only the word wrap and line numbers commits are left. Also, since #9231 the default |
Om-singhaI
force-pushed
the
feature/code-block-wrap-and-scroll
branch
from
September 30, 2026 20:11
166dd36 to
922b033
Compare
… stored in NodeState ## 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 <code> 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 <pre>. Both HTML import paths read it back: the CodeNode importDOM and the CodeImportExtension rules for <pre> and multi line <code>. 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 meta#8459. The scrolling part is fixed by the change this one builds on. Closes meta#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 > <pre data-lexical-code-word-wrap="true"> turns on word wrap TypeError: node.getWordWrap is not a function × CodeImportExtension > multi line <code data-lexical-code-word-wrap> turns on word wrap TypeError: node.getWordWrap is not a function × CodeImportExtension > <pre> without the attribute leaves word wrap off TypeError: node.getWordWrap is not a function × CodeImportExtension > <pre data-lexical-code-word-wrap="false"> 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 > <pre data-lexical-code-word-wrap="true"> turns on word wrap ✓ CodeImportExtension > multi line <code data-lexical-code-word-wrap> turns on word wrap ✓ CodeImportExtension > <pre> without the attribute leaves word wrap off ✓ CodeImportExtension > <pre data-lexical-code-word-wrap="false"> 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.
…umbersExtension for line numbers that follow wrapped lines
## 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 <br> of each LineBreakNode inside a CodeNode is wrapped in a span with data-lexical-code-line-break, whose inner <br> 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 <br> of each code line break and marks the code element
× CodeLineNumbersExtension > DOM > exposes the inner <br> 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 <br>
× 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 <br> 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 '<code spellcheck="false" data-languag…' to be '<code spellcheck="false" data-languag…' // Object.is equality
- 2"><span style="color: rgb(166, 38, 164);" data-lexical-text="true">const a = 1;</span><br>...
+ 2"><span style="color: rgb(166, 38, 164);" data-lexical-text="true">const</span><span style="color: rgb(56, 58, 66);" data-lexical-text="true"> </span>...
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.
…for code blocks
## 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.
… wrap and per line numbers
## 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 <br>, 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.
… the end of a long code line ## Description With per line numbers, CodeLineNumbersExtension wraps each <br> 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 <br> 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 416e87c, 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 <br> 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.
Om-singhaI
force-pushed
the
feature/code-block-wrap-and-scroll
branch
from
October 5, 2026 20:11
922b033 to
d9db326
Compare
…cessors 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 <noreply@anthropic.com>
…me 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…N shape test
## 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 <noreply@anthropic.com>
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
…a line break on its line when an earlier line is retokenized
## 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 <noreply@anthropic.com>
…ookup in the retain-selection tests
## Description
The new retain-selection tests read the code block's child count through `getFirstChildOrThrow<ElementNode>()`, 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…t after earlier blocks, and move the changeset's Verify paragraph last
## 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 <noreply@anthropic.com>
…ebsite] 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 <noreply@anthropic.com>
…he 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 <noreply@anthropic.com>
…ion when a code block is retokenized
## 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 <noreply@anthropic.com>
…be 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 <noreply@anthropic.com>
…layground] Chore: Merge the PR's current head (356e7bc) back into the fix branch ## Description The PR head moved to 356e7bc, a merge of upstream main on top of c00a30d, while this branch kept adding commits after c00a30d. Merging that head back in keeps the branch a fast-forward of the PR. It merges without conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…EADME'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 <noreply@anthropic.com>
…ed 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 meta#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 <noreply@anthropic.com>
…reen 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 <noreply@anthropic.com>
… range that reaches into a retokenized code block
## 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 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
A code block had no way to soft wrap long lines instead of scrolling sideways, and the highlighters'
data-gutterline numbers are one block of text, so they can't follow a line that wraps.Word wrap.
CodeNodegetsgetWordWrap()andsetWordWrap(wordWrap), declared as a field in its JSON schema next tolanguageandtheme. While it is on, the JSON has"wordWrap": true, and the<code>element and the exported<pre>getdata-lexical-code-word-wrap="true", which both HTML import paths read back ("false"counts as off). While it is off, the key and the attribute are left out. Wrapping is visual only: noLineBreakNodeis added, and the package adds no CSS; the@lexical/code-coreREADME has the recipe.Line numbers.
CodeLineNumbersExtension(experimental, also exported from@lexical/code) is an opt-in render override. It setsdata-lexical-code-line-numberson each code block and wraps the<br>of each code line break in<span data-lexical-code-line-break>, exposed through$getDOMSlot, so a theme can number lines with a CSS counter that stays with each line when it wraps. Exported and copied HTML is unchanged.onlyWordWrappedlimits it to blocks with word wrap on, and both config options are signals that can be flipped at runtime.Playground. A Wrap lines button in the code action menu, per line numbers on wrapped blocks by default, and a "Per Line Numbers in All Code Blocks" setting. The theme numbers lines with sticky, RTL-aware gutters.
Highlighters. Prism and Shiki restored a caret right after a line break by child index when they retokenized, so if an earlier line was split into more tokens (Shiki's grammar landing just after Enter, or an Enter that splits a token) the next characters went into the earlier line. They now restore it by text offset, which also stops other carets on the code node from shifting by the text before the block, and a range that starts before the block keeps its focus.
The Flow types declare the new API, and now also
CodeNode's theme accessors,$createCodeNode'sthemeargument andSerializedCodeNode.theme, withsetLanguagereturning the node as in TypeScript.Closes #8459
Changeset
.changeset/code-word-wrap-line-numbers.md([feature],@lexical/code-core,@lexical/code,@lexical/code-prismand@lexical/code-shiki), with aVerify:note thatCodeNode.exportJSON()now has an ownwordWrapkey (undefinedwhile off), as it has fortheme.Test plan
Before
No word wrap API, and no way to number wrapped code lines.
After
Unit tests for the word wrap flag (JSON, DOM, HTML round trip), caret retention in both highlighters, the line numbers extension (both modes, runtime toggles, export, selection resolution, undo, collab-style moves, VisibleNonPrinting composition) and the playground's wrap button; browser tests for wrapped-line numbering and caret scrolling with both gutters; e2e specs for the wrap button and caret scrolling with Prism and Shiki.