Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-mention-editable.ts

144 lines
6.4 KiB
TypeScript

// contentEditable 与纯文本之间的双向映射:拆分令牌、序列化、光标偏移换算与选区读写。
//
// 所有偏移都以序列化后的纯文本为准,保证 @ 令牌在光标移动、粘贴、撤销时不被截断。
import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { EditableSelection, MentionTextPart } from "./canvas-resource-mention-textarea";
import { createInlineMentionChip } from "./canvas-mention-chips";
export function splitMentionText(value: string, references: CanvasResourceReference[]) {
if (!references.length || !value) return value ? [{ type: "text", text: value } as MentionTextPart] : [];
const referenceByToken = new Map<string, { reference: CanvasResourceReference; serializedToken: string }>();
references.forEach((reference) => {
const serializedToken = canvasResourceMentionToken(reference);
referenceByToken.set(serializedToken, { reference, serializedToken });
referenceByToken.set(`@${reference.label}`, { reference, serializedToken });
if (reference.nodeId && !reference.assetId) referenceByToken.set(`@[node:${reference.nodeId}]`, { reference, serializedToken });
});
const tokens = [...referenceByToken.keys()].sort((a, b) => b.length - a.length);
const parts: MentionTextPart[] = [];
let index = 0;
while (index < value.length) {
const token = tokens.find((item) => value.startsWith(item, index) && hasMentionBoundary(value, index + item.length));
if (!token) {
const nextTokenIndex = findNextMentionIndex(value, tokens, index + 1);
const end = nextTokenIndex < 0 ? value.length : nextTokenIndex;
parts.push({ type: "text", text: value.slice(index, end) });
index = end;
continue;
}
const matched = referenceByToken.get(token)!;
parts.push({ type: "mention", token: matched.serializedToken, reference: matched.reference });
index += token.length;
}
return parts;
}
export function renderEditableContent(editor: HTMLElement, value: string, references: CanvasResourceReference[]) {
const parts = splitMentionText(value, references);
const nodes = parts.map((part) => (part.type === "mention" ? createInlineMentionChip(part.reference, part.token) : document.createTextNode(part.text)));
editor.replaceChildren(...nodes);
}
export function findNextMentionIndex(value: string, tokens: string[], fromIndex: number) {
let next = -1;
tokens.forEach((token) => {
const index = value.indexOf(token, fromIndex);
if (index >= 0 && hasMentionBoundary(value, index + token.length) && (next < 0 || index < next)) next = index;
});
return next;
}
export function hasMentionBoundary(value: string, index: number) {
const char = value[index];
return !char || /\s|[,.!?;:,。!?;:、)\]}】)]/.test(char);
}
export function serializeEditableValue(root: HTMLElement) {
return serializeNodeList(root.childNodes).replace(/\u00a0/g, " ");
}
export function serializeNodeList(nodes: NodeListOf<ChildNode> | ChildNode[]) {
let text = "";
nodes.forEach((node) => {
text += serializeNode(node);
});
return text;
}
export function serializeNode(node: ChildNode): string {
if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
if (!(node instanceof HTMLElement)) return "";
const token = node.dataset.mentionToken;
if (token) return token;
if (node.tagName === "BR") return "\n";
return serializeNodeList(node.childNodes);
}
export function getEditableSelection(root: HTMLElement | null): EditableSelection | null {
if (!root) return null;
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return null;
const range = selection.getRangeAt(0);
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null;
const start = offsetForPoint(root, range.startContainer, range.startOffset);
const end = offsetForPoint(root, range.endContainer, range.endOffset);
return start <= end ? { start, end } : { start: end, end: start };
}
export function offsetForPoint(root: Node, target: Node, targetOffset: number): number {
if (root === target) {
if (root.nodeType === Node.TEXT_NODE) return targetOffset;
return Array.from(root.childNodes)
.slice(0, targetOffset)
.reduce((offset, node) => offset + plainTextLength(node), 0);
}
let offset = 0;
for (const child of Array.from(root.childNodes)) {
if (child === target || child.contains(target)) return offset + offsetForPoint(child, target, targetOffset);
offset += plainTextLength(child);
}
return offset;
}
export function setEditableSelection(root: HTMLElement, offset: number) {
const range = document.createRange();
const point = pointForOffset(root, Math.max(0, offset));
range.setStart(point.node, point.offset);
range.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
export function pointForOffset(root: Node, offset: number): { node: Node; offset: number } {
if (root.nodeType === Node.TEXT_NODE) return { node: root, offset: Math.min(offset, root.textContent?.length || 0) };
let remaining = offset;
const children = Array.from(root.childNodes);
for (let index = 0; index < children.length; index += 1) {
const child = children[index];
const length = plainTextLength(child);
if (remaining > length) {
remaining -= length;
continue;
}
if (isMentionElement(child)) return { node: root, offset: remaining <= length / 2 ? index : index + 1 };
return pointForOffset(child, remaining);
}
return { node: root, offset: children.length };
}
export function plainTextLength(node: Node): number {
if (node.nodeType === Node.TEXT_NODE) return node.textContent?.length || 0;
if (node instanceof HTMLElement) {
const token = node.dataset.mentionToken;
if (token) return token.length;
if (node.tagName === "BR") return 1;
}
return Array.from(node.childNodes).reduce((total, child) => total + plainTextLength(child), 0);
}
export function isMentionElement(node: Node): node is HTMLElement {
return node instanceof HTMLElement && Boolean(node.dataset.mentionToken);
}