From f7bb23a1c691238d6f125d08ff9db1f8c7fedc9e Mon Sep 17 00:00:00 2001 From: KesavSundarM Date: Wed, 6 May 2026 15:48:35 +0530 Subject: [PATCH 1/2] feat(code): add horizontal auto-scroll and word wrap mode for code blocks Fixes https://github.com/facebook/lexical/issues/8459 - Add horizontal scrolling to scrollIntoViewIfNeeded so cursor navigation (Ctrl+ArrowRight/Left) auto-scrolls within overflowing code blocks - Start scroll traversal from the selection's DOM ancestor to encounter intermediate scrollable containers (like with overflow-x: auto) - Add wordWrap property to CodeNode with getDOMSlot override for adaptive gutter using real DOM elements instead of ::before pseudo-element - Add ResizeObserver to sync gutter line heights with wrapped content - Add Word Wrap toggle button in playground CodeActionMenu - Add unit tests for horizontal scrolling behavior --- .env | 6 + packages/lexical-code-core/src/CodeNode.ts | 48 ++++++- .../src/CodeHighlighterPrism.ts | 134 +++++++++++++++++- .../src/CodeHighlighterShiki.ts | 121 +++++++++++++++- .../components/WordWrapButton/index.tsx | 47 ++++++ .../plugins/CodeActionMenuPlugin/index.tsx | 2 + .../src/themes/PlaygroundEditorTheme.css | 32 +++++ packages/lexical/src/LexicalSelection.ts | 17 ++- packages/lexical/src/LexicalUtils.ts | 33 ++++- .../src/__tests__/unit/LexicalUtils.test.ts | 74 ++++++++++ 10 files changed, 502 insertions(+), 12 deletions(-) create mode 100644 .env create mode 100644 packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WordWrapButton/index.tsx diff --git a/.env b/.env new file mode 100644 index 00000000000..5d453a33df4 --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +export PUPPETEER_EXECUTABLE_PATH=chromium not found +export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +export PUPPETEER_EXECUTABLE_PATH=chromium not found +export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +export PUPPETEER_EXECUTABLE_PATH=chromium not found diff --git a/packages/lexical-code-core/src/CodeNode.ts b/packages/lexical-code-core/src/CodeNode.ts index bb43c23d1b5..c00a791449c 100644 --- a/packages/lexical-code-core/src/CodeNode.ts +++ b/packages/lexical-code-core/src/CodeNode.ts @@ -34,9 +34,11 @@ import { $isTabNode, $isTextNode, addClassNamesToElement, + ElementDOMSlot, ElementNode, isHTMLElement, setDOMStyleFromCSS, + setDOMUnmanaged, } from 'lexical'; import warnOnlyOnce from 'shared/warnOnlyOnce'; @@ -51,6 +53,7 @@ export type SerializedCodeNode = Spread< { language: string | null | undefined; theme?: string | undefined; + wordWrap?: boolean | undefined; }, SerializedElementNode >; @@ -84,6 +87,8 @@ export class CodeNode extends ElementNode { __theme: string | undefined; /** @internal */ __isSyntaxHighlightSupported: boolean; + /** @internal */ + __wordWrap: boolean; static getType(): string { return 'code'; @@ -98,6 +103,7 @@ export class CodeNode extends ElementNode { this.__language = language || undefined; this.__isSyntaxHighlightSupported = false; this.__theme = undefined; + this.__wordWrap = false; } afterCloneFrom(prevNode: this): void { @@ -105,6 +111,7 @@ export class CodeNode extends ElementNode { this.__language = prevNode.__language; this.__theme = prevNode.__theme; this.__isSyntaxHighlightSupported = prevNode.__isSyntaxHighlightSupported; + this.__wordWrap = prevNode.__wordWrap; } // View @@ -129,9 +136,36 @@ export class CodeNode extends ElementNode { if (style) { setDOMStyleFromCSS(element.style, style); } + + if (this.__wordWrap) { + element.setAttribute('data-word-wrap', 'true'); + const gutterEl = document.createElement('div'); + gutterEl.className = 'code-gutter'; + setDOMUnmanaged(gutterEl); + element.appendChild(gutterEl); + const contentEl = document.createElement('div'); + contentEl.className = 'code-content'; + element.appendChild(contentEl); + } + return element; } + + getDOMSlot(element: HTMLElement): ElementDOMSlot { + if (this.__wordWrap) { + const contentEl = element.querySelector('.code-content'); + if (contentEl) { + return super.getDOMSlot(element).withElement(contentEl); + } + } + return super.getDOMSlot(element); + } updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { + // Force re-creation if wordWrap changes (DOM structure is different) + if (this.__wordWrap !== prevNode.__wordWrap) { + return true; + } + const language = this.__language; const prevLanguage = prevNode.__language; @@ -279,7 +313,8 @@ export class CodeNode extends ElementNode { return super .updateFromJSON(serializedNode) .setLanguage(serializedNode.language) - .setTheme(serializedNode.theme); + .setTheme(serializedNode.theme) + .setWordWrap(serializedNode.wordWrap || false); } exportJSON(): SerializedCodeNode { @@ -287,6 +322,7 @@ export class CodeNode extends ElementNode { ...super.exportJSON(), language: this.getLanguage(), theme: this.getTheme(), + ...(this.__wordWrap ? {wordWrap: true} : undefined), }; } @@ -405,6 +441,16 @@ export class CodeNode extends ElementNode { getTheme(): string | undefined { return this.getLatest().__theme; } + + setWordWrap(wordWrap: boolean): this { + const writable = this.getWritable(); + writable.__wordWrap = wordWrap; + return writable; + } + + getWordWrap(): boolean { + return this.getLatest().__wordWrap; + } } export function $createCodeNode( diff --git a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts index 0e0c423c49a..dee2a5d929f 100644 --- a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts +++ b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts @@ -133,14 +133,100 @@ function updateCodeGutter(node: CodeNode, editor: LexicalEditor): void { } // @ts-ignore:: internal field codeElement.__cachedChildrenLength = childrenLength; - let gutter = '1'; let count = 1; for (let i = 0; i < childrenLength; i++) { if ($isLineBreakNode(children[i])) { - gutter += '\n' + ++count; + count++; + } + } + + if (node.getWordWrap()) { + // Word-wrap mode: update real DOM gutter elements + const gutterEl = codeElement.querySelector('.code-gutter'); + if (gutterEl) { + // Sync number of gutter line elements + while (gutterEl.children.length > count) { + gutterEl.removeChild(gutterEl.lastChild!); + } + while (gutterEl.children.length < count) { + const span = document.createElement('span'); + span.textContent = String(gutterEl.children.length + 1); + gutterEl.appendChild(span); + } + // Update text content for all lines + for (let i = 0; i < count; i++) { + const span = gutterEl.children[i] as HTMLElement; + const lineNum = String(i + 1); + if (span.textContent !== lineNum) { + span.textContent = lineNum; + } + } + // Sync heights after DOM update + syncGutterHeights(codeElement); + } + } else { + // Classic mode: data-gutter attribute + let gutter = '1'; + for (let i = 1; i < count; i++) { + gutter += '\n' + (i + 1); + } + codeElement.setAttribute('data-gutter', gutter); + } +} + +function syncGutterHeights(codeElement: HTMLElement): void { + const gutterEl = codeElement.querySelector('.code-gutter'); + const contentEl = codeElement.querySelector('.code-content'); + if (!gutterEl || !contentEl) { + return; + } + + const children = contentEl.childNodes; + let lineStart = 0; + + // Measure heights of each logical line in the content + // Lines are separated by
elements (LineBreakNode renders as
) + const lineHeights: number[] = []; + const range = document.createRange(); + + for (let i = 0; i <= children.length; i++) { + const child = children[i]; + const isEnd = i === children.length; + const isBreak = child && child.nodeName === 'BR'; + + if (isEnd || isBreak) { + // Measure height of this logical line + if (lineStart < i) { + range.setStartBefore(children[lineStart]); + range.setEndAfter(children[i - 1]); + const rects = range.getClientRects(); + let height = 0; + if (rects.length > 0) { + const first = rects[0]; + const last = rects[rects.length - 1]; + height = last.bottom - first.top; + } + lineHeights.push(height); + } else { + // Empty line — use line-height + lineHeights.push(0); + } + lineStart = i + 1; + } + } + + // Apply heights to gutter spans + for (let i = 0; i < gutterEl.children.length && i < lineHeights.length; i++) { + const span = gutterEl.children[i] as HTMLElement; + const h = lineHeights[i]; + if (h > 0) { + span.style.height = h + 'px'; + span.style.lineHeight = h + 'px'; + } else { + span.style.height = ''; + span.style.lineHeight = ''; } } - codeElement.setAttribute('data-gutter', gutter); } function $codeNodeTransform( @@ -771,16 +857,49 @@ export function registerCodeHighlighting( // Only register the mutation listener if not in headless mode if (editor._headless !== true) { + const resizeObservers = new Map(); + registrations.push( editor.registerMutationListener( CodeNode, mutations => { editor.getEditorState().read(() => { for (const [key, type] of mutations) { - if (type !== 'destroyed') { + if (type === 'destroyed') { + // Clean up ResizeObserver for destroyed nodes + const observer = resizeObservers.get(key); + if (observer) { + observer.disconnect(); + resizeObservers.delete(key); + } + } else { const node = $getNodeByKey(key); if (node !== null) { updateCodeGutter(node as CodeNode, editor); + + // Set up ResizeObserver for word-wrap mode + const codeNode = node as CodeNode; + const codeElement = editor.getElementByKey(key); + if (codeNode.getWordWrap() && codeElement) { + if (!resizeObservers.has(key)) { + const contentEl = + codeElement.querySelector('.code-content'); + if (contentEl) { + const observer = new ResizeObserver(() => { + syncGutterHeights(codeElement); + }); + observer.observe(contentEl); + resizeObservers.set(key, observer); + } + } + } else { + // Clean up observer if word wrap was disabled + const observer = resizeObservers.get(key); + if (observer) { + observer.disconnect(); + resizeObservers.delete(key); + } + } } } } @@ -788,6 +907,13 @@ export function registerCodeHighlighting( }, {skipInitialization: false}, ), + // Cleanup all observers on unmount + () => { + for (const observer of resizeObservers.values()) { + observer.disconnect(); + } + resizeObservers.clear(); + }, ); } diff --git a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts index 2a597f978f4..45a70e80ed6 100644 --- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts +++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts @@ -130,14 +130,91 @@ function updateCodeGutter(node: CodeNode, editor: LexicalEditor): void { } // @ts-ignore:: internal field codeElement.__cachedChildrenLength = childrenLength; - let gutter = '1'; let count = 1; for (let i = 0; i < childrenLength; i++) { if ($isLineBreakNode(children[i])) { - gutter += '\n' + ++count; + count++; + } + } + + if (node.getWordWrap()) { + // Word-wrap mode: update real DOM gutter elements + const gutterEl = codeElement.querySelector('.code-gutter'); + if (gutterEl) { + while (gutterEl.children.length > count) { + gutterEl.removeChild(gutterEl.lastChild!); + } + while (gutterEl.children.length < count) { + const span = document.createElement('span'); + span.textContent = String(gutterEl.children.length + 1); + gutterEl.appendChild(span); + } + for (let i = 0; i < count; i++) { + const span = gutterEl.children[i] as HTMLElement; + const lineNum = String(i + 1); + if (span.textContent !== lineNum) { + span.textContent = lineNum; + } + } + syncGutterHeights(codeElement); + } + } else { + // Classic mode: data-gutter attribute + let gutter = '1'; + for (let i = 1; i < count; i++) { + gutter += '\n' + (i + 1); + } + codeElement.setAttribute('data-gutter', gutter); + } +} + +function syncGutterHeights(codeElement: HTMLElement): void { + const gutterEl = codeElement.querySelector('.code-gutter'); + const contentEl = codeElement.querySelector('.code-content'); + if (!gutterEl || !contentEl) { + return; + } + + const children = contentEl.childNodes; + const lineHeights: number[] = []; + const range = document.createRange(); + let lineStart = 0; + + for (let i = 0; i <= children.length; i++) { + const child = children[i]; + const isEnd = i === children.length; + const isBreak = child && child.nodeName === 'BR'; + + if (isEnd || isBreak) { + if (lineStart < i) { + range.setStartBefore(children[lineStart]); + range.setEndAfter(children[i - 1]); + const rects = range.getClientRects(); + let height = 0; + if (rects.length > 0) { + const first = rects[0]; + const last = rects[rects.length - 1]; + height = last.bottom - first.top; + } + lineHeights.push(height); + } else { + lineHeights.push(0); + } + lineStart = i + 1; + } + } + + for (let i = 0; i < gutterEl.children.length && i < lineHeights.length; i++) { + const span = gutterEl.children[i] as HTMLElement; + const h = lineHeights[i]; + if (h > 0) { + span.style.height = h + 'px'; + span.style.lineHeight = h + 'px'; + } else { + span.style.height = ''; + span.style.lineHeight = ''; } } - codeElement.setAttribute('data-gutter', gutter); } interface TransformState { @@ -784,16 +861,46 @@ export function registerCodeHighlighting( // Only register the mutation listener if not in headless mode if (editor._headless !== true) { + const resizeObservers = new Map(); + registrations.push( editor.registerMutationListener( CodeNode, mutations => { editor.getEditorState().read(() => { for (const [key, type] of mutations) { - if (type !== 'destroyed') { + if (type === 'destroyed') { + const observer = resizeObservers.get(key); + if (observer) { + observer.disconnect(); + resizeObservers.delete(key); + } + } else { const node = $getNodeByKey(key); if (node !== null) { updateCodeGutter(node as CodeNode, editor); + + const codeNode = node as CodeNode; + const codeElement = editor.getElementByKey(key); + if (codeNode.getWordWrap() && codeElement) { + if (!resizeObservers.has(key)) { + const contentEl = + codeElement.querySelector('.code-content'); + if (contentEl) { + const observer = new ResizeObserver(() => { + syncGutterHeights(codeElement); + }); + observer.observe(contentEl); + resizeObservers.set(key, observer); + } + } + } else { + const observer = resizeObservers.get(key); + if (observer) { + observer.disconnect(); + resizeObservers.delete(key); + } + } } } } @@ -801,6 +908,12 @@ export function registerCodeHighlighting( }, {skipInitialization: false}, ), + () => { + for (const observer of resizeObservers.values()) { + observer.disconnect(); + } + resizeObservers.clear(); + }, ); } diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WordWrapButton/index.tsx b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WordWrapButton/index.tsx new file mode 100644 index 00000000000..b49638bb97d --- /dev/null +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WordWrapButton/index.tsx @@ -0,0 +1,47 @@ +/** + * 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 {$getNearestNodeFromDOMNode, LexicalEditor} from 'lexical'; +import * as React from 'react'; + +interface Props { + editor: LexicalEditor; + getCodeDOMNode: () => HTMLElement | null; +} + +export function WordWrapButton({editor, getCodeDOMNode}: Props) { + const [isWordWrap, setIsWordWrap] = React.useState(false); + + function handleClick(): void { + const codeDOMNode = getCodeDOMNode(); + + if (!codeDOMNode) { + return; + } + + editor.update(() => { + const codeNode = $getNearestNodeFromDOMNode(codeDOMNode); + + if ($isCodeNode(codeNode)) { + const newWordWrap = !codeNode.getWordWrap(); + codeNode.setWordWrap(newWordWrap); + setIsWordWrap(newWordWrap); + } + }); + } + + return ( + + ); +} diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx index 7f47bdf0768..8c91c74e563 100644 --- a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx @@ -23,6 +23,7 @@ import {createPortal} from 'react-dom'; import {CopyButton} from './components/CopyButton'; import {PrettierButton} from './components/PrettierButton'; +import {WordWrapButton} from './components/WordWrapButton'; import {canBePrettier} from './formatCodeWithPrettier'; import {useDebounce} from './utils'; @@ -146,6 +147,7 @@ function CodeActionMenuContainer({
{codeFriendlyName}
+ {canBePrettier(normalizedLang) ? ( targetRight) { + hDiff = currentRight - targetRight; + } + + if (hDiff !== 0) { + if (isBodyElement) { + defaultView.scrollBy(hDiff, 0); + } else { + const scrollLeft = element.scrollLeft; + element.scrollLeft += hDiff; + const xOffset = element.scrollLeft - scrollLeft; + currentLeft -= xOffset; + currentRight -= xOffset; + } + } + if (isBodyElement) { break; } diff --git a/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts b/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts index 912493cd17f..db17dbcd33b 100644 --- a/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts @@ -545,6 +545,80 @@ describe('LexicalUtils tests', () => { doc.documentElement.style.scrollPaddingTop = ''; } }); + + test('scrollIntoViewIfNeeded handles horizontal scrolling', () => { + const {editor} = testEnv; + const rootElement = editor.getRootElement()!; + + // Create a code block element that is a child of rootElement + // simulating overflow-x: auto with limited width + const codeBlock = document.createElement('code'); + codeBlock.style.overflowX = 'auto'; + codeBlock.style.width = '200px'; + codeBlock.style.display = 'block'; + rootElement.appendChild(codeBlock); + + // Mock getBoundingClientRect for the code block + const codeBlockRect = new DOMRect(0, 0, 200, 100); + vi.spyOn(codeBlock, 'getBoundingClientRect').mockReturnValue( + codeBlockRect, + ); + + // Selection rect is beyond the right edge of the code block (at x=250) + const selectionRect = new DOMRect(250, 50, 2, 20); + + // scrollLeft should be adjusted + codeBlock.scrollLeft = 0; + // Mock scrollLeft setter behavior (jsdom doesn't handle overflow) + Object.defineProperty(codeBlock, 'scrollLeft', { + configurable: true, + get: function () { + return this._scrollLeft || 0; + }, + set: function (val) { + this._scrollLeft = val; + }, + }); + + scrollIntoViewIfNeeded(editor, selectionRect, rootElement, codeBlock); + + // The cursor right edge (250+2=252) is 52px past the container right (200px) + // So scrollLeft should be increased by 52 + expect(codeBlock.scrollLeft).toBe(52); + }); + + test('scrollIntoViewIfNeeded scrolls left when cursor is before left edge', () => { + const {editor} = testEnv; + const rootElement = editor.getRootElement()!; + + const codeBlock = document.createElement('code'); + rootElement.appendChild(codeBlock); + + // Code block starts at x=50 + const codeBlockRect = new DOMRect(50, 0, 200, 100); + vi.spyOn(codeBlock, 'getBoundingClientRect').mockReturnValue( + codeBlockRect, + ); + + // Selection rect is before the left edge of the code block (at x=20) + const selectionRect = new DOMRect(20, 50, 2, 20); + + Object.defineProperty(codeBlock, 'scrollLeft', { + configurable: true, + get: function () { + return this._scrollLeft || 0; + }, + set: function (val) { + this._scrollLeft = val; + }, + }); + + scrollIntoViewIfNeeded(editor, selectionRect, rootElement, codeBlock); + + // The cursor at x=20 is 30px before the left edge (50px) + // So scrollLeft should be decreased by 30 + expect(codeBlock.scrollLeft).toBe(-30); + }); }); }); describe('$applyNodeReplacement', () => { From 2d9caa2d80ade1909c51edc3c321b3ae62997c75 Mon Sep 17 00:00:00 2001 From: KesavSundarM Date: Thu, 14 May 2026 15:42:15 +0530 Subject: [PATCH 2/2] Addressed Review Comments & minor design bug fix --- .env | 6 - packages/lexical-code-core/src/CodeGutter.ts | 267 ++++++++++++++++++ packages/lexical-code-core/src/CodeNode.ts | 67 +++-- .../src/__tests__/unit/CodeGutter.test.ts | 149 ++++++++++ .../src/__tests__/unit/CodeNode.test.ts | 48 +++- packages/lexical-code-core/src/index.ts | 5 + .../src/CodeHighlighterPrism.ts | 201 +------------ .../src/CodeHighlighterShiki.ts | 188 +----------- .../plugins/CodeActionMenuPlugin/index.css | 2 + 9 files changed, 550 insertions(+), 383 deletions(-) delete mode 100644 .env create mode 100644 packages/lexical-code-core/src/CodeGutter.ts create mode 100644 packages/lexical-code-core/src/__tests__/unit/CodeGutter.test.ts diff --git a/.env b/.env deleted file mode 100644 index 5d453a33df4..00000000000 --- a/.env +++ /dev/null @@ -1,6 +0,0 @@ -export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true -export PUPPETEER_EXECUTABLE_PATH=chromium not found -export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true -export PUPPETEER_EXECUTABLE_PATH=chromium not found -export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true -export PUPPETEER_EXECUTABLE_PATH=chromium not found diff --git a/packages/lexical-code-core/src/CodeGutter.ts b/packages/lexical-code-core/src/CodeGutter.ts new file mode 100644 index 00000000000..20f8a399034 --- /dev/null +++ b/packages/lexical-code-core/src/CodeGutter.ts @@ -0,0 +1,267 @@ +/** + * 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 type {LexicalEditor} from 'lexical'; + +import {effect, namedSignals} from '@lexical/extension'; +import { + $getNodeByKey, + $isLineBreakNode, + defineExtension, + mergeRegister, + safeCast, +} from 'lexical'; + +import {CodeExtension} from './CodeExtension'; +import {$isCodeNode, CodeNode} from './CodeNode'; + +/** + * Update the gutter for a given {@link CodeNode}. + * + * In classic mode (no word-wrap) the line numbers are written into the + * `data-gutter` attribute on the code element so they can be rendered by + * a CSS pseudo-element. In word-wrap mode, the gutter is a real DOM + * element (`.code-gutter`) populated with one `` per line; the + * span heights are then synced to the wrapped content lines via + * {@link syncGutterHeights}. + * + * The DOM contract (`.code-gutter` / `.code-content` children) is + * established by {@link CodeNode#createDOM} when word-wrap is enabled. + */ +function updateCodeGutter(node: CodeNode, editor: LexicalEditor): void { + const codeElement = editor.getElementByKey(node.getKey()); + if (codeElement === null) { + return; + } + const children = node.getChildren(); + const childrenLength = children.length; + // @ts-ignore: internal field + if (childrenLength === codeElement.__cachedChildrenLength) { + // Avoid updating the attribute if the children length hasn't changed. + return; + } + // @ts-ignore:: internal field + codeElement.__cachedChildrenLength = childrenLength; + let count = 1; + for (let i = 0; i < childrenLength; i++) { + if ($isLineBreakNode(children[i])) { + count++; + } + } + + if (node.getWordWrap()) { + // Word-wrap mode: update real DOM gutter elements + const gutterEl = codeElement.querySelector('.code-gutter'); + if (gutterEl) { + // Sync number of gutter line elements + while (gutterEl.children.length > count) { + gutterEl.removeChild(gutterEl.lastChild!); + } + while (gutterEl.children.length < count) { + const span = document.createElement('span'); + span.textContent = String(gutterEl.children.length + 1); + gutterEl.appendChild(span); + } + // Update text content for all lines + for (let i = 0; i < count; i++) { + const span = gutterEl.children[i] as HTMLElement; + const lineNum = String(i + 1); + if (span.textContent !== lineNum) { + span.textContent = lineNum; + } + } + // Sync heights after DOM update + syncGutterHeights(codeElement); + } + } else { + // Classic mode: data-gutter attribute + let gutter = '1'; + for (let i = 1; i < count; i++) { + gutter += '\n' + (i + 1); + } + codeElement.setAttribute('data-gutter', gutter); + } +} + +/** + * Measure the height of each logical code line in the `.code-content` + * subtree (lines are separated by `
` elements that LineBreakNode + * renders) and apply the measured height to the matching `.code-gutter` + * span so the gutter line numbers stay vertically aligned with their + * (possibly wrapped) content lines. + */ +function syncGutterHeights(codeElement: HTMLElement): void { + const gutterEl = codeElement.querySelector('.code-gutter'); + const contentEl = codeElement.querySelector('.code-content'); + if (!gutterEl || !contentEl) { + return; + } + + const children = contentEl.childNodes; + let lineStart = 0; + + // Measure heights of each logical line in the content + // Lines are separated by
elements (LineBreakNode renders as
) + const lineHeights: number[] = []; + const range = document.createRange(); + + for (let i = 0; i <= children.length; i++) { + const child = children[i]; + const isEnd = i === children.length; + const isBreak = child && child.nodeName === 'BR'; + + if (isEnd || isBreak) { + // Measure height of this logical line + if (lineStart < i) { + range.setStartBefore(children[lineStart]); + range.setEndAfter(children[i - 1]); + const rects = range.getClientRects(); + let height = 0; + if (rects.length > 0) { + const first = rects[0]; + const last = rects[rects.length - 1]; + height = last.bottom - first.top; + } + lineHeights.push(height); + } else { + // Empty line — use line-height + lineHeights.push(0); + } + lineStart = i + 1; + } + } + + // Apply heights to gutter spans + for (let i = 0; i < gutterEl.children.length && i < lineHeights.length; i++) { + const span = gutterEl.children[i] as HTMLElement; + const h = lineHeights[i]; + if (h > 0) { + span.style.height = h + 'px'; + span.style.lineHeight = h + 'px'; + } else { + span.style.height = ''; + span.style.lineHeight = ''; + } + } +} + +/** + * Register a {@link CodeNode} mutation listener that keeps the code + * block gutter (line numbers + their heights in word-wrap mode) in sync + * with the editor state. + * + * Previously this code was duplicated inside both + * `@lexical/code-prism` and `@lexical/code-shiki`; consolidating it + * here lets either highlighter — or any other consumer that depends on + * `@lexical/code-core` — share the same DOM contract established by + * {@link CodeNode#createDOM}. + * + * In headless mode this is a no-op (no DOM to update); the returned + * cleanup function is still safe to call. + */ +export function registerCodeGutter(editor: LexicalEditor): () => void { + // Headless editors have no DOM to mutate. + if (editor._headless === true) { + return () => {}; + } + const resizeObservers = new Map(); + + return mergeRegister( + editor.registerMutationListener( + CodeNode, + mutations => { + editor.getEditorState().read(() => { + for (const [key, type] of mutations) { + if (type === 'destroyed') { + // Clean up ResizeObserver for destroyed nodes + const observer = resizeObservers.get(key); + if (observer) { + observer.disconnect(); + resizeObservers.delete(key); + } + } else { + const node = $getNodeByKey(key); + if (node !== null && $isCodeNode(node)) { + updateCodeGutter(node, editor); + + // Set up ResizeObserver for word-wrap mode + const codeElement = editor.getElementByKey(key); + if (node.getWordWrap() && codeElement) { + if (!resizeObservers.has(key)) { + const contentEl = + codeElement.querySelector('.code-content'); + if (contentEl) { + const observer = new ResizeObserver(() => { + syncGutterHeights(codeElement); + }); + observer.observe(contentEl); + resizeObservers.set(key, observer); + } + } + } else { + // Clean up observer if word wrap was disabled + const observer = resizeObservers.get(key); + if (observer) { + observer.disconnect(); + resizeObservers.delete(key); + } + } + } + } + } + }); + }, + {skipInitialization: false}, + ), + // Cleanup all observers on unmount + () => { + for (const observer of resizeObservers.values()) { + observer.disconnect(); + } + resizeObservers.clear(); + }, + ); +} + +export interface CodeGutterConfig { + /** + * When true, the gutter mutation listener is not registered on the + * editor. This signal can be flipped at runtime to enable or disable + * gutter rendering without rebuilding the editor. + */ + disabled: boolean; +} + +/** + * Manages the line-number gutter for {@link "@lexical/code-core".CodeNode} + * blocks (both classic `data-gutter` mode and the word-wrap mode that + * uses real DOM `.code-gutter` / `.code-content` children). + * + * Both {@link "@lexical/code-shiki".CodeShikiExtension} and + * {@link "@lexical/code-prism".CodePrismExtension} declare this as a + * dependency, mirroring how + * {@link "@lexical/code-core".CodeIndentExtension} is consumed, so the + * gutter is activated automatically alongside either highlighter. + */ +export const CodeGutterExtension = defineExtension({ + build: (_editor, config) => namedSignals(config), + config: safeCast({ + disabled: false, + }), + dependencies: [CodeExtension], + name: '@lexical/code-gutter', + register: (editor, _config, state) => { + const stores = state.getOutput(); + return effect(() => { + if (stores.disabled.value) { + return; + } + return registerCodeGutter(editor); + }); + }, +}); diff --git a/packages/lexical-code-core/src/CodeNode.ts b/packages/lexical-code-core/src/CodeNode.ts index a1b79eb8c00..e96d0c2ca80 100644 --- a/packages/lexical-code-core/src/CodeNode.ts +++ b/packages/lexical-code-core/src/CodeNode.ts @@ -20,6 +20,8 @@ import type { RangeSelection, SerializedElementNode, Spread, + StateConfigValue, + StateValueOrUpdater, TabNode, } from 'lexical'; @@ -30,13 +32,17 @@ import { $createParagraphNode, $createTabNode, $getEditor, + $getState, $isLineBreakNode, $isTabNode, $isTextNode, + $setState, addClassNamesToElement, + createState, ElementDOMSlot, ElementNode, isHTMLElement, + NODE_STATE_KEY, setDOMStyleFromCSS, setDOMUnmanaged, } from 'lexical'; @@ -77,6 +83,22 @@ const LANGUAGE_DATA_ATTRIBUTE = 'data-language'; const HIGHLIGHT_LANGUAGE_DATA_ATTRIBUTE = 'data-highlight-language'; const THEME_DATA_ATTRIBUTE = 'data-theme'; +/** + * NodeState backing for the `wordWrap` flag on a {@link CodeNode}. Using + * {@link createState} replaces what would otherwise be a `__wordWrap` + * field plus matching `clone`/`afterCloneFrom`/`updateFromJSON`/setters + * boilerplate with a single state config plus thin getter/setter + * wrappers; the framework handles cloning and copy-on-write automatically. + * + * The legacy top-level `wordWrap` JSON shape is preserved by hand in + * {@link CodeNode#updateFromJSON} and {@link CodeNode#exportJSON} (the + * latter strips the auto-serialized entry from `$`), so consumers see + * the same wire format as before this migration. + */ +const wordWrapState = createState('wordWrap', { + parse: v => (typeof v === 'boolean' ? v : false), +}); + const noExtensionDeprecation = warnOnlyOnce( 'Using CodeNode without CodeExtension is deprecated', ); @@ -89,8 +111,6 @@ export class CodeNode extends ElementNode { __theme: string | undefined; /** @internal */ __isSyntaxHighlightSupported: boolean; - /** @internal */ - __wordWrap: boolean; static getType(): string { return 'code'; @@ -105,7 +125,6 @@ export class CodeNode extends ElementNode { this.__language = language || undefined; this.__isSyntaxHighlightSupported = false; this.__theme = undefined; - this.__wordWrap = false; } afterCloneFrom(prevNode: this): void { @@ -113,7 +132,6 @@ export class CodeNode extends ElementNode { this.__language = prevNode.__language; this.__theme = prevNode.__theme; this.__isSyntaxHighlightSupported = prevNode.__isSyntaxHighlightSupported; - this.__wordWrap = prevNode.__wordWrap; } // View @@ -139,7 +157,7 @@ export class CodeNode extends ElementNode { setDOMStyleFromCSS(element.style, style); } - if (this.__wordWrap) { + if (this.getWordWrap()) { element.setAttribute('data-word-wrap', 'true'); const gutterEl = document.createElement('div'); gutterEl.className = 'code-gutter'; @@ -154,7 +172,7 @@ export class CodeNode extends ElementNode { } getDOMSlot(element: HTMLElement): ElementDOMSlot { - if (this.__wordWrap) { + if (this.getWordWrap()) { const contentEl = element.querySelector('.code-content'); if (contentEl) { return super.getDOMSlot(element).withElement(contentEl); @@ -163,8 +181,14 @@ export class CodeNode extends ElementNode { return super.getDOMSlot(element); } updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { - // Force re-creation if wordWrap changes (DOM structure is different) - if (this.__wordWrap !== prevNode.__wordWrap) { + // Force re-creation if wordWrap changes (DOM structure is different). + // Use 'direct' reads so we compare each version's stored value, the + // same way the previous `this.__wordWrap !== prevNode.__wordWrap` + // check did. + if ( + $getState(this, wordWrapState, 'direct') !== + $getState(prevNode, wordWrapState, 'direct') + ) { return true; } @@ -320,11 +344,24 @@ export class CodeNode extends ElementNode { } exportJSON(): SerializedCodeNode { + // `wordWrap` is backed by NodeState so `super.exportJSON()` will + // include it under the `$` (NODE_STATE_KEY) bucket. Strip it from + // there and re-emit at the top level to preserve the legacy wire + // format (`{wordWrap: true}` only when enabled). + const {[NODE_STATE_KEY]: nodeStateBucket, ...rest} = super.exportJSON(); + let cleanedNodeState = nodeStateBucket; + if (nodeStateBucket && 'wordWrap' in nodeStateBucket) { + const {wordWrap: _stripped, ...others} = nodeStateBucket; + cleanedNodeState = Object.keys(others).length > 0 ? others : undefined; + } return { - ...super.exportJSON(), + ...rest, + ...(cleanedNodeState !== undefined + ? {[NODE_STATE_KEY]: cleanedNodeState} + : undefined), language: this.getLanguage(), theme: this.getTheme(), - ...(this.__wordWrap ? {wordWrap: true} : undefined), + ...(this.getWordWrap() ? {wordWrap: true} : undefined), }; } @@ -444,14 +481,12 @@ export class CodeNode extends ElementNode { return this.getLatest().__theme; } - setWordWrap(wordWrap: boolean): this { - const writable = this.getWritable(); - writable.__wordWrap = wordWrap; - return writable; + setWordWrap(valueOrUpdater: StateValueOrUpdater): this { + return $setState(this, wordWrapState, valueOrUpdater); } - getWordWrap(): boolean { - return this.getLatest().__wordWrap; + getWordWrap(): StateConfigValue { + return $getState(this, wordWrapState); } } diff --git a/packages/lexical-code-core/src/__tests__/unit/CodeGutter.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeGutter.test.ts new file mode 100644 index 00000000000..d36cbb020aa --- /dev/null +++ b/packages/lexical-code-core/src/__tests__/unit/CodeGutter.test.ts @@ -0,0 +1,149 @@ +/** + * 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 {$createLineBreakNode, $getRoot} from 'lexical'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {beforeAll, describe, expect, it, vi} from 'vitest'; + +import {registerCodeGutter} from '../../CodeGutter'; +import {$createCodeHighlightNode} from '../../CodeHighlightNode'; +import {$createCodeNode} from '../../CodeNode'; + +// jsdom does not implement ResizeObserver. The gutter helper instantiates +// one when word-wrap is enabled, so provide a no-op mock with disconnect +// tracking so we can assert teardown. +class MockResizeObserver { + static instances: MockResizeObserver[] = []; + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + constructor(_callback: ResizeObserverCallback) { + MockResizeObserver.instances.push(this); + } +} + +beforeAll(() => { + // Stub the missing global so word-wrap mode can instantiate one. + (globalThis as {ResizeObserver: typeof ResizeObserver}).ResizeObserver = + MockResizeObserver as unknown as typeof ResizeObserver; + // jsdom's Range does not implement getClientRects, which + // syncGutterHeights() needs. Stub it so the helper can run. + if (typeof Range !== 'undefined' && !Range.prototype.getClientRects) { + Range.prototype.getClientRects = function () { + return [] as unknown as DOMRectList; + }; + } +}); + +describe('CodeGutter', () => { + initializeUnitTest(testEnv => { + it('writes line numbers into data-gutter in classic (no word-wrap) mode', () => { + const {editor} = testEnv; + const cleanup = registerCodeGutter(editor); + + try { + editor.update( + () => { + const codeNode = $createCodeNode('javascript'); + codeNode.append( + $createCodeHighlightNode('one'), + $createLineBreakNode(), + $createCodeHighlightNode('two'), + $createLineBreakNode(), + $createCodeHighlightNode('three'), + ); + $getRoot().append(codeNode); + }, + {discrete: true}, + ); + + const codeEl = testEnv.container.querySelector('code'); + expect(codeEl).not.toBeNull(); + expect(codeEl!.getAttribute('data-gutter')).toBe('1\n2\n3'); + // Classic mode renders no real `.code-gutter` element. + expect(codeEl!.querySelector('.code-gutter')).toBeNull(); + } finally { + cleanup(); + } + }); + + it('populates .code-gutter spans and observes content in word-wrap mode', () => { + const {editor} = testEnv; + MockResizeObserver.instances = []; + const cleanup = registerCodeGutter(editor); + + try { + editor.update( + () => { + const codeNode = $createCodeNode('javascript'); + codeNode.setWordWrap(true); + codeNode.append( + $createCodeHighlightNode('alpha'), + $createLineBreakNode(), + $createCodeHighlightNode('beta'), + ); + $getRoot().append(codeNode); + }, + {discrete: true}, + ); + + const codeEl = testEnv.container.querySelector('code'); + expect(codeEl).not.toBeNull(); + expect(codeEl!.getAttribute('data-word-wrap')).toBe('true'); + const gutterEl = codeEl!.querySelector('.code-gutter')!; + expect(gutterEl).not.toBeNull(); + expect(gutterEl.children.length).toBe(2); + expect(gutterEl.children[0].textContent).toBe('1'); + expect(gutterEl.children[1].textContent).toBe('2'); + + // The helper must have wired a ResizeObserver to the + // .code-content element so wrapped-line heights stay in sync. + expect(MockResizeObserver.instances.length).toBe(1); + expect(MockResizeObserver.instances[0].observe).toHaveBeenCalledTimes( + 1, + ); + } finally { + cleanup(); + } + }); + + it('disconnects observers and removes the listener on cleanup', () => { + const {editor} = testEnv; + MockResizeObserver.instances = []; + const cleanup = registerCodeGutter(editor); + + editor.update( + () => { + const codeNode = $createCodeNode('javascript'); + codeNode.setWordWrap(true); + codeNode.append($createCodeHighlightNode('only')); + $getRoot().append(codeNode); + }, + {discrete: true}, + ); + + expect(MockResizeObserver.instances.length).toBe(1); + const observer = MockResizeObserver.instances[0]; + + cleanup(); + expect(observer.disconnect).toHaveBeenCalledTimes(1); + + // After teardown, further mutations must not throw and must not + // create a new observer (the listener has been removed). + MockResizeObserver.instances = []; + editor.update( + () => { + const codeNode = $getRoot().getFirstChildOrThrow(); + codeNode.remove(); + }, + {discrete: true}, + ); + expect(MockResizeObserver.instances.length).toBe(0); + }); + }); +}); 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 dde9d99793a..4f4af670a7e 100644 --- a/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts +++ b/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts @@ -8,11 +8,11 @@ import type {EditorConfig} from 'lexical'; -import {$getRoot} from 'lexical'; +import {$getRoot, NODE_STATE_KEY} from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; import {describe, expect, it} from 'vitest'; -import {$createCodeNode} from '../../CodeNode'; +import {$createCodeNode, CodeNode} from '../../CodeNode'; const editorConfig = { namespace: '', @@ -71,6 +71,50 @@ describe('CodeNode', () => { expect(exportedElement!.style.padding).toBe('1px'); expect(exportedElement!.style.color).toBe('blue'); }); + + it('round-trips wordWrap through JSON without changing the wire format', () => { + const {editor} = testEnv; + + editor.update( + () => { + // Default value: nothing should be emitted (no top-level + // `wordWrap` key, and no `$` bucket entry — the migration to + // NodeState must remain invisible to existing serialized data). + const defaultNode = $createCodeNode('javascript'); + const defaultJSON = defaultNode.exportJSON(); + expect(defaultJSON).not.toHaveProperty('wordWrap'); + if (NODE_STATE_KEY in defaultJSON) { + expect(defaultJSON[NODE_STATE_KEY]).not.toHaveProperty( + 'wordWrap', + ); + } + expect(defaultNode.getWordWrap()).toBe(false); + + // Enabled value: `wordWrap: true` is emitted at the top level + // (matches the pre-NodeState wire format) and never under `$`. + const wrappedNode = $createCodeNode('javascript'); + wrappedNode.setWordWrap(true); + const wrappedJSON = wrappedNode.exportJSON(); + expect(wrappedJSON.wordWrap).toBe(true); + if (NODE_STATE_KEY in wrappedJSON) { + expect(wrappedJSON[NODE_STATE_KEY]).not.toHaveProperty( + 'wordWrap', + ); + } + + // Re-import and confirm the value survives the round-trip. + const reimported = CodeNode.importJSON(wrappedJSON); + expect(reimported.getWordWrap()).toBe(true); + + // Toggling back to false strips the top-level key on re-export. + const toggledOff = reimported.setWordWrap(false); + const toggledJSON = toggledOff.exportJSON(); + expect(toggledJSON).not.toHaveProperty('wordWrap'); + expect(toggledOff.getWordWrap()).toBe(false); + }, + {discrete: true}, + ); + }); }, { namespace: 'test', diff --git a/packages/lexical-code-core/src/index.ts b/packages/lexical-code-core/src/index.ts index db0b0acc3a6..44448241c51 100644 --- a/packages/lexical-code-core/src/index.ts +++ b/packages/lexical-code-core/src/index.ts @@ -7,6 +7,11 @@ */ export {CodeExtension} from './CodeExtension'; +export { + type CodeGutterConfig, + CodeGutterExtension, + registerCodeGutter, +} from './CodeGutter'; export { $createCodeHighlightNode, $isCodeHighlightNode, diff --git a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts index 3acdd70337e..8ad886fb5bc 100644 --- a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts +++ b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts @@ -12,10 +12,12 @@ import { $isCodeHighlightNode, $isCodeNode, CodeExtension, + CodeGutterExtension, CodeHighlightNode, CodeIndentExtension, CodeNode, DEFAULT_CODE_LANGUAGE, + registerCodeGutter, registerCodeIndentation, } from '@lexical/code-core'; import {effect, namedSignals} from '@lexical/extension'; @@ -86,116 +88,6 @@ function $textNodeTransform( } } -function updateCodeGutter(node: CodeNode, editor: LexicalEditor): void { - const codeElement = editor.getElementByKey(node.getKey()); - if (codeElement === null) { - return; - } - const children = node.getChildren(); - const childrenLength = children.length; - // @ts-ignore: internal field - if (childrenLength === codeElement.__cachedChildrenLength) { - // Avoid updating the attribute if the children length hasn't changed. - return; - } - // @ts-ignore:: internal field - codeElement.__cachedChildrenLength = childrenLength; - let count = 1; - for (let i = 0; i < childrenLength; i++) { - if ($isLineBreakNode(children[i])) { - count++; - } - } - - if (node.getWordWrap()) { - // Word-wrap mode: update real DOM gutter elements - const gutterEl = codeElement.querySelector('.code-gutter'); - if (gutterEl) { - // Sync number of gutter line elements - while (gutterEl.children.length > count) { - gutterEl.removeChild(gutterEl.lastChild!); - } - while (gutterEl.children.length < count) { - const span = document.createElement('span'); - span.textContent = String(gutterEl.children.length + 1); - gutterEl.appendChild(span); - } - // Update text content for all lines - for (let i = 0; i < count; i++) { - const span = gutterEl.children[i] as HTMLElement; - const lineNum = String(i + 1); - if (span.textContent !== lineNum) { - span.textContent = lineNum; - } - } - // Sync heights after DOM update - syncGutterHeights(codeElement); - } - } else { - // Classic mode: data-gutter attribute - let gutter = '1'; - for (let i = 1; i < count; i++) { - gutter += '\n' + (i + 1); - } - codeElement.setAttribute('data-gutter', gutter); - } -} - -function syncGutterHeights(codeElement: HTMLElement): void { - const gutterEl = codeElement.querySelector('.code-gutter'); - const contentEl = codeElement.querySelector('.code-content'); - if (!gutterEl || !contentEl) { - return; - } - - const children = contentEl.childNodes; - let lineStart = 0; - - // Measure heights of each logical line in the content - // Lines are separated by
elements (LineBreakNode renders as
) - const lineHeights: number[] = []; - const range = document.createRange(); - - for (let i = 0; i <= children.length; i++) { - const child = children[i]; - const isEnd = i === children.length; - const isBreak = child && child.nodeName === 'BR'; - - if (isEnd || isBreak) { - // Measure height of this logical line - if (lineStart < i) { - range.setStartBefore(children[lineStart]); - range.setEndAfter(children[i - 1]); - const rects = range.getClientRects(); - let height = 0; - if (rects.length > 0) { - const first = rects[0]; - const last = rects[rects.length - 1]; - height = last.bottom - first.top; - } - lineHeights.push(height); - } else { - // Empty line — use line-height - lineHeights.push(0); - } - lineStart = i + 1; - } - } - - // Apply heights to gutter spans - for (let i = 0; i < gutterEl.children.length && i < lineHeights.length; i++) { - const span = gutterEl.children[i] as HTMLElement; - const h = lineHeights[i]; - if (h > 0) { - span.style.height = h + 'px'; - span.style.lineHeight = h + 'px'; - } else { - span.style.height = ''; - span.style.lineHeight = ''; - } - } -} - function $codeNodeTransform( editor: LexicalEditor, tokenizer: Tokenizer, @@ -398,17 +290,19 @@ interface TransformState { /** * @internal - * Register only the Prism highlighting transforms and the gutter - * mutation listener. No keyboard / indent handlers — those are the - * responsibility of + * Register only the Prism highlighting transforms. The gutter + * mutation listener is provided separately via + * {@link "@lexical/code-core".registerCodeGutter} / + * {@link "@lexical/code-core".CodeGutterExtension}, and the keyboard / + * indent handlers via * {@link "@lexical/code-core".registerCodeIndentation} / * {@link "@lexical/code-core".CodeIndentExtension}. * - * Used by {@link CodePrismExtension}, whose `CodeIndentExtension` - * dependency handles the indent side. The legacy - * {@link registerCodeHighlighting} wrapper combines this helper with - * `registerCodeIndentation` for direct callers that want the original - * single-call setup. + * Used by {@link CodePrismExtension}, whose `CodeGutterExtension` and + * `CodeIndentExtension` dependencies handle the gutter and indent + * sides. The legacy {@link registerCodeHighlighting} wrapper combines + * this helper with both `registerCodeGutter` and `registerCodeIndentation` + * for direct callers that want the original single-call setup. * * Exported for use by the package's own unit tests; not re-exported * from the package entry point. @@ -417,75 +311,11 @@ export function registerHighlightingOnly( editor: LexicalEditor, tokenizer: Tokenizer, ): () => void { - const registrations = []; - - // Only register the mutation listener if not in headless mode - if (editor._headless !== true) { - const resizeObservers = new Map(); - - registrations.push( - editor.registerMutationListener( - CodeNode, - mutations => { - editor.getEditorState().read(() => { - for (const [key, type] of mutations) { - if (type === 'destroyed') { - // Clean up ResizeObserver for destroyed nodes - const observer = resizeObservers.get(key); - if (observer) { - observer.disconnect(); - resizeObservers.delete(key); - } - } else { - const node = $getNodeByKey(key); - if (node !== null) { - updateCodeGutter(node as CodeNode, editor); - - // Set up ResizeObserver for word-wrap mode - const codeNode = node as CodeNode; - const codeElement = editor.getElementByKey(key); - if (codeNode.getWordWrap() && codeElement) { - if (!resizeObservers.has(key)) { - const contentEl = - codeElement.querySelector('.code-content'); - if (contentEl) { - const observer = new ResizeObserver(() => { - syncGutterHeights(codeElement); - }); - observer.observe(contentEl); - resizeObservers.set(key, observer); - } - } - } else { - // Clean up observer if word wrap was disabled - const observer = resizeObservers.get(key); - if (observer) { - observer.disconnect(); - resizeObservers.delete(key); - } - } - } - } - } - }); - }, - {skipInitialization: false}, - ), - // Cleanup all observers on unmount - () => { - for (const observer of resizeObservers.values()) { - observer.disconnect(); - } - resizeObservers.clear(); - }, - ); - } - const transformState: TransformState = { didTransform: false, nodesCurrentlyHighlighting: new Set(), }; - registrations.push( + return mergeRegister( editor.registerNodeTransform( CodeNode, $codeNodeTransform.bind(null, editor, tokenizer, transformState), @@ -499,8 +329,6 @@ export function registerHighlightingOnly( $textNodeTransform.bind(null, editor, tokenizer, transformState), ), ); - - return mergeRegister(...registrations); } /** @@ -520,6 +348,7 @@ export function registerCodeHighlighting( } return mergeRegister( registerHighlightingOnly(editor, tokenizer), + registerCodeGutter(editor), registerCodeIndentation(editor), ); } @@ -550,7 +379,7 @@ export const CodePrismExtension = defineExtension({ disabled: false, tokenizer: PrismTokenizer, }), - dependencies: [CodeExtension, CodeIndentExtension], + dependencies: [CodeExtension, CodeGutterExtension, CodeIndentExtension], name: '@lexical/code-prism', register: (editor, config, state) => { const stores = state.getOutput(); diff --git a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts index 76d3f4469fd..f8fb1d6e693 100644 --- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts +++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts @@ -12,10 +12,12 @@ import { $isCodeHighlightNode, $isCodeNode, CodeExtension, + CodeGutterExtension, CodeHighlightNode, CodeIndentExtension, CodeNode, DEFAULT_CODE_LANGUAGE, + registerCodeGutter, registerCodeIndentation, } from '@lexical/code-core'; import {effect, namedSignals} from '@lexical/extension'; @@ -84,107 +86,6 @@ function $textNodeTransform( } } -function updateCodeGutter(node: CodeNode, editor: LexicalEditor): void { - const codeElement = editor.getElementByKey(node.getKey()); - if (codeElement === null) { - return; - } - const children = node.getChildren(); - const childrenLength = children.length; - // @ts-ignore: internal field - if (childrenLength === codeElement.__cachedChildrenLength) { - // Avoid updating the attribute if the children length hasn't changed. - return; - } - // @ts-ignore:: internal field - codeElement.__cachedChildrenLength = childrenLength; - let count = 1; - for (let i = 0; i < childrenLength; i++) { - if ($isLineBreakNode(children[i])) { - count++; - } - } - - if (node.getWordWrap()) { - // Word-wrap mode: update real DOM gutter elements - const gutterEl = codeElement.querySelector('.code-gutter'); - if (gutterEl) { - while (gutterEl.children.length > count) { - gutterEl.removeChild(gutterEl.lastChild!); - } - while (gutterEl.children.length < count) { - const span = document.createElement('span'); - span.textContent = String(gutterEl.children.length + 1); - gutterEl.appendChild(span); - } - for (let i = 0; i < count; i++) { - const span = gutterEl.children[i] as HTMLElement; - const lineNum = String(i + 1); - if (span.textContent !== lineNum) { - span.textContent = lineNum; - } - } - syncGutterHeights(codeElement); - } - } else { - // Classic mode: data-gutter attribute - let gutter = '1'; - for (let i = 1; i < count; i++) { - gutter += '\n' + (i + 1); - } - codeElement.setAttribute('data-gutter', gutter); - } -} - -function syncGutterHeights(codeElement: HTMLElement): void { - const gutterEl = codeElement.querySelector('.code-gutter'); - const contentEl = codeElement.querySelector('.code-content'); - if (!gutterEl || !contentEl) { - return; - } - - const children = contentEl.childNodes; - const lineHeights: number[] = []; - const range = document.createRange(); - let lineStart = 0; - - for (let i = 0; i <= children.length; i++) { - const child = children[i]; - const isEnd = i === children.length; - const isBreak = child && child.nodeName === 'BR'; - - if (isEnd || isBreak) { - if (lineStart < i) { - range.setStartBefore(children[lineStart]); - range.setEndAfter(children[i - 1]); - const rects = range.getClientRects(); - let height = 0; - if (rects.length > 0) { - const first = rects[0]; - const last = rects[rects.length - 1]; - height = last.bottom - first.top; - } - lineHeights.push(height); - } else { - lineHeights.push(0); - } - lineStart = i + 1; - } - } - - for (let i = 0; i < gutterEl.children.length && i < lineHeights.length; i++) { - const span = gutterEl.children[i] as HTMLElement; - const h = lineHeights[i]; - if (h > 0) { - span.style.height = h + 'px'; - span.style.lineHeight = h + 'px'; - } else { - span.style.height = ''; - span.style.lineHeight = ''; - } - } -} - interface TransformState { didTransform: boolean; // Using extra cache (`nodesCurrentlyHighlighting`) since both CodeNode and CodeHighlightNode @@ -403,17 +304,19 @@ function isEqual(nodeA: LexicalNode, nodeB: LexicalNode): boolean { /** * @internal - * Register only the Shiki highlighting transforms and the gutter - * mutation listener. No keyboard / indent handlers — those are the - * responsibility of + * Register only the Shiki highlighting transforms. The gutter + * mutation listener is provided separately via + * {@link "@lexical/code-core".registerCodeGutter} / + * {@link "@lexical/code-core".CodeGutterExtension}, and the keyboard / + * indent handlers via * {@link "@lexical/code-core".registerCodeIndentation} / * {@link "@lexical/code-core".CodeIndentExtension}. * - * Used by {@link CodeShikiExtension}, whose `CodeIndentExtension` - * dependency handles the indent side. The legacy - * {@link registerCodeHighlighting} wrapper combines this helper with - * `registerCodeIndentation` for direct callers that want the original - * single-call setup. + * Used by {@link CodeShikiExtension}, whose `CodeGutterExtension` and + * `CodeIndentExtension` dependencies handle the gutter and indent + * sides. The legacy {@link registerCodeHighlighting} wrapper combines + * this helper with both `registerCodeGutter` and `registerCodeIndentation` + * for direct callers that want the original single-call setup. * * Exported for use by the package's own unit tests; not re-exported * from the package entry point. @@ -422,71 +325,11 @@ export function registerHighlightingOnly( editor: LexicalEditor, tokenizer: Tokenizer, ): () => void { - const registrations = []; - - // Only register the mutation listener if not in headless mode - if (editor._headless !== true) { - const resizeObservers = new Map(); - - registrations.push( - editor.registerMutationListener( - CodeNode, - mutations => { - editor.getEditorState().read(() => { - for (const [key, type] of mutations) { - if (type === 'destroyed') { - const observer = resizeObservers.get(key); - if (observer) { - observer.disconnect(); - resizeObservers.delete(key); - } - } else { - const node = $getNodeByKey(key); - if (node !== null) { - updateCodeGutter(node as CodeNode, editor); - - const codeNode = node as CodeNode; - const codeElement = editor.getElementByKey(key); - if (codeNode.getWordWrap() && codeElement) { - if (!resizeObservers.has(key)) { - const contentEl = - codeElement.querySelector('.code-content'); - if (contentEl) { - const observer = new ResizeObserver(() => { - syncGutterHeights(codeElement); - }); - observer.observe(contentEl); - resizeObservers.set(key, observer); - } - } - } else { - const observer = resizeObservers.get(key); - if (observer) { - observer.disconnect(); - resizeObservers.delete(key); - } - } - } - } - } - }); - }, - {skipInitialization: false}, - ), - () => { - for (const observer of resizeObservers.values()) { - observer.disconnect(); - } - resizeObservers.clear(); - }, - ); - } - const transformState: TransformState = { didTransform: false, nodesCurrentlyHighlighting: new Set(), }; - registrations.push( + return mergeRegister( editor.registerNodeTransform( CodeNode, $codeNodeTransform.bind(null, editor, tokenizer, transformState), @@ -500,8 +343,6 @@ export function registerHighlightingOnly( $textNodeTransform.bind(null, editor, tokenizer, transformState), ), ); - - return mergeRegister(...registrations); } /** @@ -521,6 +362,7 @@ export function registerCodeHighlighting( } return mergeRegister( registerHighlightingOnly(editor, tokenizer), + registerCodeGutter(editor), registerCodeIndentation(editor), ); } @@ -551,7 +393,7 @@ export const CodeShikiExtension = defineExtension({ disabled: false, tokenizer: ShikiTokenizer, }), - dependencies: [CodeExtension, CodeIndentExtension], + dependencies: [CodeExtension, CodeGutterExtension, CodeIndentExtension], name: '@lexical/code-shiki', register: (editor, config, state) => { const stores = state.getOutput(); diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.css b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.css index ec85b8a78fd..981a3a53853 100644 --- a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.css +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.css @@ -15,6 +15,8 @@ align-items: center; flex-direction: row; user-select: none; + background: #ffffff; + padding-left: 5px; } .code-action-menu-container .code-highlight-language {