refactor(*): 平台架构 - 拆分后端领域模块与画布前端组件并补充测试
This commit is contained in:
1 parent
e0a2697c60
commit
7f5d87ef1a
186 files changed
+33727
-29905
No files matched your search
@@ -0,0 +1,143 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in new issue
Block a user