120 lines
10 KiB
TypeScript
120 lines
10 KiB
TypeScript
/**
|
||
* 旧多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 } : {}) })) };
|
||
}
|