// 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(); 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[]) { 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); }