docs(canvas): explain legacy state history synchronization and subgraphs
This commit is contained in:
@@ -1,23 +1,37 @@
|
||||
/**
|
||||
* 旧多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);
|
||||
@@ -29,6 +43,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
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);
|
||||
@@ -37,6 +52,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
}
|
||||
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 };
|
||||
@@ -48,6 +64,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
}
|
||||
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);
|
||||
@@ -56,6 +73,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
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");
|
||||
@@ -67,10 +85,14 @@ export function referenceAfterMoves(root: string, documents: CanvasDocuments, bo
|
||||
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;
|
||||
@@ -80,15 +102,18 @@ export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument
|
||||
}
|
||||
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 } : {}) })) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user