mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 03:38:44 +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:
@@ -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<typeof $getNodeByKey>,
|
||||
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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user