export type CanvasConflictChoice = "local" | "remote"; const absent = Symbol("absent"); const record = (v: any) => v !== absent && v !== null && typeof v === "object" && !Array.isArray(v); const same = (a: any, b: any): boolean => { if (a === b) return true; if (record(a) && record(b)) { const keys = Object.keys(a); return ( keys.length === Object.keys(b).length && keys.every((k) => Object.hasOwn(b, k) && same(a[k], b[k])) ); } return ( Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => same(v, b[i])) ); }; /** Three-way merge. Never resolve edits to the same field implicitly. */ export function mergeCanvasSnapshots( baseText: string, localText: string, remoteText: string, choice?: CanvasConflictChoice, ) { const conflicts: string[] = []; function merge(base: any, local: any, remote: any, path: string): any { // View and selection belong to this tab, not shared document content. if (["canvasData.viewport", "canvasData.selectedNodeIds", "canvasData.tool"].includes(path)) return local; if (same(local, remote) || same(base, remote)) return local; if (same(base, local)) return remote; if (record(base) && record(local) && record(remote)) { const output: Record = {}; for (const key of new Set([ ...Object.keys(base), ...Object.keys(local), ...Object.keys(remote), ])) { if (["__proto__", "prototype", "constructor"].includes(key)) continue; const result = merge( Object.hasOwn(base, key) ? base[key] : absent, Object.hasOwn(local, key) ? local[key] : absent, Object.hasOwn(remote, key) ? remote[key] : absent, path ? `${path}.${key}` : key, ); if (result !== absent) output[key] = result; } return output; } const keyed = (v: any) => Array.isArray(v) && v.every((item) => record(item) && typeof item.id === "string") && new Set(v.map((item) => item.id)).size === v.length; if (keyed(base) && keyed(local) && keyed(remote)) { const b = new Map(base.map((n: any) => [n.id, n])), l = new Map(local.map((n: any) => [n.id, n])), r = new Map(remote.map((n: any) => [n.id, n])); const order = (items: any[]) => items.filter((n) => b.has(n.id) && l.has(n.id) && r.has(n.id)).map((n) => n.id); const bo = order(base), lo = order(local), ro = order(remote); let primary = local; if (same(lo, bo)) primary = remote; else if (!same(ro, bo) && !same(lo, ro)) { conflicts.push(`${path}.顺序`); primary = choice === "remote" ? remote : local; } const ids = new Set([ ...primary.map((n: any) => n.id), ...remote.map((n: any) => n.id), ...local.map((n: any) => n.id), ...base.map((n: any) => n.id), ]); return [...ids] .map((id) => merge( b.has(id) ? b.get(id) : absent, l.has(id) ? l.get(id) : absent, r.has(id) ? r.get(id) : absent, `${path}[${id}]`, ), ) .filter((v) => v !== absent); } conflicts.push(path || "画布"); return choice === "remote" ? remote : local; } const parse = (text: string) => { const value = JSON.parse(text); for (const node of value.canvasData?.nodes || []) { delete node.createdAt; delete node.updatedAt; } return value; }; const local = parse(localText); const snapshot = merge(parse(baseText), local, parse(remoteText), ""); if (snapshot.canvasData && local.canvasData) for (const key of ["viewport", "selectedNodeIds", "tool"]) if (Object.hasOwn(local.canvasData, key)) snapshot.canvasData[key] = local.canvasData[key]; if (Array.isArray(snapshot.canvasData?.nodes) && Array.isArray(snapshot.canvasData?.edges)) { const ids = new Set(snapshot.canvasData.nodes.map((n: any) => n.id)); snapshot.canvasData.edges = snapshot.canvasData.edges.filter( (e: any) => ids.has(e.source) && ids.has(e.target), ); } return { snapshot: JSON.stringify(snapshot), conflicts }; }