Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
6d8f737
[lexical-code-core][lexical-code] Feature: word wrap flag on CodeNode…
Om-singhaI Sep 24, 2026
602eb10
[lexical-code-core][lexical-code][lexical-website] Feature: CodeLineN…
Om-singhaI Sep 24, 2026
f6555d2
[lexical-playground] Feature: wrap lines toggle and per line numbers …
Om-singhaI Sep 24, 2026
5c75775
[lexical-playground] Chore: browser and e2e tests for code block word…
Om-singhaI Sep 24, 2026
b7dadbb
[lexical-playground] Chore: browser test for insertLineBreak(true) at…
Om-singhaI Sep 24, 2026
a1671db
[lexical-code-core][lexical-playground] Bug Fix: keep line break wrap…
Om-singhaI Sep 28, 2026
adf0d3f
[lexical-code-core][lexical-code][lexical-playground] Chore: Merge up…
claude Oct 29, 2026
58a6a1c
[lexical-code-core] Refactor: Store the code block word wrap flag in …
claude Oct 29, 2026
dc26969
[lexical][lexical-code-core][lexical-playground] Bug Fix: Move a care…
claude Oct 29, 2026
6febdf6
[lexical][lexical-code-core] Bug Fix: Revert writing the caret back o…
claude Oct 29, 2026
f847a73
[lexical-code-core][lexical-playground] Bug Fix: Keep render signals …
claude Oct 29, 2026
94a8ee7
[lexical-playground] Bug Fix: Refresh the code menu's language along …
claude Oct 29, 2026
1f65ceb
[lexical-code-core] Documentation Update: Say the line numbers extens…
claude Oct 29, 2026
b031de8
[lexical-code-core] Documentation Update: Point the line numbers docs…
claude Oct 29, 2026
fe1757d
[lexical-code-core][lexical-playground] Documentation Update: Describ…
claude Oct 29, 2026
4005248
[lexical-code-core][lexical-code] Bug Fix: Match the Flow CodeNode ac…
claude Oct 29, 2026
5a6eef7
[lexical-code-core][lexical-code] Bug Fix: Declare the code block the…
claude Oct 29, 2026
9b8c8d4
[lexical-code-core] Documentation Update: Restyle rather than hide an…
claude Oct 29, 2026
c00a30d
[lexical-code] Chore: Expect the wordWrap key in CodeNode's exportJSO…
claude Oct 29, 2026
356e7bc
Merge remote-tracking branch 'origin/main' into feature/code-block-wr…
etrepum Oct 29, 2026
e3aacab
[lexical-code-prism][lexical-code-shiki] Bug Fix: Keep a caret after …
claude Oct 29, 2026
99584d2
[lexical-code-prism][lexical-code-shiki] Chore: Type the code block l…
claude Oct 29, 2026
450a5ff
[lexical-code-core][lexical-code][lexical-playground] Refactor: Make …
claude Oct 29, 2026
7f0b24c
[lexical-playground] Chore: Drop the e2e wait for Shiki tokens before…
claude Oct 29, 2026
6573075
[lexical-code-prism][lexical-code-shiki] Chore: Cover an element care…
claude Oct 29, 2026
42a7bad
[lexical-code-core][lexical-code-prism][lexical-code-shiki][lexical-w…
claude Oct 29, 2026
11eb3a9
[lexical-code-prism][lexical-code-shiki] Documentation Update: Note t…
claude Oct 29, 2026
6b45eab
[lexical-code-prism][lexical-code-shiki] Bug Fix: Keep a range select…
claude Oct 29, 2026
4e9a3e0
[lexical-code-prism][lexical-code-shiki] Documentation Update: Descri…
claude Oct 29, 2026
8c93162
[lexical-code-core][lexical-code-prism][lexical-code-shiki][lexical-p…
claude Oct 29, 2026
943f982
[lexical-code-core] Documentation Update: Reserve the gutter in the R…
claude Oct 29, 2026
d6ad0a5
[lexical-code-prism][lexical-code-shiki] Bug Fix: Restore a retokeniz…
claude Oct 29, 2026
6b8c0f5
[lexical-code-core] Documentation Update: Keep line numbers out of sc…
claude Oct 29, 2026
cefa5d1
[lexical-code-prism][lexical-code-shiki] Bug Fix: Keep the focus of a…
claude Oct 29, 2026
f2b2050
Merge branch 'main' into feature/code-block-wrap-and-scroll
etrepum Oct 29, 2026
c48f08f
Merge branch 'main' into feature/code-block-wrap-and-scroll
etrepum Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/code-word-wrap-line-numbers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@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

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 `<br>` of each of its line breaks in a `<span data-lexical-code-line-break>`, 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.

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.
93 changes: 91 additions & 2 deletions packages/lexical-code-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,94 @@ 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 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 `<br>` of each `LineBreakNode` in a code block is wrapped in `<span data-lexical-code-line-break>`

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);
/* empty alternative text, so screen readers don't read the numbers */
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. Neither do
the wrappers: the default `exportDOM` of a `LineBreakNode` goes through the same `$createDOM` overrides, so the
extension leaves the `<br>` unwrapped while `RenderContextExport` is set. Exported and copied HTML is the same as without
the extension, including what other `$createDOM` overrides do to the `<br>`. The highlighters keep writing `data-gutter`
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.

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 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.

## 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. 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 `<pre>` 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;
}
```

`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` 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

When Lexical moves the caret itself, it scrolls the code block sideways to show the caret. That happens on the line
Expand All @@ -19,8 +107,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 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
(`PlaygroundEditorTheme.css`) does both.
(`PlaygroundEditorTheme.css`) does all of this.
26 changes: 24 additions & 2 deletions packages/lexical-code-core/flow/LexicalCodeCore.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import type {
ExtensionConfigBase,
} from 'lexical';

import type {NamedSignalsOutput} from '@lexical/extension';

import {ElementNode, TextNode} from 'lexical';

/**
Expand Down Expand Up @@ -86,10 +88,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,
Expand All @@ -104,8 +111,12 @@ declare export class CodeNode extends ElementNode {
restoreSelection?: boolean,
): null | ParagraphNode | CodeHighlightNode | TabNode;
collapseAtStart(): true;
setLanguage(language: string): void;
setLanguage(language: ?string): this;
getLanguage(): string | void;
setTheme(theme: ?string): this;
getTheme(): string | void;
getWordWrap(): boolean;
setWordWrap(wordWrap: boolean): this;
}

/**
Expand All @@ -127,3 +138,14 @@ declare export function registerCodeIndentation(
): () => void;

declare export var CodeIndentExtension: LexicalExtension<CodeIndentConfig, "@lexical/code-indent", unknown, unknown>;

/**
* CodeLineNumbers
*/

export type CodeLineNumbersConfig = {
disabled: boolean,
onlyWordWrapped: boolean,
};

declare export var CodeLineNumbersExtension: LexicalExtension<CodeLineNumbersConfig, "@lexical/code-line-numbers", NamedSignalsOutput<CodeLineNumbersConfig>, void>;
23 changes: 11 additions & 12 deletions packages/lexical-code-core/src/CodeImportExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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'),
Expand Down
Loading
Loading