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 7f8eb4cd925..5bd028f69d4 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'; @@ -31,13 +33,19 @@ import { $createParagraphNode, $createTabNode, $getEditor, + $getState, $isLineBreakNode, $isTabNode, $isTextNode, + $setState, addClassNamesToElement, + createState, + ElementDOMSlot, ElementNode, isHTMLElement, + NODE_STATE_KEY, setDOMStyleFromCSS, + setDOMUnmanaged, } from 'lexical'; import { @@ -51,6 +59,7 @@ export type SerializedCodeNode = Spread< { language: string | null | undefined; theme?: string | undefined; + wordWrap?: boolean | undefined; }, SerializedElementNode >; @@ -75,6 +84,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', ); @@ -132,9 +157,42 @@ export class CodeNode extends ElementNode { if (style) { setDOMStyleFromCSS(element.style, style); } + + if (this.getWordWrap()) { + 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.getWordWrap()) { + 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). + // 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; + } + const language = this.__language; const prevLanguage = prevNode.__language; @@ -282,14 +340,29 @@ export class CodeNode extends ElementNode { return super .updateFromJSON(serializedNode) .setLanguage(serializedNode.language) - .setTheme(serializedNode.theme); + .setTheme(serializedNode.theme) + .setWordWrap(serializedNode.wordWrap || false); } 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.getWordWrap() ? {wordWrap: true} : undefined), }; } @@ -408,6 +481,14 @@ export class CodeNode extends ElementNode { getTheme(): string | undefined { return this.getLatest().__theme; } + + setWordWrap(valueOrUpdater: StateValueOrUpdater): this { + return $setState(this, wordWrapState, valueOrUpdater); + } + + getWordWrap(): StateConfigValue { + return $getState(this, wordWrapState); + } } export function $createCodeNode( 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 6e32e1d9a96..eba66775713 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 c964f6bbfcc..cf5186ce2b4 100644 --- a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts +++ b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts @@ -13,10 +13,12 @@ import { $isCodeNode, $plainifyCodeContent, CodeExtension, + CodeGutterExtension, CodeHighlightNode, CodeIndentExtension, CodeNode, DEFAULT_CODE_LANGUAGE, + registerCodeGutter, registerCodeIndentation, } from '@lexical/code-core'; import {effect, namedSignals} from '@lexical/extension'; @@ -99,30 +101,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 gutter = '1'; - let count = 1; - for (let i = 0; i < childrenLength; i++) { - if ($isLineBreakNode(children[i])) { - gutter += '\n' + ++count; - } - } - codeElement.setAttribute('data-gutter', gutter); -} - function $codeNodeTransform( editor: LexicalEditor, tokenizer: Tokenizer, @@ -335,17 +313,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. @@ -354,35 +334,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) { - registrations.push( - editor.registerMutationListener( - CodeNode, - mutations => { - editor.getEditorState().read(() => { - for (const [key, type] of mutations) { - if (type !== 'destroyed') { - const node = $getNodeByKey(key); - if (node !== null) { - updateCodeGutter(node as CodeNode, editor); - } - } - } - }); - }, - {skipInitialization: false}, - ), - ); - } - const transformState: TransformState = { didTransform: false, nodesCurrentlyHighlighting: new Set(), }; - registrations.push( + return mergeRegister( editor.registerNodeTransform( CodeNode, $codeNodeTransform.bind(null, editor, tokenizer, transformState), @@ -396,8 +352,6 @@ export function registerHighlightingOnly( $textNodeTransform.bind(null, editor, tokenizer, transformState), ), ); - - return mergeRegister(...registrations); } /** @@ -417,6 +371,7 @@ export function registerCodeHighlighting( } return mergeRegister( registerHighlightingOnly(editor, tokenizer), + registerCodeGutter(editor), registerCodeIndentation(editor), ); } @@ -447,7 +402,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 c7eacf8cf00..87bc2825ee5 100644 --- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts +++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts @@ -13,10 +13,12 @@ import { $isCodeNode, $plainifyCodeContent, CodeExtension, + CodeGutterExtension, CodeHighlightNode, CodeIndentExtension, CodeNode, DEFAULT_CODE_LANGUAGE, + registerCodeGutter, registerCodeIndentation, } from '@lexical/code-core'; import {effect, namedSignals} from '@lexical/extension'; @@ -95,30 +97,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 gutter = '1'; - let count = 1; - for (let i = 0; i < childrenLength; i++) { - if ($isLineBreakNode(children[i])) { - gutter += '\n' + ++count; - } - } - codeElement.setAttribute('data-gutter', gutter); -} - interface TransformState { didTransform: boolean; // Using extra cache (`nodesCurrentlyHighlighting`) since both CodeNode and CodeHighlightNode @@ -352,17 +330,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. @@ -371,35 +351,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) { - registrations.push( - editor.registerMutationListener( - CodeNode, - mutations => { - editor.getEditorState().read(() => { - for (const [key, type] of mutations) { - if (type !== 'destroyed') { - const node = $getNodeByKey(key); - if (node !== null) { - updateCodeGutter(node as CodeNode, editor); - } - } - } - }); - }, - {skipInitialization: false}, - ), - ); - } - const transformState: TransformState = { didTransform: false, nodesCurrentlyHighlighting: new Set(), }; - registrations.push( + return mergeRegister( editor.registerNodeTransform( CodeNode, $codeNodeTransform.bind(null, editor, tokenizer, transformState), @@ -413,8 +369,6 @@ export function registerHighlightingOnly( $textNodeTransform.bind(null, editor, tokenizer, transformState), ), ); - - return mergeRegister(...registrations); } /** @@ -434,6 +388,7 @@ export function registerCodeHighlighting( } return mergeRegister( registerHighlightingOnly(editor, tokenizer), + registerCodeGutter(editor), registerCodeIndentation(editor), ); } @@ -464,7 +419,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/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.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 { diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx index b0ea1cf7919..f8adff57af3 100644 --- a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx @@ -24,6 +24,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'; @@ -154,6 +155,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 9a5e8d4a4cc..b0c803f26e8 100644 --- a/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts @@ -588,6 +588,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', () => {