From 48efbf36f955af74527f3fea8e31f7cc73b4a2d8 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 29 Mar 2026 00:12:11 +0100 Subject: [PATCH] fix: prevent crash when pressing Shift+Enter in empty code block The CodeHighlightPlugin's selection restoration could create an invalid Lexical selection targeting a LineBreakNode with type 'element'. Since LineBreakNode is not an ElementNode, Lexical threw during reconciliation and the LexicalErrorBoundary replaced the editor with an error state. The fix ensures findPoint never targets a LineBreakNode directly. Instead it falls back to an element-level selection on the parent CodeNode using the child index, which is always a valid target. Extracted the selection helpers (getCodeNodeAbsoluteOffset, findCodeNodeSelectionPoint, restoreCodeNodeSelection) into markdownEditor.ts for testability and added regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/components/MarkdownComposer.tsx | 60 +-------- src/renderer/lib/markdownEditor.ts | 81 +++++++++++- tests/renderer/markdownEditor.test.ts | 132 +++++++++++++++++++ 3 files changed, 216 insertions(+), 57 deletions(-) diff --git a/src/renderer/components/MarkdownComposer.tsx b/src/renderer/components/MarkdownComposer.tsx index 0e4d04f..22c2a3c 100644 --- a/src/renderer/components/MarkdownComposer.tsx +++ b/src/renderer/components/MarkdownComposer.tsx @@ -24,7 +24,6 @@ import { $isCodeNode, $createCodeNode, $createCodeHighlightNode, - $isCodeHighlightNode, CodeNode, CodeHighlightNode, } from '@lexical/code'; @@ -44,9 +43,7 @@ import { $getNodeByKey, $getRoot, $getSelection, - $isLineBreakNode, $isRangeSelection, - $isTextNode, CLEAR_EDITOR_COMMAND, COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, @@ -63,6 +60,8 @@ import { markdownEditorNamespace, markdownEditorNodes, markdownEditorTransformers, + getCodeNodeAbsoluteOffset, + restoreCodeNodeSelection, } from '@renderer/lib/markdownEditor'; import { prepareChatMessageContent } from '@shared/utils/chatMessage'; @@ -295,55 +294,6 @@ function parseHljsHtml(html: string): HljsToken[] { /* ── Code highlight plugin ────────────────────────────── */ -function getAbsoluteOffset( - codeNode: ReturnType, - point: { key: string; offset: number }, -): number { - if (!codeNode || !('getChildren' in codeNode)) return 0; - let offset = 0; - for (const child of (codeNode as CodeNode).getChildren()) { - if (child.getKey() === point.key) return offset + point.offset; - offset += $isLineBreakNode(child) ? 1 : child.getTextContentSize(); - } - return offset; -} - -function restoreSelectionFromOffsets(codeNode: CodeNode, anchorOff: number, focusOff: number) { - const children = codeNode.getChildren(); - - function findPoint(target: number) { - let offset = 0; - for (const child of children) { - const size = $isLineBreakNode(child) ? 1 : child.getTextContentSize(); - if (offset + size > target || (offset + size === target && $isTextNode(child))) { - return { - key: child.getKey(), - offset: target - offset, - type: ($isTextNode(child) || $isCodeHighlightNode(child) ? 'text' : 'element') as 'text' | 'element', - }; - } - offset += size; - } - const last = children[children.length - 1]; - if (last) { - return { - key: last.getKey(), - offset: $isLineBreakNode(last) ? 0 : last.getTextContentSize(), - type: ($isTextNode(last) || $isCodeHighlightNode(last) ? 'text' : 'element') as 'text' | 'element', - }; - } - return { key: codeNode.getKey(), offset: 0, type: 'element' as const }; - } - - const anchor = findPoint(anchorOff); - const focus = findPoint(focusOff); - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - selection.anchor.set(anchor.key, anchor.offset, anchor.type); - selection.focus.set(focus.key, focus.offset, focus.type); - } -} - /** Enables highlight.js-based syntax highlighting inside CodeNodes. */ function CodeHighlightPlugin() { const [editor] = useLexicalComposerContext(); @@ -369,8 +319,8 @@ function CodeHighlightPlugin() { let anchorOff: number | undefined; let focusOff: number | undefined; if ($isRangeSelection(sel)) { - anchorOff = getAbsoluteOffset(current, sel.anchor); - focusOff = getAbsoluteOffset(current, sel.focus); + anchorOff = getCodeNodeAbsoluteOffset(current, sel.anchor); + focusOff = getCodeNodeAbsoluteOffset(current, sel.focus); } // Build new children from tokens @@ -392,7 +342,7 @@ function CodeHighlightPlugin() { // Restore cursor if (anchorOff !== undefined && focusOff !== undefined) { - restoreSelectionFromOffsets(current, anchorOff, focusOff); + restoreCodeNodeSelection(current, anchorOff, focusOff); } }); queueMicrotask(() => highlightingKeys.delete(nodeKey)); diff --git a/src/renderer/lib/markdownEditor.ts b/src/renderer/lib/markdownEditor.ts index 4613922..30d635c 100644 --- a/src/renderer/lib/markdownEditor.ts +++ b/src/renderer/lib/markdownEditor.ts @@ -1,9 +1,16 @@ -import { CodeHighlightNode, CodeNode } from '@lexical/code'; +import { $isCodeHighlightNode, CodeHighlightNode, CodeNode } from '@lexical/code'; import { AutoLinkNode, LinkNode } from '@lexical/link'; import { type Transformer, TRANSFORMERS } from '@lexical/markdown'; import { ListItemNode, ListNode } from '@lexical/list'; import { HeadingNode, QuoteNode } from '@lexical/rich-text'; -import { type Klass, type LexicalNode } from 'lexical'; +import { + $getSelection, + $isLineBreakNode, + $isRangeSelection, + $isTextNode, + type Klass, + type LexicalNode, +} from 'lexical'; import { normalizeChatMessageLineEndings } from '@shared/utils/chatMessage'; @@ -96,3 +103,73 @@ export function inspectMarkdownPaste(text: string): MarkdownPasteInspection { export function shouldImportMarkdownPaste(text: string): boolean { return inspectMarkdownPaste(text).shouldImportMarkdown; } + +/* ── Code-node selection helpers ──────────────────────── */ + +/** + * Returns the absolute character offset of a selection point within a CodeNode. + * LineBreakNodes count as 1 character; all other children use their text content size. + */ +export function getCodeNodeAbsoluteOffset( + codeNode: CodeNode, + point: { key: string; offset: number }, +): number { + let offset = 0; + for (const child of codeNode.getChildren()) { + if (child.getKey() === point.key) return offset + point.offset; + offset += $isLineBreakNode(child) ? 1 : child.getTextContentSize(); + } + return offset; +} + +/** + * Converts an absolute character offset within a CodeNode into a valid Lexical + * selection point (node key, local offset, and point type). + * + * The returned point always targets either a text-like child (`type: 'text'`) + * or the parent CodeNode itself (`type: 'element'`). It never targets a + * LineBreakNode directly — doing so would crash Lexical because LineBreakNode + * is not an ElementNode. + */ +export function findCodeNodeSelectionPoint( + codeNode: CodeNode, + target: number, +): { key: string; offset: number; type: 'text' | 'element' } { + const children = codeNode.getChildren(); + let offset = 0; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const size = $isLineBreakNode(child) ? 1 : child.getTextContentSize(); + if (offset + size > target || (offset + size === target && $isTextNode(child))) { + if ($isTextNode(child) || $isCodeHighlightNode(child)) { + return { key: child.getKey(), offset: target - offset, type: 'text' as const }; + } + // Non-text child (e.g. LineBreakNode): target the parent CodeNode at this child index + return { key: codeNode.getKey(), offset: i, type: 'element' as const }; + } + offset += size; + } + const last = children.length > 0 ? children[children.length - 1] : undefined; + if (last && ($isTextNode(last) || $isCodeHighlightNode(last))) { + return { key: last.getKey(), offset: last.getTextContentSize(), type: 'text' as const }; + } + return { key: codeNode.getKey(), offset: children.length, type: 'element' as const }; +} + +/** + * Restores a range selection within a CodeNode from absolute character offsets. + * Must be called inside an editor update or read context. + */ +export function restoreCodeNodeSelection( + codeNode: CodeNode, + anchorOff: number, + focusOff: number, +): void { + const anchor = findCodeNodeSelectionPoint(codeNode, anchorOff); + const focus = findCodeNodeSelectionPoint(codeNode, focusOff); + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.anchor.set(anchor.key, anchor.offset, anchor.type); + selection.focus.set(focus.key, focus.offset, focus.type); + } +} diff --git a/tests/renderer/markdownEditor.test.ts b/tests/renderer/markdownEditor.test.ts index 5be5976..6ce4917 100644 --- a/tests/renderer/markdownEditor.test.ts +++ b/tests/renderer/markdownEditor.test.ts @@ -1,12 +1,26 @@ import { describe, expect, test } from 'bun:test'; import { createHeadlessEditor } from '@lexical/headless'; import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown'; +import { + $createCodeNode, + $createCodeHighlightNode, + CodeNode, +} from '@lexical/code'; +import { + $createLineBreakNode, + $createRangeSelection, + $getRoot, + $setSelection, +} from 'lexical'; import { + findCodeNodeSelectionPoint, + getCodeNodeAbsoluteOffset, inspectMarkdownPaste, markdownEditorNamespace, markdownEditorNodes, markdownEditorTransformers, + restoreCodeNodeSelection, } from '@renderer/lib/markdownEditor'; function roundTripMarkdown(markdown: string): string { @@ -71,3 +85,121 @@ describe('markdown editor contract', () => { }); }); }); + +/* ── Code-node selection helpers ──────────────────────── */ + +function createTestEditor() { + return createHeadlessEditor({ + namespace: markdownEditorNamespace, + nodes: [...markdownEditorNodes], + onError(error) { + throw error; + }, + }); +} + +describe('code-node selection helpers', () => { + test('findCodeNodeSelectionPoint targets CodeNode (not LineBreakNode) for linebreak-only content', () => { + const editor = createTestEditor(); + + editor.update(() => { + const codeNode = $createCodeNode(); + codeNode.append($createLineBreakNode()); + $getRoot().clear().append(codeNode); + + // target=0 → before the LineBreakNode + const before = findCodeNodeSelectionPoint(codeNode, 0); + expect(before.key).toBe(codeNode.getKey()); + expect(before.offset).toBe(0); + expect(before.type).toBe('element'); + + // target=1 → after the LineBreakNode (fallback) + const after = findCodeNodeSelectionPoint(codeNode, 1); + expect(after.key).toBe(codeNode.getKey()); + expect(after.offset).toBe(1); + expect(after.type).toBe('element'); + }, { discrete: true }); + }); + + test('findCodeNodeSelectionPoint targets text nodes correctly', () => { + const editor = createTestEditor(); + + editor.update(() => { + const codeNode = $createCodeNode(); + codeNode.append($createCodeHighlightNode('hello')); + codeNode.append($createLineBreakNode()); + codeNode.append($createCodeHighlightNode('world')); + $getRoot().clear().append(codeNode); + + // target=3 → inside "hello" + const mid = findCodeNodeSelectionPoint(codeNode, 3); + expect(mid.key).toBe(codeNode.getChildren()[0].getKey()); + expect(mid.offset).toBe(3); + expect(mid.type).toBe('text'); + + // target=5 → end of "hello" (text boundary) + const endHello = findCodeNodeSelectionPoint(codeNode, 5); + expect(endHello.key).toBe(codeNode.getChildren()[0].getKey()); + expect(endHello.offset).toBe(5); + expect(endHello.type).toBe('text'); + + // target=6 → after the LineBreakNode → start of "world" + const startWorld = findCodeNodeSelectionPoint(codeNode, 6); + expect(startWorld.key).toBe(codeNode.getChildren()[2].getKey()); + expect(startWorld.offset).toBe(0); + expect(startWorld.type).toBe('text'); + }, { discrete: true }); + }); + + test('findCodeNodeSelectionPoint handles empty CodeNode', () => { + const editor = createTestEditor(); + + editor.update(() => { + const codeNode = $createCodeNode(); + $getRoot().clear().append(codeNode); + + const point = findCodeNodeSelectionPoint(codeNode, 0); + expect(point.key).toBe(codeNode.getKey()); + expect(point.offset).toBe(0); + expect(point.type).toBe('element'); + }, { discrete: true }); + }); + + test('restoreCodeNodeSelection does not throw on linebreak-only CodeNode', () => { + const editor = createTestEditor(); + + editor.update(() => { + const codeNode = $createCodeNode(); + codeNode.append($createLineBreakNode()); + $getRoot().clear().append(codeNode); + + // Create a valid selection so restoreCodeNodeSelection has one to update + const sel = $createRangeSelection(); + sel.anchor.set(codeNode.getKey(), 0, 'element'); + sel.focus.set(codeNode.getKey(), 0, 'element'); + $setSelection(sel); + + // This would throw before the fix because findPoint returned a point + // targeting the LineBreakNode with type 'element' + expect(() => restoreCodeNodeSelection(codeNode, 1, 1)).not.toThrow(); + }, { discrete: true }); + }); + + test('getCodeNodeAbsoluteOffset computes character offsets correctly', () => { + const editor = createTestEditor(); + + editor.update(() => { + const codeNode = $createCodeNode(); + const hello = $createCodeHighlightNode('hello'); + const lb = $createLineBreakNode(); + const world = $createCodeHighlightNode('world'); + codeNode.append(hello, lb, world); + $getRoot().clear().append(codeNode); + + expect(getCodeNodeAbsoluteOffset(codeNode, { key: hello.getKey(), offset: 0 })).toBe(0); + expect(getCodeNodeAbsoluteOffset(codeNode, { key: hello.getKey(), offset: 3 })).toBe(3); + expect(getCodeNodeAbsoluteOffset(codeNode, { key: lb.getKey(), offset: 0 })).toBe(5); + expect(getCodeNodeAbsoluteOffset(codeNode, { key: world.getKey(), offset: 2 })).toBe(8); + }, { discrete: true }); + }); +});