|
|
|
@@ -12,6 +12,7 @@ export type CanvasCommandResult = { transaction_id?: string; parent?: OwnedSnaps
|
|
|
|
|
type LocalHistory = { boardId: string; before: CanvasDocument };
|
|
|
|
|
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) {
|
|
|
|
@@ -20,6 +21,16 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|
|
|
|
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;
|
|
|
|
@@ -59,6 +70,13 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|
|
|
|
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));
|
|
|
|
|
};
|
|
|
|
@@ -99,6 +117,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|
|
|
|
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 }));
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
memberCapability.current = snapshot ? api<{ subgraphs?: { member_positions?: boolean } }>(canvasPath(workspace, root) + "/capabilities", { headers: { Accept: CANVAS_ACCEPT }, signal: AbortSignal.timeout(2000) }).then((value) => value.subgraphs?.member_positions === true).catch(() => false) : Promise.resolve(false);
|
|
|
|
|
}, [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);
|
|
|
|
@@ -157,11 +178,11 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|
|
|
|
} catch { setError("local_backup_failed"); }
|
|
|
|
|
}, 250);
|
|
|
|
|
const save = setTimeout(() => {
|
|
|
|
|
if (locked.current || retryOperation.current || pause.current) return;
|
|
|
|
|
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]);
|
|
|
|
|
}, [documents, paused, workspace, memberJobs]);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const backup = () => {
|
|
|
|
|
if (pause.current) return;
|
|
|
|
@@ -173,15 +194,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|
|
|
|
window.addEventListener("pagehide", backup);
|
|
|
|
|
return () => { window.removeEventListener("pagehide", backup); backup(); };
|
|
|
|
|
}, [workspace]);
|
|
|
|
|
async function flush() {
|
|
|
|
|
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;
|
|
|
|
|
await writer.enqueue(document); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
|
|
|
|
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) throw new Error("canvas_busy");
|
|
|
|
|
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); }
|
|
|
|
@@ -225,6 +248,78 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|
|
|
|
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 memberCapability.current;
|
|
|
|
|
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 rebasedHistory = undo.current.map((entry, index) => index < historyIndex || "id" in entry || !values.has(entry.boardId) ? entry : { ...entry, before: mergeCanvasDocuments(bases.get(entry.boardId)!, normalize(entry.boardId, entry.before), values.get(entry.boardId)!.document) });
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
async function refresh(boardId: string) {
|
|
|
|
|
return exclusive(async () => {
|
|
|
|
|
const writer = writers.current.get(boardId); if (!writer) return;
|
|
|
|
@@ -250,7 +345,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|
|
|
|
}
|
|
|
|
|
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) return;
|
|
|
|
|
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;
|
|
|
|
@@ -273,5 +368,5 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|
|
|
|
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, 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: !!undo.current.length, canRedo: !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
|
|
|
|
|
return { documents, current, views, 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 };
|
|
|
|
|
}
|
|
|
|
|