144 lines
6.4 KiB
TypeScript
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);
|
|
}
|