112 lines
4.1 KiB
TypeScript
112 lines
4.1 KiB
TypeScript
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<string, any> = {};
|
|
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 };
|
|
}
|