dde75ee389
CI / Build & Quality Checks (push) Successful in 11m12s
When selecting an autocomplete suggestion (user/room mention, emoticon,
command), Slate's replaceWithElement inserts a void inline node into the
model but React hasn't flushed the DOM update yet. Calling
Transforms.move + insertText immediately after causes ReactEditor.toDOMNode
to fail with "Cannot resolve a DOM node from slate node: {\"text\":\" \"}".
Fix: wrap moveCursor body in setTimeout(fn, 0) so React can flush the void
element's DOM node before Slate attempts to resolve the cursor position.
Also call ReactEditor.focus to restore editor focus after the autocomplete
menu item click blurs the editor.
Fixes all callers: UserMentionAutocomplete, RoomMentionAutocomplete,
EmoticonAutocomplete, CommandAutocomplete.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
282 lines
7.2 KiB
TypeScript
282 lines
7.2 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) => {
|
|
Transforms.select(editor, selectRange);
|
|
Transforms.insertNodes(editor, element);
|
|
Transforms.collapse(editor, {
|
|
edge: 'end',
|
|
});
|
|
};
|
|
|
|
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
|
// Defer to the next tick so React can flush any pending void-element DOM
|
|
// updates (e.g. after inserting a mention) before Slate resolves cursor
|
|
// positions via ReactEditor.toDOMNode — otherwise Slate throws
|
|
// "Cannot resolve a DOM node from slate node".
|
|
setTimeout(() => {
|
|
ReactEditor.focus(editor);
|
|
Transforms.move(editor);
|
|
if (withSpace) editor.insertText(' ');
|
|
}, 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;
|
|
};
|