Files
Endoleap/src/lib/canvas-tree.ts
T

120 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧多board画布树的显示投影、移动参考系和三路合并。每个owner保留独立CanvasDocument,
* 显示ID加board命名空间,投影节点/边不能直接回写为owner快照。分区展开/折叠通过旧端口映射呈现。
*/
import { nodeSize } from "./canvas-geometry.ts";
import type { CanvasDocument, CanvasNode } from "./canvas.ts";
import type { CanvasEdgeModel, EdgeData } from "./canvas-v2.ts";
/** 显示ID到真实board/元素ID的映射与平移/缩放参考系;旧协议不在此表达父级旋转矩阵。 */
export type CanvasReference = { boardId: string; id: string; origin: { x: number; y: number }; scale: number; parentBoard?: string; parentFrame?: string };
/** 已读取的各owner文档表,缺失子board不会凭空创建空文档。 */
export type CanvasDocuments = Map<string, CanvasDocument>;
/** 根board保留原ID,其余加board::前缀,避免旧协议不同board里的同名元素显示碰撞。 */
export const displayID = (root: string, board: string, id: string) => root === board ? id : `${board}::${id}`;
/** 路径位置点应用平移+缩放,切线/首尾控制量是向量只缩放;inverse将显示路径转换回owner空间。
* 没有path时复制data,避免调用方修改投影时改变原始对象。 */
export function transformEdgeData(data: EdgeData | undefined, reference: CanvasReference, inverse = false): EdgeData | undefined {
if (!data?.path) return data ? structuredClone(data) : undefined;
const { origin, scale } = reference;
/** vector=true排除origin偏移,不能把切线当绝对点从而重复平移。 */
const point = (point: { x: number; y: number }, vector = false) => inverse
? { x: (point.x - (vector ? 0 : origin.x)) / scale, y: (point.y - (vector ? 0 : origin.y)) / scale }
: { x: point.x * scale + (vector ? 0 : origin.x), y: point.y * scale + (vector ? 0 : origin.y) };
return { ...data, path: { ...data.path, ...(data.path.startControl ? { startControl: point(data.path.startControl, true) } : {}), ...(data.path.endControl ? { endControl: point(data.path.endControl, true) } : {}), knots: data.path.knots.map((knot) => ({ ...knot, position: point(knot.position), in: point(knot.in, true), out: point(knot.out, true) })) } };
}
/** Projection is disposable view data. Only owner documents are ever serialized. */
/** 从root递归展开已加载且未折叠的子图,输出显示对象、反向引用和board参考系。
* 先完成可见节点,再处理暂存边,端口递归可映射到真正显示的内层节点。 */
export function projectCanvasTree(root: string, documents: CanvasDocuments) {
const nodeRefs = new Map<string, CanvasReference>(), edgeRefs = new Map<string, CanvasReference>();
const nodes: CanvasNode[] = [], edges: CanvasEdgeModel[] = [];
const boards = new Map<string, CanvasReference>();
// 这里pending只是本次投影中延后处理的边,不是网络同步队列。
const pending: { edge: CanvasEdgeModel; ref: CanvasReference }[] = [];
/** 用祖先集合防循环并限制深度;子内容原点固定偏移24/36,contentScale乘父显示比例。 */
function visit(boardId: string, origin: { x: number; y: number }, scale: number, ancestors: Set<string>, parentBoard?: string, parentFrame?: string) {
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
const document = documents.get(boardId);
if (!document) return;
const next = new Set(ancestors); next.add(boardId);
const reference = { boardId, id: boardId, origin, scale, parentBoard, parentFrame }; boards.set(boardId, reference);
for (const node of document.nodes) {
const id = displayID(root, boardId, node.id);
nodeRefs.set(id, { ...reference, id: node.id });
const width = nodeSize(node).width * scale, height = nodeSize(node).height * scale;
const position = { x: origin.x + node.position.x * scale, y: origin.y + node.position.y * scale };
// 只改投影id/尺寸/frameId/viewScale,nodeRefs保留真实owner身份供后续命令路由。
nodes.push({ ...node, id, position, width, height, style: { ...node.style, width, height }, data: { ...node.data, ...(parentBoard && parentFrame ? { frameId: displayID(root, parentBoard, parentFrame) } : {}), viewScale: scale } });
if (node.data.subgraph && !node.data.presentation?.collapsed) visit(node.data.subgraph.boardId,
{ x: position.x + 24 * scale, y: position.y + 36 * scale }, scale * (node.data.presentation?.contentScale || 1), next, boardId, node.id);
}
for (const edge of document.edges) pending.push({ edge, ref: reference });
}
visit(root, { x: 0, y: 0 }, 1, new Set());
const visible = new Set(nodes.map((node) => node.id));
/** 展开入口且端口有效时递归寻内层显示端点;找不到已显示内层就保留外侧入口端口。 */
function endpoint(board: string, nodeId: string, handle: string | null | undefined, depth = 0): { id: string; handle?: string | null } {
const id = displayID(root, board, nodeId);
if (depth > 8) return { id, handle };
const node = documents.get(board)?.nodes.find((node) => node.id === nodeId);
const port = node?.data.ports?.find((port) => `port:${port.id}` === handle);
if (port && node?.data.subgraph && !node.data.presentation?.collapsed) {
const inner = endpoint(node.data.subgraph.boardId, port.nodeId, port.handle, depth + 1);
if (visible.has(inner.id)) return inner;
}
return { id, handle };
}
// 边引用仍指回原owner文档,显示端点可以在另一展开分区内,数据路径按本边owner变换。
for (const { edge, ref } of pending) {
const id = displayID(root, ref.boardId, edge.id); edgeRefs.set(id, { ...ref, id: edge.id });
const source = endpoint(ref.boardId, edge.source, edge.sourceHandle), target = endpoint(ref.boardId, edge.target, edge.targetHandle);
edges.push({ ...edge, id, source: source.id, target: target.id, sourceHandle: source.handle, targetHandle: target.handle, data: transformEdgeData(edge.data, ref) });
}
return { nodes, edges, nodeRefs, edgeRefs, boards };
}
/** A selected child and its selected parent frame must not receive the same drag twice. */
/** 父框与子节点同时选中拖动时,先递归取得父框移动后的显示原点,避免同一拖动位移叠加两次。 */
export function referenceAfterMoves(root: string, documents: CanvasDocuments, boards: Map<string, CanvasReference>, boardId: string, moves: Map<string, { x: number; y: number }>): CanvasReference {
const reference = boards.get(boardId);
if (!reference) throw new Error("invalid_reference");
if (boardId === root || !reference.parentBoard || !reference.parentFrame) return reference;
const parent = referenceAfterMoves(root, documents, boards, reference.parentBoard, moves);
const frame = documents.get(reference.parentBoard)?.nodes.find((node) => node.id === reference.parentFrame);
if (!frame) throw new Error("invalid_reference");
const position = moves.get(displayID(root, reference.parentBoard, reference.parentFrame)) || { x: parent.origin.x + frame.position.x * parent.scale, y: parent.origin.y + frame.position.y * parent.scale };
return { ...reference, origin: { x: position.x + 24 * parent.scale, y: position.y + 36 * parent.scale } };
}
/** 三路合并的稳定键JSON比较,数组顺序仍参与比较;只供旧格式JSON风格数据使用。 */
const equal = (a: unknown, b: unknown) => JSON.stringify(a, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value)
=== JSON.stringify(b, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value);
/** Three-way merge preserves local geometry while applying server AI output. */
/** base/local/remote三路字段合并:一侧未改采用另一侧,两侧同值也接受,不兼容双改明确冲突。
* nodes/edges按ID合并,框架瞬态字段先剥离;viewport与选择最终取本地,绝非远端整快照覆盖。 */
export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument, remote: CanvasDocument): CanvasDocument {
/** 仅普通对象可逐字段递归,数组/标量两侧不同改动不会“最后写入获胜”,而是抛version_conflict。 */
const merge = (base: unknown, local: unknown, remote: unknown): unknown => {
if (equal(local, base)) return remote;
if (equal(remote, base) || equal(local, remote)) return local;
if ([base, local, remote].every((v) => !!v && typeof v === "object" && !Array.isArray(v))) {
const b = base as Record<string, unknown>, l = local as Record<string, unknown>, r = remote as Record<string, unknown>;
return Object.fromEntries([...new Set([...Object.keys(b), ...Object.keys(l), ...Object.keys(r)])].map((key) => [key, merge(b[key], l[key], r[key])]).filter(([, value]) => value !== undefined));
}
throw new Error("version_conflict");
};
/** ID集合以remote序后补local新项,缺项参与三路删除判断,删除与另一侧修改可能冲突。 */
function collection<T extends { id: string }>(base: T[], local: T[], remote: T[]): T[] {
const b = new Map(base.map((v) => [v.id, v])), l = new Map(local.map((v) => [v.id, v])), r = new Map(remote.map((v) => [v.id, v]));
return [...new Set([...r.keys(), ...l.keys()])].map((id) => merge(b.get(id), l.get(id), r.get(id))).filter((v) => v !== undefined) as T[];
}
/** 排除选择/拖动/测量/style这些框架字段,它们不该引发业务字段合并冲突。 */
const strip = <T extends { selected?: boolean; dragging?: boolean; measured?: unknown; style?: unknown }>(value: T) => { const { selected, dragging, measured, style, ...clean } = value; return clean; };
const result = {
schema_version: Math.max(local.schema_version, remote.schema_version) as 1 | 2,
nodes: collection(base.nodes.map(strip), local.nodes.map(strip), remote.nodes.map(strip)) as CanvasNode[],
edges: collection(base.edges.map(strip), local.edges.map(strip), remote.edges.map(strip)) as CanvasEdgeModel[], viewport: local.viewport,
};
// 合并后重新生成样式与frame层序,恢复本地选择;不继承远端React Flow临时测量。
return { ...result, nodes: result.nodes.map((node) => ({ ...node, selected: local.nodes.find((item) => item.id === node.id)?.selected, style: { width: node.width, height: node.height, ...(node.data.kind === "frame" ? { pointerEvents: "none" } : {}) }, ...(node.data.kind === "frame" ? { zIndex: -1 } : {}) })) };
}