"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EdgeChange, NodeChange } from "@xyflow/react"; import { api as requestAPI, 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, type CanvasRequest } 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; archived_board_ids?: string[]; undo_receipt?: { expires_at: string }; [key: string]: unknown }; type LocalHistory = { boardId: string; before: CanvasDocument; related?: Map; pendingMove?: { id: string; source: string; target: string } }; type TransactionHistory = { boardId: string; path: string; payload: Record; 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; 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, migration?: {accountId:string;isCurrentIdentity():boolean}) { const migrationGate=useRef(!!migration); const migrationFlush=useRef(false); const [migrationHeld,setMigrationState]=useState(!!migration); const migrationBlocked=()=>migrationGate.current||!!migration&&!migration.isCurrentIdentity(); function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);} const api:CanvasRequest=async (path:string,options?:RequestInit)=>{ if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed'); if(migrationBlocked()&&options?.method&&options.method!=='GET'&&!(migrationFlush.current&&options.method==='PATCH'&&path.endsWith('/canvas')))throw new Error('canvas_busy'); const headers=new Headers(options?.headers);if(migration)headers.set('X-Expected-Account',migration.accountId); const value=await requestAPI(path,{...options,headers:Object.fromEntries(headers)}); if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');return value; }; 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([]); const memberCapability = useRef | null>(null); const members = useRef([]), 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) | null>(null); const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused; const writers = useRef(new Map()); const loading = useRef(new Set()); const [retryLoad, setRetryLoad] = useState(0); const [recoveries, setRecoveries] = useState(new Map()); 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) { if(migrationBlocked())return; 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([]), redo = useRef([]); const boardAliases = useRef(new Map()); const [, tick] = useState(0); const views = useMemo(() => projectCanvasTree(root, documents), [root, documents]); const view = useRef(views); view.current = views; function publish(next: Map) { 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 (migrationBlocked() || 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[]) => { if (migrationBlocked() || locked.current || retryOperation.current) return; const groups = new Map[]>(); 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(); 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(canvasPath(workspace, boardId), { headers: { Accept: CANVAS_ACCEPT }, signal })); function supportsMemberPositions(): Promise { if (!snapshot) return Promise.resolve(false); if (!memberCapability.current) { const capability: Promise = 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()): Promise { 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(); 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([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 || migrationBlocked()) return; const backup = setTimeout(() => { if(migrationBlocked())return; 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 (migrationBlocked() || 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, migrationHeld]); useEffect(() => { const backup = () => { if (pause.current || migrationBlocked()) 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(); 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 prepareMigration(projectRoot:string){ if(!migrationGate.current)throw new Error('migration_gate_required'); if(locked.current||retryOperation.current||members.current.length||dragging.current||loading.current.size)throw new Error('canvas_busy'); if(pause.current||pendingRecovery.current.size)throw new Error('draft_recovery_required'); await Promise.all([...writers.current.values()].map(writer=>writer.settled())); await ensure(projectRoot,true); if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed'); if(pendingRecovery.current.size)throw new Error('draft_recovery_required'); if([...current.current.values()].some(document=>document.nodes.some(node=>node.pendingUpload)))throw new Error('migration_pending_uploads'); for(const[boardId,document]of current.current){ const writer=writers.current.get(boardId); const local=readLocalRecovery(localStorage,`endoleap_canvas_v1:${workspace}:${boardId}`,writer?{version:writer.version,document:writer.document}:undefined); if(local&&canvasFingerprint(local,false)!==canvasFingerprint(document,false)){ const next=new Map(pendingRecovery.current).set(boardId,local);pendingRecovery.current=next;setRecoveries(next);throw new Error('draft_recovery_required'); } // Preserve the current view even if it closes while PATCH is in flight. localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:writer?writer.needsSave(document):false})); } migrationFlush.current=true; try{await flush();}finally{migrationFlush.current=false;} if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed'); for(const[boardId,document]of current.current){ const writer=writers.current.get(boardId); if(writer?.error||writer?.needsSave(document))throw new Error(writer.error||'canvas_busy'); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:false})); } } async function exclusive(operation: () => Promise) { if (migrationBlocked() || 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, 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 => { try { const result = await api(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 (migrationBlocked() || 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(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[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 (migrationBlocked() || 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((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}) { if(migrationBlocked())return; 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) { if(migrationBlocked())throw new Error("canvas_busy"); 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)[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 || migrationBlocked() || locked.current || retryOperation.current || members.current.length) return; const alias = (id: string) => { const seen = new Set(); 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(); 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 { migrationHeld,setMigrationHeld,prepareMigration,isMigrationHeld:migrationBlocked, 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 || migrationHeld, 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 }; }