mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +02:00
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>
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user