Picking an autocomplete item (mention/emoji/command — all inline voids)
occasionally tripped the composer error boundary, forcing a page refresh, even
though the element had already inserted. Root cause (traced through slate-react):
moveCursor deferred its cursor work to setTimeout(0), leaving the caret on the
just-inserted void's zero-width edge whose DOM (a U+FEFF node) isn't populated on
that tick. slate-react's commit-phase selection sync then calls
setBaseAndExtent(voidEdge, 1) and throws IndexSizeError mid-render → boundary.
Prevention: do the cursor work SYNCHRONOUSLY, in the same commit as the insert —
Transforms.move (escapes the void into the real trailing text node) then
insertText(' '). The caret is then always a resolvable text point when the
selection sync runs. (moveCursor's focus stays deferred+guarded, unchanged.)
Recovery (belt-and-suspenders): the composer error boundary is now recoverable —
a "Reload composer" button (resetErrorBoundary) + onReset Transforms.deselect
clears a transient bad selection so it remounts with the draft intact, no page
refresh. + role="alert" for screen readers.
Three review agents: two root-caused the exact slate-react throw and proved the
try/catch-only version merely recovered; a third reproduced the transforms
headlessly and caught that a first "sync insertText WITHOUT move" attempt hit
Slate's void guard (space dropped, caret trapped) — the move is required to
escape the void. Not unit-testable (needs the live DOM + the timing race).
Gate-green (tsc, eslint, prettier, 925 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
303 lines
8.6 KiB
TypeScript
303 lines
8.6 KiB
TypeScript
import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate';
|
|
import { ReactEditor } from 'slate-react';
|
|
import { BlockType, MarkType } from './types';
|
|
import {
|
|
CommandElement,
|
|
EmoticonElement,
|
|
FormattedText,
|
|
HeadingLevel,
|
|
LinkElement,
|
|
MentionElement,
|
|
} from './slate';
|
|
|
|
const ALL_MARK_TYPE: MarkType[] = [
|
|
MarkType.Bold,
|
|
MarkType.Code,
|
|
MarkType.Italic,
|
|
MarkType.Spoiler,
|
|
MarkType.StrikeThrough,
|
|
MarkType.Underline,
|
|
];
|
|
|
|
export const isMarkActive = (editor: Editor, format: MarkType) => {
|
|
const marks = Editor.marks(editor);
|
|
return marks ? marks[format] === true : false;
|
|
};
|
|
|
|
export const isAnyMarkActive = (editor: Editor) => {
|
|
const marks = Editor.marks(editor);
|
|
return marks && !!ALL_MARK_TYPE.find((type) => marks[type] === true);
|
|
};
|
|
|
|
export const toggleMark = (editor: Editor, format: MarkType) => {
|
|
const isActive = isMarkActive(editor, format);
|
|
|
|
if (isActive) {
|
|
Editor.removeMark(editor, format);
|
|
} else {
|
|
Editor.addMark(editor, format, true);
|
|
}
|
|
};
|
|
|
|
export const removeAllMark = (editor: Editor) => {
|
|
ALL_MARK_TYPE.forEach((mark) => {
|
|
if (isMarkActive(editor, mark)) Editor.removeMark(editor, mark);
|
|
});
|
|
};
|
|
|
|
export const isBlockActive = (editor: Editor, format: BlockType) => {
|
|
const [match] = Editor.nodes(editor, {
|
|
match: (node) => Element.isElement(node) && node.type === format,
|
|
});
|
|
|
|
return !!match;
|
|
};
|
|
|
|
export const headingLevel = (editor: Editor): HeadingLevel | undefined => {
|
|
const [nodeEntry] = Editor.nodes(editor, {
|
|
match: (node) => Element.isElement(node) && node.type === BlockType.Heading,
|
|
});
|
|
const [node] = nodeEntry ?? [];
|
|
if (!node) return undefined;
|
|
if ('level' in node) return node.level;
|
|
return undefined;
|
|
};
|
|
|
|
type BlockOption = { level: HeadingLevel };
|
|
const NESTED_BLOCK = [
|
|
BlockType.OrderedList,
|
|
BlockType.UnorderedList,
|
|
BlockType.BlockQuote,
|
|
BlockType.CodeBlock,
|
|
];
|
|
|
|
export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => {
|
|
Transforms.collapse(editor, {
|
|
edge: 'end',
|
|
});
|
|
const isActive = isBlockActive(editor, format);
|
|
|
|
Transforms.unwrapNodes(editor, {
|
|
match: (node) => Element.isElement(node) && NESTED_BLOCK.includes(node.type),
|
|
split: true,
|
|
});
|
|
|
|
if (isActive) {
|
|
Transforms.setNodes(editor, {
|
|
type: BlockType.Paragraph,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (format === BlockType.OrderedList || format === BlockType.UnorderedList) {
|
|
Transforms.setNodes(editor, {
|
|
type: BlockType.ListItem,
|
|
});
|
|
const block = {
|
|
type: format,
|
|
children: [],
|
|
};
|
|
Transforms.wrapNodes(editor, block);
|
|
return;
|
|
}
|
|
if (format === BlockType.CodeBlock) {
|
|
Transforms.setNodes(editor, {
|
|
type: BlockType.CodeLine,
|
|
});
|
|
const block = {
|
|
type: format,
|
|
children: [],
|
|
};
|
|
Transforms.wrapNodes(editor, block);
|
|
return;
|
|
}
|
|
|
|
if (format === BlockType.BlockQuote) {
|
|
Transforms.setNodes(editor, {
|
|
type: BlockType.QuoteLine,
|
|
});
|
|
const block = {
|
|
type: format,
|
|
children: [],
|
|
};
|
|
Transforms.wrapNodes(editor, block);
|
|
return;
|
|
}
|
|
|
|
if (format === BlockType.Heading) {
|
|
Transforms.setNodes(editor, {
|
|
type: format,
|
|
level: option?.level ?? 1,
|
|
});
|
|
}
|
|
|
|
Transforms.setNodes(editor, {
|
|
type: format,
|
|
});
|
|
};
|
|
|
|
export const resetEditor = (editor: Editor) => {
|
|
Transforms.delete(editor, {
|
|
at: {
|
|
anchor: Editor.start(editor, []),
|
|
focus: Editor.end(editor, []),
|
|
},
|
|
});
|
|
|
|
toggleBlock(editor, BlockType.Paragraph);
|
|
removeAllMark(editor);
|
|
};
|
|
|
|
export const resetEditorHistory = (editor: Editor) => {
|
|
editor.history = {
|
|
undos: [],
|
|
redos: [],
|
|
};
|
|
};
|
|
|
|
export const createMentionElement = (
|
|
id: string,
|
|
name: string,
|
|
highlight: boolean,
|
|
eventId?: string,
|
|
viaServers?: string[],
|
|
): MentionElement => ({
|
|
type: BlockType.Mention,
|
|
id,
|
|
eventId,
|
|
viaServers,
|
|
highlight,
|
|
name,
|
|
children: [{ text: '' }],
|
|
});
|
|
|
|
export const createEmoticonElement = (key: string, shortcode: string): EmoticonElement => ({
|
|
type: BlockType.Emoticon,
|
|
key,
|
|
shortcode,
|
|
children: [{ text: '' }],
|
|
});
|
|
|
|
export const createLinkElement = (
|
|
href: string,
|
|
children: string | FormattedText[],
|
|
): LinkElement => ({
|
|
type: BlockType.Link,
|
|
href,
|
|
children: typeof children === 'string' ? [{ text: children }] : children,
|
|
});
|
|
|
|
export const createCommandElement = (command: string): CommandElement => ({
|
|
type: BlockType.Command,
|
|
command,
|
|
children: [{ text: '' }],
|
|
});
|
|
|
|
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
|
|
// Wrap the whole sequence: on a stale autocomplete range (the document changed
|
|
// between the menu opening and the pick) `insertNodes` — not `select`, which is
|
|
// lazy in this Slate version — can throw. This runs inside the pick's event
|
|
// handler, so an escape wouldn't hit the error boundary, but keep it contained.
|
|
try {
|
|
Transforms.select(editor, selectRange);
|
|
Transforms.insertNodes(editor, element);
|
|
Transforms.collapse(editor, { edge: 'end' });
|
|
} catch {
|
|
/* stale range — the pick is a no-op rather than an uncaught error */
|
|
}
|
|
};
|
|
|
|
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
|
// Move the caret out of the just-inserted inline void and land it in a real
|
|
// trailing text node — SYNCHRONOUSLY, in the same commit as the insert.
|
|
// `Transforms.move` escapes the void (after insertNodes+collapse the caret is
|
|
// INSIDE the void's inner text node; insertText there is a no-op, blocked by
|
|
// Slate's void guard). The space then lands in a real text node.
|
|
// Doing this in the same commit (vs the old deferred setTimeout) means the
|
|
// caret never sits on the void's zero-width edge on a racy tick — that edge's
|
|
// DOM (a U+FEFF node) isn't populated yet, so slate-react's commit-phase
|
|
// selection sync (setBaseAndExtent) threw IndexSizeError mid-render and tripped
|
|
// the composer error boundary. Both ops are pure model transforms (no DOM
|
|
// resolution), so running them synchronously is safe.
|
|
Transforms.move(editor);
|
|
if (withSpace) editor.insertText(' ');
|
|
// Re-assert focus next tick (a pick usually keeps the editor focused). Guarded
|
|
// because ReactEditor.focus resolves the DOM; with the caret now in a real text
|
|
// node this is safe, but stay defensive against a mid-flight editor.
|
|
setTimeout(() => {
|
|
try {
|
|
ReactEditor.focus(editor);
|
|
} catch {
|
|
// The editor DOM can be mid-flight (autocomplete just closed / re-render
|
|
// landed). The element is already inserted, so skip the focus nudge.
|
|
}
|
|
}, 0);
|
|
};
|
|
|
|
interface PointUntilCharOptions {
|
|
match: (char: string) => boolean;
|
|
reverse?: boolean;
|
|
}
|
|
export const getPointUntilChar = (
|
|
editor: Editor,
|
|
cursorPoint: BasePoint,
|
|
options: PointUntilCharOptions,
|
|
): BasePoint | undefined => {
|
|
let targetPoint: BasePoint | undefined;
|
|
let prevPoint: BasePoint | undefined;
|
|
let char: string | undefined;
|
|
|
|
const pointItr = Editor.positions(editor, {
|
|
at: {
|
|
anchor: Editor.start(editor, []),
|
|
focus: Editor.point(editor, cursorPoint, { edge: 'start' }),
|
|
},
|
|
unit: 'character',
|
|
reverse: options.reverse,
|
|
});
|
|
|
|
for (const point of pointItr) {
|
|
if (!Point.equals(point, cursorPoint) && prevPoint) {
|
|
char = Editor.string(editor, { anchor: point, focus: prevPoint });
|
|
|
|
if (options.match(char)) break;
|
|
targetPoint = point;
|
|
}
|
|
prevPoint = point;
|
|
}
|
|
return targetPoint;
|
|
};
|
|
|
|
export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => {
|
|
const { selection } = editor;
|
|
if (!selection || !Range.isCollapsed(selection)) return undefined;
|
|
const [cursorPoint] = Range.edges(selection);
|
|
const worldStartPoint = getPointUntilChar(editor, cursorPoint, {
|
|
reverse: true,
|
|
match: (char) => char === ' ',
|
|
});
|
|
return worldStartPoint && Editor.range(editor, worldStartPoint, cursorPoint);
|
|
};
|
|
|
|
export const isEmptyEditor = (editor: Editor): boolean => {
|
|
const firstChildren = editor.children[0];
|
|
if (firstChildren && Element.isElement(firstChildren)) {
|
|
const isEmpty = editor.children.length === 1 && Editor.isEmpty(editor, firstChildren);
|
|
return isEmpty;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
export const getBeginCommand = (editor: Editor): string | undefined => {
|
|
const lineBlock = editor.children[0];
|
|
if (!Element.isElement(lineBlock)) return undefined;
|
|
if (lineBlock.type !== BlockType.Paragraph) return undefined;
|
|
|
|
const [firstInline, secondInline] = lineBlock.children;
|
|
const isEmptyText = Text.isText(firstInline) && firstInline.text.trim() === '';
|
|
if (!isEmptyText) return undefined;
|
|
if (Element.isElement(secondInline) && secondInline.type === BlockType.Command)
|
|
return secondInline.command;
|
|
return undefined;
|
|
};
|