435 lines
35 KiB
TypeScript
435 lines
35 KiB
TypeScript
"use client";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { EdgeChange, NodeChange } from "@xyflow/react";
|
|
import { api, ApiError } from "@/lib/api";
|
|
import { canvasReducer, decodeCanvas, type CanvasAction, type CanvasDocument, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
|
|
import { CanvasSync, canvasFingerprint, canvasPayload, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
|
|
import { CANVAS_ACCEPT, type EdgeData } from "@/lib/canvas-v2";
|
|
import { subgraphMovePath, subgraphMoveParticipants } from "@/lib/canvas-subgraphs";
|
|
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData, referenceAfterMoves } from "@/lib/canvas-tree";
|
|
|
|
type OwnedSnapshot = CanvasSnapshot & { board_id: string };
|
|
export type CanvasCommandResult = { transaction_id?: string; parent?: OwnedSnapshot; child?: OwnedSnapshot; snapshots?: OwnedSnapshot[]; children?: OwnedSnapshot[]; canvas_version?: number; applied_versions?: Record<string, number>; archived_board_ids?: string[]; undo_receipt?: { expires_at: string }; [key: string]: unknown };
|
|
type LocalHistory = { boardId: string; before: CanvasDocument; related?: Map<string, CanvasDocument>; pendingMove?: { id: string; source: string; target: string } };
|
|
type TransactionHistory = { boardId: string; path: string; payload: Record<string, unknown>; id: string; participants: string[] };
|
|
type Entry = LocalHistory | TransactionHistory;
|
|
type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise<void>; resolve: () => void };
|
|
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
|
|
|
|
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string) {
|
|
const [documents, setDocuments] = useState(new Map([[root, initial]]));
|
|
const current = useRef(documents); current.current = documents;
|
|
const [status, setStatus] = useState("saved");
|
|
const [error, setError] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [memberJobs, setMemberJobs] = useState<MemberJob[]>([]);
|
|
const memberCapability = useRef<Promise<boolean> | null>(null);
|
|
const members = useRef<MemberJob[]>([]), memberRunning = useRef(false), dragging = useRef(false);
|
|
const showMembers = () => { if (mounted.current) setMemberJobs([...members.current]); };
|
|
function setDragging(value: boolean) { dragging.current = value; if (!value) publish(new Map(current.current)); }
|
|
function protectedNodes(boardId: string) {
|
|
const ids = new Set(members.current.flatMap((job) => [job.source, job.target].includes(boardId) ? job.ids : []));
|
|
if (members.current.length) for (const node of current.current.get(boardId)?.nodes || []) if (node.data.subgraph) ids.add(node.id);
|
|
return ids;
|
|
}
|
|
const [pendingCommand, setPendingCommand] = useState(false);
|
|
const retryOperation = useRef<(() => Promise<CanvasCommandResult>) | null>(null);
|
|
const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused;
|
|
const writers = useRef(new Map<string, CanvasSync>());
|
|
const loading = useRef(new Set<string>());
|
|
const [retryLoad, setRetryLoad] = useState(0);
|
|
const [recoveries, setRecoveries] = useState(new Map<string, CanvasDocument>());
|
|
const pendingRecovery = useRef(recoveries); pendingRecovery.current = recoveries;
|
|
function inspectRecovery(boardId: string, value: CanvasSnapshot) {
|
|
const local = readLocalRecovery(localStorage, `endoleap_canvas_v1:${workspace}:${boardId}`, value);
|
|
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
|
|
}
|
|
function resolveRecovery(boardId: string, restore: boolean) {
|
|
const draft = pendingRecovery.current.get(boardId);
|
|
if (restore && draft) update(boardId, { type: "replace", document: draft });
|
|
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
|
|
localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`);
|
|
}
|
|
const undo = useRef<Entry[]>([]), redo = useRef<Entry[]>([]);
|
|
const boardAliases = useRef(new Map<string, string>());
|
|
const [, tick] = useState(0);
|
|
const views = useMemo(() => projectCanvasTree(root, documents), [root, documents]);
|
|
const view = useRef(views); view.current = views;
|
|
function publish(next: Map<string, CanvasDocument>) { current.current = next; if (mounted.current) setDocuments(next); }
|
|
function install(boardId: string, value: CanvasSnapshot) {
|
|
const writer = writers.current.get(boardId);
|
|
if (writer) writer.adopt(value);
|
|
else if (snapshot) writers.current.set(boardId, new CanvasSync(api, canvasPath(workspace, boardId), value, (state) => {
|
|
if (!mounted.current) return;
|
|
setStatus(state.status); if (state.error) setError(state.error);
|
|
}));
|
|
}
|
|
// Initialization never writes a snapshot to the server.
|
|
if (snapshot && !writers.current.has(root)) install(root, snapshot);
|
|
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
const update = (boardId: string, action: CanvasAction) => {
|
|
if (locked.current || retryOperation.current) return;
|
|
const before = current.current.get(boardId); if (!before) return;
|
|
const result = canvasReducer(history(before), action);
|
|
const protectedIDs = protectedNodes(boardId);
|
|
if (protectedIDs.size) result.present = { ...result.present, nodes: result.present.nodes.map((node) => {
|
|
const prior = before.nodes.find((item) => item.id === node.id);
|
|
if (!prior || !protectedIDs.has(node.id)) return node;
|
|
const moving = members.current.some((job) => [job.source, job.target].includes(boardId) && job.ids.includes(node.id));
|
|
return prior.data.subgraph && !moving ? { ...node, position: prior.position, width: prior.width, height: prior.height, data: { ...node.data, subgraph: prior.data.subgraph, ports: prior.data.ports, presentation: prior.data.presentation } } : { ...prior, selected: node.selected };
|
|
}).concat(before.nodes.filter((node) => protectedIDs.has(node.id) && !result.present.nodes.some((item) => item.id === node.id))) };
|
|
if (result.past.length) { undo.current.push({ boardId, before }); undo.current = undo.current.slice(-40); redo.current = []; }
|
|
publish(new Map(current.current).set(boardId, result.present));
|
|
};
|
|
const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action);
|
|
const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); };
|
|
const nodesChanged = (changes: NodeChange<CanvasNode>[]) => {
|
|
if (locked.current || retryOperation.current) return;
|
|
const groups = new Map<string, NodeChange<CanvasNode>[]>();
|
|
const moves = new Map(changes.flatMap((change) => change.type === "position" && change.position ? [[change.id, change.position] as const] : []));
|
|
for (const change of changes) {
|
|
if (change.type === "add" || change.type === "replace") continue;
|
|
const ref = view.current.nodeRefs.get(change.id); if (!ref) continue;
|
|
const adjusted = referenceAfterMoves(root, current.current, view.current.boards, ref.boardId, moves);
|
|
const next = { ...change, id: ref.id };
|
|
if (next.type === "position" && next.position) next.position = { x: (next.position.x - adjusted.origin.x) / ref.scale, y: (next.position.y - adjusted.origin.y) / ref.scale };
|
|
if (next.type === "dimensions") {
|
|
// ResizeObserver measurements are editor state. Only an explicit resize
|
|
// gesture may write node dimensions back to the owning snapshot.
|
|
if (!next.resizing) next.setAttributes = false;
|
|
if (next.dimensions) next.dimensions = { width: next.dimensions.width / ref.scale, height: next.dimensions.height / ref.scale };
|
|
}
|
|
groups.set(ref.boardId, [...(groups.get(ref.boardId) || []), next]);
|
|
}
|
|
for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes });
|
|
};
|
|
const edgesChanged = (changes: EdgeChange[]) => {
|
|
const groups = new Map<string, EdgeChange[]>();
|
|
for (const change of changes) {
|
|
if (change.type === "add" || change.type === "replace") continue;
|
|
const ref = view.current.edgeRefs.get(change.id); if (ref) groups.set(ref.boardId, [...(groups.get(ref.boardId) || []), { ...change, id: ref.id }]);
|
|
}
|
|
for (const [boardId, changes] of groups) update(boardId, { type: "edges", changes });
|
|
};
|
|
const patchEdge = (id: string, data: EdgeData, transient = false) => {
|
|
const ref = view.current.edgeRefs.get(id); if (ref) update(ref.boardId, { type: "edge-data", id: ref.id, data: transformEdgeData(data, ref, true)!, transient });
|
|
};
|
|
const select = (id: string) => {
|
|
for (const [boardId, document] of current.current) update(boardId, { type: "nodes", changes: document.nodes.map((node) => ({ type: "select", id: node.id, selected: view.current.nodeRefs.get(id)?.boardId === boardId && view.current.nodeRefs.get(id)?.id === node.id })) });
|
|
};
|
|
const read = async (boardId: string, signal?: AbortSignal) => decodeSnapshot(await api<CanvasSnapshot>(canvasPath(workspace, boardId), { headers: { Accept: CANVAS_ACCEPT }, signal }));
|
|
function supportsMemberPositions(): Promise<boolean> {
|
|
if (!snapshot) return Promise.resolve(false);
|
|
if (!memberCapability.current) {
|
|
const capability: Promise<boolean> = api<{ subgraphs?: { member_positions?: boolean } }>(canvasPath(workspace, root) + "/capabilities", { headers: { Accept: CANVAS_ACCEPT } })
|
|
.then((value) => value.subgraphs?.member_positions === true)
|
|
.catch((error) => { if (memberCapability.current === capability) memberCapability.current = null; throw error; });
|
|
memberCapability.current = capability;
|
|
}
|
|
return memberCapability.current;
|
|
}
|
|
useEffect(() => {
|
|
void supportsMemberPositions().catch(() => { /* A failed prefetch is retried on demand, never cached as unsupported. */ });
|
|
}, [root, workspace, !!snapshot]);
|
|
async function ensure(boardId: string, recursive = false, ancestors = new Set<string>()): Promise<void> {
|
|
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
|
|
let document = current.current.get(boardId);
|
|
if (!document) {
|
|
const value = snapshot ? await read(boardId) : (() => { const raw = localStorage.getItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`); return { version: 1, document: raw ? decodeCanvas(JSON.stringify(JSON.parse(raw).document)) : { schema_version: 2 as const, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; })();
|
|
install(boardId, value); if (snapshot) inspectRecovery(boardId, value); document = value.document; publish(new Map(current.current).set(boardId, document));
|
|
}
|
|
if (recursive) { const next = new Set(ancestors); next.add(boardId); for (const node of document.nodes) if (node.data.subgraph) await ensure(node.data.subgraph.boardId, true, next); }
|
|
}
|
|
async function exportDocuments() {
|
|
return exclusive(async () => {
|
|
await ensure(root, true);
|
|
const reachable = new Map<string, CanvasDocument>();
|
|
const collect = (id: string) => { const document = current.current.get(id)!; reachable.set(id, document); for (const node of document.nodes) if (node.data.subgraph && !reachable.has(node.data.subgraph.boardId)) collect(node.data.subgraph.boardId); };
|
|
collect(root); return reachable;
|
|
});
|
|
}
|
|
useEffect(() => {
|
|
if (!snapshot || !parentBoardId) return;
|
|
let alive = true;
|
|
void (async () => {
|
|
let id: string | undefined = parentBoardId; const seen = new Set<string>([root]);
|
|
while (id && alive) {
|
|
if (seen.has(id) || seen.size > 8) throw new Error("subgraph_cycle"); seen.add(id);
|
|
const [metadata, value]: [{ parent_board_id?: string }, CanvasSnapshot] = await Promise.all([api<{ parent_board_id?: string }>(`workspaces/${workspace}/materials/${id}`), read(id)]);
|
|
if (!alive) return;
|
|
if (!current.current.has(id)) { install(id, value); publish(new Map(current.current).set(id, value.document)); }
|
|
id = metadata.parent_board_id;
|
|
}
|
|
})().catch((error) => { if (alive) setError(error.message); });
|
|
return () => { alive = false; };
|
|
// Root identity is stable until this editor unmounts.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [parentBoardId, root, workspace]);
|
|
useEffect(() => {
|
|
for (const [boardId, document] of documents) for (const node of document.nodes) {
|
|
if (!view.current.boards.has(boardId)) continue;
|
|
const child = node.data.subgraph?.boardId;
|
|
if (!child || node.data.presentation?.collapsed || documents.has(child) || loading.current.has(child)) continue;
|
|
loading.current.add(child);
|
|
read(child).then((value) => {
|
|
if (!mounted.current || current.current.has(child) || ![...current.current.values()].some((doc) => doc.nodes.some((node) => node.data.subgraph?.boardId === child))) return;
|
|
install(child, value); inspectRecovery(child, value); publish(new Map(current.current).set(child, value.document));
|
|
}).catch((error) => { if (mounted.current) setError(error.message); }).finally(() => loading.current.delete(child));
|
|
}
|
|
// Values and writers are scoped to this mounted root/workspace.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [documents, retryLoad]);
|
|
useEffect(() => {
|
|
if (paused) return;
|
|
const backup = setTimeout(() => {
|
|
for (const [boardId, document] of current.current) try {
|
|
if (pendingRecovery.current.has(boardId)) continue;
|
|
const writer = writers.current.get(boardId);
|
|
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false }));
|
|
} catch { setError("local_backup_failed"); }
|
|
}, 250);
|
|
const save = setTimeout(() => {
|
|
if (locked.current || retryOperation.current || pause.current || members.current.length || dragging.current) return;
|
|
for (const [boardId, document] of current.current) if (!pendingRecovery.current.has(boardId)) void writers.current.get(boardId)?.enqueue(document);
|
|
}, 800);
|
|
return () => { clearTimeout(backup); clearTimeout(save); };
|
|
}, [documents, paused, workspace, memberJobs]);
|
|
useEffect(() => {
|
|
const backup = () => {
|
|
if (pause.current) return;
|
|
for (const [boardId, document] of current.current) {
|
|
if (pendingRecovery.current.has(boardId)) continue;
|
|
try { const writer = writers.current.get(boardId); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
|
|
}
|
|
};
|
|
window.addEventListener("pagehide", backup);
|
|
return () => { window.removeEventListener("pagehide", backup); backup(); };
|
|
}, [workspace]);
|
|
async function flush(deferMemberPositions = false) {
|
|
for (const [boardId, document] of current.current) {
|
|
if (pendingRecovery.current.has(boardId)) throw new Error("draft_recovery_required");
|
|
const writer = writers.current.get(boardId); if (!writer) continue;
|
|
const deferred = deferMemberPositions ? new Set(members.current.filter((job) => job.source === boardId).flatMap((job) => job.ids)) : new Set<string>();
|
|
const saving = deferred.size ? { ...document, nodes: document.nodes.map((node) => deferred.has(node.id) ? { ...node, position: writer.document.nodes.find((item) => item.id === node.id)?.position || node.position } : node) } : document;
|
|
await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
|
}
|
|
}
|
|
async function exclusive<T>(operation: () => Promise<T>) {
|
|
if (locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
|
|
locked.current = true; setBusy(true);
|
|
try { return await operation(); } catch (error) { setError(error instanceof Error ? error.message : "request_failed"); throw error; }
|
|
finally { locked.current = false; if (mounted.current) setBusy(false); }
|
|
}
|
|
function keepSelection(document: CanvasDocument, local?: CanvasDocument): CanvasDocument {
|
|
return { ...document, nodes: document.nodes.map((node) => ({ ...node, selected: local?.nodes.find((item) => item.id === node.id)?.selected })), edges: document.edges.map((edge) => ({ ...edge, selected: local?.edges.find((item) => item.id === edge.id)?.selected })) };
|
|
}
|
|
function accept(result: CanvasCommandResult) {
|
|
const next = new Map(current.current);
|
|
const values = [...new Map([...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])].map((value)=>[value.board_id,value])).values()];
|
|
for (const value of values) {
|
|
const decoded=decodeSnapshot(value),local=current.current.get(value.board_id),base=writers.current.get(value.board_id)?.document;
|
|
const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document;
|
|
install(value.board_id,decoded);next.set(value.board_id,keepSelection(merged,local));
|
|
}
|
|
for (const id of result.archived_board_ids || []) {
|
|
const local=current.current.get(id);
|
|
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.data.subgraph?.boardId===id));
|
|
if(local&&owner){
|
|
const frame=owner[1].nodes.find((node)=>node.data.subgraph?.boardId===id)!,target=next.get(owner[0])!;
|
|
const scale=frame.data.presentation?.contentScale||1,ids=new Set(local.nodes.filter((node)=>node.pendingUpload).map((node)=>node.id));
|
|
const moved=local.nodes.filter((node)=>ids.has(node.id)).map((node)=>({...node,position:{x:frame.position.x+24+node.position.x*scale,y:frame.position.y+36+node.position.y*scale},width:node.width!*scale,height:node.height!*scale}));
|
|
next.set(owner[0],{...target,nodes:[...target.nodes,...moved],edges:[...target.edges,...local.edges.filter((edge)=>ids.has(edge.source)||ids.has(edge.target)).map((edge)=>({...edge,data:transformEdgeData(edge.data,{boardId:id,id:edge.id,origin:{x:frame.position.x+24,y:frame.position.y+36},scale})}))]});
|
|
}
|
|
next.delete(id);writers.current.delete(id);
|
|
}
|
|
publish(next);
|
|
return [...new Set(values.map((value) => value.board_id))];
|
|
}
|
|
async function command(boardId: string, path: string, payload: Record<string, unknown>, participants?: string[], record = true, versionBoard = boardId) {
|
|
if (retryOperation.current) throw new Error("canvas_busy");
|
|
return exclusive(async () => {
|
|
for (const id of participants || []) if (!writers.current.has(id)) {
|
|
const value = await read(id); install(id, value); publish(new Map(current.current).set(id, value.document));
|
|
}
|
|
await flush();
|
|
const body = { ...payload, ...(participants ? { expected_versions: Object.fromEntries(participants.map((id) => [id, writers.current.get(id)?.version])) } : {}) };
|
|
const key = crypto.randomUUID(), version = writers.current.get(versionBoard)?.version || 1;
|
|
const execute = async (): Promise<CanvasCommandResult> => {
|
|
try {
|
|
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
|
|
const changed = accept(result);
|
|
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
|
|
if (!changed.length && result.canvas_version) {
|
|
const target=typeof result.board_id === "string"?result.board_id:boardId,remote=await read(target),local=current.current.get(target),base=writers.current.get(target)?.document;
|
|
const merged=base&&local?mergeCanvasDocuments(base,local,remote.document):remote.document;
|
|
install(target,remote);publish(new Map(current.current).set(target,keepSelection(merged,local)));
|
|
}
|
|
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
|
|
retryOperation.current = null; setPendingCommand(false); setError("");
|
|
return result;
|
|
} catch (error) {
|
|
const code = error instanceof Error ? error.message : "request_failed";
|
|
if (error instanceof TypeError || error instanceof ApiError && error.status >= 500 || ["upstream_timeout", "upstream_unavailable", "request_failed", "Failed to fetch", "internal_error", "invalid_canvas_snapshot"].includes(code)) { retryOperation.current = execute; setPendingCommand(true); }
|
|
else { retryOperation.current = null; setPendingCommand(false); }
|
|
throw error;
|
|
}
|
|
};
|
|
return execute();
|
|
});
|
|
}
|
|
// Ownership changes pause autosaves, not unrelated local editing. Server
|
|
// acknowledgements merge against the persisted base, preserving intervening edits.
|
|
async function runMembers() {
|
|
if (memberRunning.current || !members.current.length) return;
|
|
const job = members.current[0]; memberRunning.current = true; job.error = undefined; showMembers();
|
|
try {
|
|
if (!job.run) {
|
|
for (const id of job.participants) if (!writers.current.has(id)) await ensure(id);
|
|
if (job.refresh) {
|
|
const refreshed = await Promise.all(job.participants.map(async (id) => [id, await read(id)] as const));
|
|
const next = new Map(current.current);
|
|
for (const [id, remote] of refreshed) {
|
|
const base = writers.current.get(id)!.document, local = current.current.get(id)!;
|
|
next.set(id, mergeCanvasDocuments(base, local, remote.document));
|
|
}
|
|
for (const [id, remote] of refreshed) install(id, remote);
|
|
publish(next); job.refresh = false;
|
|
}
|
|
const atomic = await supportsMemberPositions();
|
|
await flush(atomic === true);
|
|
const bases = new Map(job.participants.map((id) => [id, writers.current.get(id)!.document]));
|
|
const source = current.current.get(job.source)!;
|
|
const positions = Object.fromEntries(job.ids.map((id) => {
|
|
const node = source.nodes.find((node) => node.id === id); if (!node) throw new Error("invalid_reference");
|
|
return [id, node.position];
|
|
}));
|
|
const body = JSON.stringify({ source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, ...(atomic ? { positions } : {}),
|
|
expected_versions: Object.fromEntries(job.participants.map((id) => [id, writers.current.get(id)!.version])) });
|
|
const key = crypto.randomUUID(), historyIndex = undo.current.length;
|
|
job.run = async () => {
|
|
const result = await api<CanvasCommandResult>(canvasPath(workspace, job.parent) + `/subgraphs/${job.frame}/members`, {
|
|
method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": key }, body });
|
|
const snapshots = [...(result.parent ? [result.parent] : []), ...(result.snapshots || [])];
|
|
const values = new Map(snapshots.map((value) => [value.board_id, { ...decodeSnapshot(value), board_id: value.board_id }]));
|
|
if (!job.participants.every((id) => values.has(id))) throw new Error("invalid_canvas_snapshot");
|
|
const normalize = (id: string, local: CanvasDocument) => id !== job.source ? local : { ...local, nodes: local.nodes.map((node) => job.ids.includes(node.id) ? { ...node, position: bases.get(id)!.nodes.find((item) => item.id === node.id)!.position } : node) };
|
|
const rebaseLocal=(id:string,doc:CanvasDocument)=>values.has(id)?mergeCanvasDocuments(bases.get(id)!,normalize(id,doc),values.get(id)!.document):doc;
|
|
const rebasedHistory = undo.current.map((entry,index)=>index<historyIndex||"id" in entry?entry:{...entry,before:rebaseLocal(entry.boardId,entry.before),...(entry.related?{related:new Map([...entry.related].map(([id,doc])=>[id,rebaseLocal(id,doc)]))}:{})});
|
|
const next = new Map(current.current);
|
|
for (const [id, value] of values) {
|
|
const base = bases.get(id)!; if (!base) throw new Error("invalid_canvas_snapshot");
|
|
let local = current.current.get(id)!;
|
|
// The server owns these nodes after the move. Ignore only their
|
|
// staged coordinates; all other local edits participate in the merge.
|
|
if (id === job.source) local = { ...local, nodes: local.nodes.map((node) => job.ids.includes(node.id) ? { ...node, position: base.nodes.find((item) => item.id === node.id)!.position } : node) };
|
|
const merged = keepSelection(mergeCanvasDocuments(base, local, value.document), local);
|
|
next.set(id, id !== job.target ? merged : { ...merged, nodes: merged.nodes.map((node) => job.ids.includes(node.id) ? { ...node, selected: current.current.get(job.source)?.nodes.find((item) => item.id === node.id)?.selected } : node) });
|
|
}
|
|
for (const [id, value] of values) install(id, value);
|
|
publish(next);
|
|
undo.current = rebasedHistory;
|
|
if (result.transaction_id) { undo.current.splice(historyIndex, 0, { boardId: job.parent, path: `subgraphs/${job.frame}/members`, payload: { source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, positions }, id: result.transaction_id, participants: [...values.keys()] }); redo.current = []; }
|
|
};
|
|
}
|
|
await job.run();
|
|
members.current.shift(); showMembers(); job.resolve();
|
|
} catch (error) {
|
|
job.error = error instanceof Error ? error.message : "request_failed";
|
|
// A definite rejection performed zero writes. A retry may prepare fresh
|
|
// versions; ambiguous transport failures must retain the exact body/key.
|
|
if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; }
|
|
showMembers();
|
|
} finally {
|
|
memberRunning.current = false;
|
|
if (members.current.length && !members.current[0].error) void runMembers();
|
|
}
|
|
}
|
|
function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) {
|
|
if (locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
|
|
return new Promise<void>((resolve) => {
|
|
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
|
|
});
|
|
}
|
|
function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) {
|
|
const resolve = (document: CanvasDocument): CanvasDocument => ({ ...document, nodes: document.nodes.map((node) => {
|
|
if (node.pendingUpload !== id) return node;
|
|
const fitted=node.data.kind==="plain-image"&&node.width===320&&node.height===240?size:undefined;
|
|
const {pendingUpload,...ready}=node;
|
|
return { ...ready, ...(!asset?{pendingUpload}:{}), ...(fitted?{...fitted,style:{...ready.style,...fitted}}:{}), data: { ...ready.data, ...(asset?{src:asset.url,...(document.schema_version===2?{assetId:asset.id}:{})}:{}) } };
|
|
}) });
|
|
const rebase = (entry: Entry): Entry => "id" in entry ? entry : { ...entry, before: resolve(entry.before), ...(entry.related ? {related:new Map([...entry.related].map(([id,doc])=>[id,resolve(doc)]))} : {}) };
|
|
undo.current = undo.current.map(rebase); redo.current = redo.current.map(rebase);
|
|
publish(new Map([...current.current].map(([board,doc])=>[board,resolve(doc)])));
|
|
}
|
|
function movePendingImage(source: string, target: string, id: string) {
|
|
const from = current.current.get(source)!, to = current.current.get(target)!;
|
|
const node = from.nodes.find((node)=>node.id===id); if (!node?.pendingUpload || to.nodes.length>=500 || to.nodes.some((item)=>item.id===id)) throw new Error("invalid_reference");
|
|
const projected=projectCanvasTree(root,current.current), a=projected.boards.get(source)!,b=projected.boards.get(target)!;
|
|
if (!a || !b || from.edges.some((edge)=>edge.source===id||edge.target===id)) throw new Error("图片上传完成后即可跨分区移动连线。");
|
|
const moved={...node,position:{x:(a.origin.x+node.position.x*a.scale-b.origin.x)/b.scale,y:(a.origin.y+node.position.y*a.scale-b.origin.y)/b.scale},width:node.width!*a.scale/b.scale,height:node.height!*a.scale/b.scale};
|
|
undo.current.push({boardId:source,before:from,related:new Map([[target,to]]),pendingMove:{id,source,target}});redo.current=[];
|
|
publish(new Map(current.current).set(source,{...from,nodes:from.nodes.filter((node)=>node.id!==id)}).set(target,{...to,nodes:[...to.nodes,moved]}));
|
|
}
|
|
async function refresh(boardId: string) {
|
|
return exclusive(async () => {
|
|
const writer = writers.current.get(boardId); if (!writer) return;
|
|
await writer.settled(); const base = writer.document;
|
|
const remote = await read(boardId), local = current.current.get(boardId)!;
|
|
const merged = mergeCanvasDocuments(base, local, remote.document);
|
|
if (canvasFingerprint(merged) !== canvasFingerprint(local)) { undo.current.push({ boardId, before: local }); redo.current = []; }
|
|
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
|
|
});
|
|
}
|
|
async function prepareApply(boardId: string, nodeId: string) {
|
|
await exclusive(async () => {
|
|
const writer = writers.current.get(boardId); if (!writer) throw new Error("invalid_reference");
|
|
await writer.settled(); const remote = await read(boardId), base = structuredClone(writer.document), local = current.current.get(boardId)!;
|
|
const before = base.nodes.find((node) => node.id === nodeId), after = remote.document.nodes.find((node) => node.id === nodeId);
|
|
if (!before || !after) throw new Error("invalid_reference");
|
|
for (const key of ["text", "src", "assetId", "ai"] as const) { if (key in after.data) (before.data as Record<string, unknown>)[key] = after.data[key]; else delete before.data[key]; }
|
|
const merged = mergeCanvasDocuments(base, local, remote.document);
|
|
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
|
|
await writer.enqueue(merged); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
|
setError("");
|
|
});
|
|
}
|
|
async function undoAction(isRedo = false) {
|
|
const stack = isRedo ? redo.current : undo.current, destination = isRedo ? undo.current : redo.current;
|
|
const original = stack.at(-1); if (!original || locked.current || retryOperation.current || members.current.length) return;
|
|
const alias = (id: string) => { const seen = new Set<string>(); while (boardAliases.current.has(id) && !seen.has(id)) { seen.add(id); id = boardAliases.current.get(id)!; } return id; };
|
|
const remapDocument = (document: CanvasDocument) => ({ ...document, nodes: document.nodes.map((node) => node.data.subgraph ? { ...node, data: { ...node.data, subgraph: { boardId: alias(node.data.subgraph.boardId) } } } : node) });
|
|
const entry: Entry = isRedo ? "id" in original ? { ...original, boardId: alias(original.boardId), participants: original.participants.map(alias), payload: { ...original.payload, ...(typeof original.payload.source_board_id === "string" ? { source_board_id: alias(original.payload.source_board_id) } : {}), ...(typeof original.payload.target_board_id === "string" ? { target_board_id: alias(original.payload.target_board_id) } : {}), ...Object.fromEntries(["source", "target"].flatMap((key) => { const endpoint = original.payload[key] as { board_id?: string } | undefined; return endpoint?.board_id ? [[key, { ...endpoint, board_id: alias(endpoint.board_id) }]] : []; })) } } : { ...original, boardId: alias(original.boardId), before: remapDocument(original.before), ...(original.related?{related:new Map([...original.related].map(([id,doc])=>[alias(id),remapDocument(doc)]))}:{}) } : original;
|
|
if (!("id" in entry) && entry.pendingMove) {
|
|
const move=entry.pendingMove,desired=alias(isRedo?move.target:move.source);
|
|
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.id===move.id));
|
|
if(!owner)throw new Error("invalid_reference");
|
|
const writer=writers.current.get(owner[0]);await writer?.settled();
|
|
if(writer?.document.nodes.some((node)=>node.id===move.id)){
|
|
if(owner[0]!==desired)for(const hop of subgraphMovePath(current.current,owner[0],desired))await command(hop.parent,`subgraphs/${hop.frame}/members`,{source_board_id:hop.source,target_board_id:hop.target,node_ids:[move.id]},subgraphMoveParticipants(current.current,hop.parent,hop.source,hop.target,[move.id]),false);
|
|
stack.pop();destination.push(entry);tick((v)=>v+1);return;
|
|
}
|
|
}
|
|
if ("id" in entry) {
|
|
if (isRedo) {
|
|
const result = await command(entry.boardId, entry.path, entry.payload, entry.path === "subgraphs" ? undefined : entry.participants, false);
|
|
const snapshots = [...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || [])];
|
|
if (entry.path === "subgraphs" && result.child) {
|
|
const priorChild = entry.participants.find((id) => id !== entry.boardId); if (priorChild) boardAliases.current.set(priorChild, result.child.board_id);
|
|
}
|
|
stack.pop(); destination.push({ ...entry, id: result.transaction_id as string, participants: [...new Set(snapshots.map((value) => value.board_id))] });
|
|
} else {
|
|
await command(entry.boardId, `transactions/${entry.id}/undo`, {}, entry.participants, false);
|
|
stack.pop(); destination.push(entry);
|
|
}
|
|
} else {
|
|
const before = current.current.get(entry.boardId); if (!before) return;
|
|
const next=new Map(current.current).set(entry.boardId, entry.before), related=new Map<string,CanvasDocument>();
|
|
for(const [id,doc] of entry.related || []){const currentDoc=current.current.get(id);if(currentDoc)related.set(id,currentDoc);next.set(id,doc);}
|
|
publish(next); stack.pop(); destination.push({ ...entry, before, ...(entry.related?{related}:{}) });
|
|
}
|
|
tick((v) => v + 1);
|
|
}
|
|
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
|
|
return { documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
|
|
}
|