From 33f5c076f6278fea66217c89873d3850cdecae75 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 02:13:31 +0800 Subject: [PATCH] docs(canvas): explain legacy tree state and transaction coordination --- src/components/canvas/use-canvas-tree.ts | 106 +++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/src/components/canvas/use-canvas-tree.ts b/src/components/canvas/use-canvas-tree.ts index 30db0a3..cc5bf06 100644 --- a/src/components/canvas/use-canvas-tree.ts +++ b/src/components/canvas/use-canvas-tree.ts @@ -1,3 +1,9 @@ +/** + * 旧版画布树的状态与操作协调Hook。documents按boardId保存已加载快照,views把嵌套子画布投影为当前ReactFlow场景。 + * 每个board有独立CanvasSync writer,本地历史和服务端结构事务共用两个栈,成员迁移另用串行内存队列。 + * 该实现保留旧快照协议与本地草稿恢复,不等同于新版engine的统一提交根、持久ledger和恢复状态机。 + * 调用方应按根/工作区身份重挂载;refs提供异步操作读取的最新状态,React state负责发布界面变化。 + */ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EdgeChange, NodeChange } from "@xyflow/react"; @@ -8,20 +14,34 @@ 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"; +/** 命令返回的快照额外携带board_id,用于把多画布结果安装回各自writer。 */ 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 }; +/** 本地历史保存操作前整份画布,可附关联画布;pendingMove标记尚未上传图片的跨画布移动,便于后来按服务端归属撤销。 */ type LocalHistory = { boardId: string; before: CanvasDocument; related?: Map; pendingMove?: { id: string; source: string; target: string } }; +/** 服务端结构事务历史保存可重放的路径/负载、事务ID和参与画布;撤销请求服务端undo,重做重新执行原命令。 */ type TransactionHistory = { boardId: string; path: string; payload: Record; id: string; participants: string[] }; +/** 两种历史条目共用undo/redo栈,通过是否有id字段区分;不是每次pointermove都直接创建条目。 */ type Entry = LocalHistory | TransactionHistory; +/** 成员归属迁移的FIFO任务;run在准备后捕获固定body/key供不确定失败重试,refresh表示确定冲突后需刷新基线。 */ type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise; resolve: () => void }; +/** 只为一次reducer运算建立临时空历史,借其past是否变化判断该动作是否应进入本Hook自己的历史栈。 */ const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] }); +/** initial建立根画布,snapshot决定是否连接服务端writer;paused阻止保存/独占事务等准入,migration额外限制旧写入。 + * parentBoardId用于补读祖先快照;导出、跨分区移动和事务可能按需加载更多画布。 */ export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string, migration?: {accountId:string;isCurrentIdentity():boolean}) { + // 有迁移上下文时先关旧编辑入口,等迁移协调组件取得共享租约再开放;flush只有迁移准备阶段临时启用。 const migrationGate=useRef(!!migration); const migrationFlush=useRef(false); const [migrationHeld,setMigrationState]=useState(!!migration); + /** 同步检查门闩和账号身份,事件处理无需等待React的migrationHeld重新渲染。 */ const migrationBlocked=()=>migrationGate.current||!!migration&&!migration.isCurrentIdentity(); + /** 同时更新同步门闩与界面状态;只更新state会留下同一事件轮次中的旧写入窗口。 */ function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);} + /** 旧接口请求护栏:await前后检查账号,写请求受迁移门闩限制;迁移排空时只放行canvas PATCH。 + * X-Expected-Account要求代理校验目标账号,避免切号后旧请求误用新会话。 */ 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'); @@ -29,33 +49,42 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo const value=await requestAPI(path,{...options,headers:Object.fromEntries(headers)}); if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');return value; }; + // documents是已加载board的模型表,current立即跟随发布供异步回调读取;status/error是旧writer汇总显示值。 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([]); + // capability缓存查询Promise;members是实际内存队列,memberJobs只是供React显示的数组副本。 const memberCapability = useRef | null>(null); const members = useRef([]), memberRunning = useRef(false), dragging = useRef(false); + /** 仅挂载期间发布队列副本;任务对象仍共享引用,修改error等字段后需显式通知。 */ const showMembers = () => { if (mounted.current) setMemberJobs([...members.current]); }; + /** 拖动期间延后自动保存,结束时发布新Map触发防抖重新评估;不在这里执行成员归属变化。 */ 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); + // 不确定命令失败时保留同一execute闭包,pendingCommand驱动UI;locked只管当前独占异步操作。 const retryOperation = useRef<(() => Promise) | null>(null); const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused; + // writer持每画布ACK基线与保存队列,loading去重子画布读取;二者都不是documents的替代存储。 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; + /** 以当前服务端快照筛选有内容差异的本地恢复候选,按board记录,等待用户决定;不证明可安全覆盖模型。 */ 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); } } + /** 可编辑时按选择替换本地文档或采用现状,移除恢复候选和草稿记录;远端写入仍走后续writer。 */ function resolveRecovery(boardId: string, restore: boolean) { if(migrationBlocked())return; const draft = pendingRecovery.current.get(boardId); @@ -63,12 +92,16 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next); localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`); } + // 两个ref数组作为历史栈,boardAliases记录重做子图创建后新旧boardID对应;tick用于仅栈变化时通知UI。 const undo = useRef([]), redo = useRef([]); const boardAliases = useRef(new Map()); const [, tick] = useState(0); + // 根据根与documents派生投影索引,nodeRefs/edgeRefs把场景ID映射回所属board、本地ID和坐标变换。 const views = useMemo(() => projectCanvasTree(root, documents), [root, documents]); const view = useRef(views); view.current = views; + /** 先更新同步ref,再在挂载期间发布React state;等待中的操作可立即读取新Map。 */ function publish(next: Map) { current.current = next; if (mounted.current) setDocuments(next); } + /** 将服务端快照作为writer基线:已有writer采用新ACK,否则在真实快照模式建立writer;这里只安装,不发保存。 */ function install(boardId: string, value: CanvasSnapshot) { const writer = writers.current.get(boardId); if (writer) writer.adopt(value); @@ -80,25 +113,33 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo // 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; }; }, []); + /** 普通单画布动作入口:检查门闩,经reducer计算新文档,保留成员迁移保护字段,再按需要记历史并发布。 */ 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); + // 迁移中的普通节点只允许选中变化;未作为移动目标的子图入口保留原几何、subgraph/ports/presentation。 + // reducer删掉的受保护节点也补回,避免异步成员请求到来时对象已被本地删除。 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))) }; + // reducer产生历史才入undo,普通更新路径最多留40项并清redo;其它事务/刷新路径不一定使用同一截断逻辑。 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)); }; + /** 默认对根发动作,也允许调用方显式指定已加载board。 */ const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action); + /** 从投影节点ID解析实际owner,再用原始节点ID构造动作,避免把场景复合ID写回快照。 */ const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); }; + /** 按owner分组ReactFlow节点变化;忽略add/replace,将场景位置和尺寸还原为所属board局部坐标。 */ const nodesChanged = (changes: NodeChange[]) => { if (migrationBlocked() || locked.current || retryOperation.current) return; const groups = new Map[]>(); + // 同批父分区也在移动时先计算调整后的原点,避免对子节点重复施加父位移;尺寸再除投影scale。 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; @@ -106,6 +147,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo 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 }; + // ResizeObserver只更新测量信息;非显式resizing不得把测量宽高当模型尺寸写回。 if (next.type === "dimensions") { // ResizeObserver measurements are editor state. Only an explicit resize // gesture may write node dimensions back to the owning snapshot. @@ -116,6 +158,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo } for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes }); }; + /** 按edgeRefs分组选择/删除等变化,忽略add/replace;最终update仍统一检查写入准入。 */ const edgesChanged = (changes: EdgeChange[]) => { const groups = new Map(); for (const change of changes) { @@ -124,13 +167,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo } for (const [boardId, changes] of groups) update(boardId, { type: "edges", changes }); }; + /** 曲线路径从场景坐标反变换回owner局部空间,再派发edge-data;transient交reducer决定历史语义。 */ 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 }); }; + /** 遍历已加载文档,只有目标owner/localID被选中,其余节点取消选择;不直接操作投影数组。 */ 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 })) }); }; + /** 读取并解码一个board的旧v2快照,可传取消信号;api层仍检查迁移账号身份。 */ const read = async (boardId: string, signal?: AbortSignal) => decodeSnapshot(await api(canvasPath(workspace, boardId), { headers: { Accept: CANVAS_ACCEPT }, signal })); + /** 查询服务端是否支持成员迁移同时提交坐标;演示为false,成功结果复用,失败清缓存供按需重试。 */ function supportsMemberPositions(): Promise { if (!snapshot) return Promise.resolve(false); if (!memberCapability.current) { @@ -141,9 +188,11 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo } return memberCapability.current; } + // 能力预取失败不视作确定不支持,真正成员操作仍可重新请求。 useEffect(() => { void supportsMemberPositions().catch(() => { /* A failed prefetch is retried on demand, never cached as unsupported. */ }); }, [root, workspace, !!snapshot]); + /** 按需加载真实快照或演示本地草稿,装writer并检查恢复候选;recursive递归加载子图,祖先集合防环且限制深度。 */ 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); @@ -153,14 +202,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo } 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); } } + /** 独占期间完整加载根下可达子图,然后收集导出Map;不会把已加载但不可达的祖先或其它分支混入导出。 */ async function exportDocuments() { return exclusive(async () => { await ensure(root, true); const reachable = new Map(); + /** 可达集合兼作去重;ensure已验证递归关系,此处只遍历当前已加载文档。 */ 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; }); } + // 打开子画布时向上读取元数据与快照,补齐祖先关系;alive阻止离页后的采用,不在本effect中abort请求。 useEffect(() => { if (!snapshot || !parentBoardId) return; let alive = true; @@ -178,6 +230,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo // Root identity is stable until this editor unmounts. // eslint-disable-next-line react-hooks/exhaustive-deps }, [parentBoardId, root, workspace]); + // 场景可见且未折叠的子图按需异步读取,loading去重;回包时重新确认仍被当前文档引用。 useEffect(() => { for (const [boardId, document] of documents) for (const node of document.nodes) { if (!view.current.boards.has(boardId)) continue; @@ -192,6 +245,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo // Values and writers are scoped to this mounted root/workspace. // eslint-disable-next-line react-hooks/exhaustive-deps }, [documents, retryLoad]); + // 文档变化防抖:250ms本地备份、800ms远端保存。恢复待决画布跳过,防止覆盖用户尚未选择的草稿。 + // 远端另等独占锁、命令重试、成员任务和拖动结束;本地备份不需要等这些普通操作全部结束。 useEffect(() => { if (paused || migrationBlocked()) return; const backup = setTimeout(() => { @@ -208,7 +263,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo }, 800); return () => { clearTimeout(backup); clearTimeout(save); }; }, [documents, paused, workspace, memberJobs, migrationHeld]); + // pagehide/卸载尽量做本地同步备份,不等待远端flush;暂停/迁移仍禁止写,存储满时保留已有记录。 useEffect(() => { + /** 遍历最新内存文档,备份规范化payload与ACK版本/dirty;页面离开时错误不再触发界面更新。 */ const backup = () => { if (pause.current || migrationBlocked()) return; for (const [boardId, document] of current.current) { @@ -219,6 +276,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo window.addEventListener("pagehide", backup); return () => { window.removeEventListener("pagehide", backup); backup(); }; }, [workspace]); + /** 串行排空各writer,任一恢复待决或writer错误就失败;deferMemberPositions保留待移动节点原ACK坐标。 + * 支持原子坐标的成员接口会在归属事务里提交新位置,避免先把新位置单独写入旧owner。 */ async function flush(deferMemberPositions = false) { for (const [boardId, document] of current.current) { if (pendingRecovery.current.has(boardId)) throw new Error("draft_recovery_required"); @@ -228,6 +287,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error); } } + /** 迁移前必须已关门,无事务/成员/拖动/加载,且恢复已处理;加载整棵迁移树并核查上传占位。 + * 先备份本页,再临时放行PATCH排空旧writer;所有writer确认无待保存内容后把备份标为clean。 */ 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'); @@ -240,12 +301,14 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo 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})); } + // 只在flush的try/finally窗口放开旧PATCH,其它写命令仍被api护栏拒绝。 migrationFlush.current=true; try{await flush();}finally{migrationFlush.current=false;} if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed'); @@ -255,15 +318,19 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:false})); } } + /** 旧版内存独占执行器,阻断冲突准入并发布busy;异常记error后继续抛出,finally可靠释放锁。 + * 它不是跨标签页锁,也不提供操作的整批服务端回滚。 */ 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 })) }; } + /** 汇总命令返回的各类快照,按boardID去重,基于旧ACK做三方合并再安装新基线。 */ 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()]; @@ -272,6 +339,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document; install(value.board_id,decoded);next.set(value.board_id,keepSelection(merged,local)); } + // 子图归档后,将仍在内存上传的节点及相关边投到父画布,避免服务端快照中没有的占位被丢弃。 + // 其余已归档文档及writer从当前表移除,不在这里删除上传任务的原文件。 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)); @@ -286,6 +355,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo publish(next); return [...new Set(values.map((value) => value.board_id))]; } + /** 结构/AI等旧命令入口:加载参与writer、flush,再冻结expected_versions、If-Match和幂等key。 + * execute闭包在不确定失败后复用同一body/key/version;这些恢复信息只在内存,不保证刷新后继续。 */ 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 () => { @@ -295,21 +366,25 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo 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; + /** 发送、采用回执、按需补读目标快照,并把有transaction_id的结果记入服务端事务历史。 */ 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))); } + // record=false用于undo/redo等已有历史流程,避免将历史遍历再次当新操作压栈。 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"; + // 网络/上游/解码等不确定失败保留execute并阻断普通编辑;确定拒绝清重试入口,错误仍交调用方处理。 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; @@ -320,12 +395,15 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo } // Ownership changes pause autosaves, not unrelated local editing. Server // acknowledgements merge against the persisted base, preserving intervening edits. + /** 成员队列只跑队首;归属移动期间暂停autosave,允许不相关本地编辑,结果按捕获ACK合并。 + * 只有成功才shift并resolve;失败任务留队首供重试,Promise不在失败时假装完成。 */ 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); + // 412等确定冲突后的重新准备:读参与快照,先三方合并本地变化,再更新各writer基线。 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); @@ -336,6 +414,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo for (const [id, remote] of refreshed) install(id, remote); publish(next); job.refresh = false; } + // 支持坐标时flush延后待移动节点位置;否则先按旧保存行为排空,再捕获参与文档作为合并基线。 const atomic = await supportsMemberPositions(); await flush(atomic === true); const bases = new Map(job.participants.map((id) => [id, writers.current.get(id)!.document])); @@ -344,17 +423,22 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo const node = source.nodes.find((node) => node.id === id); if (!node) throw new Error("invalid_reference"); return [id, node.position]; })); + // 请求内容含参与版本和可选局部坐标;创建job.run后重试不得换body或幂等key,除非确定原请求未应用。 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; + /** 一次准备生成的可重试闭包;要求回执覆盖所有参与画布,不能凭缺失快照移动本地owner。 */ 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) }; + /** 参与画布基于原ACK+归一化本地内容+返回快照重放,未参与文档原样保留。 */ const rebaseLocal=(id:string,doc:CanvasDocument)=>values.has(id)?mergeCanvasDocuments(bases.get(id)!,normalize(id,doc),values.get(id)!.document):doc; + // 请求期间新增本地历史也基于新owner结果调整;开始前历史和服务端事务条目保持原语义。 const rebasedHistory = undo.current.map((entry,index)=>index[id,rebaseLocal(id,doc)]))}:{})}); const next = new Map(current.current); for (const [id, value] of values) { @@ -369,6 +453,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo for (const [id, value] of values) install(id, value); publish(next); undo.current = rebasedHistory; + // 将迁移事务插回开始时的historyIndex,保留请求期间后来编辑的顺序,并清除旧redo。 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 = []; } }; } @@ -378,6 +463,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo 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. + // 确定拒绝可清冻结闭包重新准备;412还需刷新版本。不确定传输失败继续保留原job.run。 if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; } showMembers(); } finally { @@ -385,24 +471,31 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo if (members.current.length && !members.current[0].error) void runMembers(); } } + /** 检查准入和重叠成员任务后入FIFO队列;返回Promise由成功出队时resolve,错误在memberJobs中供UI重试。 */ 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(); }); } + /** 将上传资源/尺寸绑定到所有当前文档及本地历史,避免撤销恢复旧Blob占位。 + * 无asset时保留pendingUpload;尺寸仅在纯图片仍为320×240默认值时采用,不覆盖用户已调整大小。 */ function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) { if(migrationBlocked())return; + /** 只匹配pendingUpload身份,移除或保留占位标记,并按文档schema选择是否写assetId。 */ 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}:{})}:{}) } }; }) }); + /** 仅改本地快照历史(含related),服务端事务条目仍由服务端重放解释。 */ 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)]))); } + /** 未上传且无关联边的图片可先在内存跨分区移动;校验容量/ID后按两owner原点和scale换算。 + * 保存双画布before及pendingMove历史,上传完成后若进入服务端,撤销可能需要真实成员事务。 */ 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)!; @@ -413,6 +506,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo 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]})); } + /** 独占等待writer停止后读新快照并三方合并;内容指纹改变才记本地历史,再安装新ACK。 */ async function refresh(boardId: string) { return exclusive(async () => { const writer = writers.current.get(boardId); if (!writer) return; @@ -423,6 +517,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo install(boardId, remote); publish(new Map(current.current).set(boardId, merged)); }); } + /** 显式采用生成结果前,针对目标节点把基线text/src/assetId/ai对齐到远端,再合并其它本地编辑。 + * 合并结果先排空保存,后续apply命令才能在明确版本上覆盖目标;此方法本身不发送生成结果采用请求。 */ async function prepareApply(boardId: string, nodeId: string) { await exclusive(async () => { const writer = writers.current.get(boardId); if (!writer) throw new Error("invalid_reference"); @@ -436,12 +532,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo setError(""); }); } + /** 查看栈顶并检查准入,成功后才移动栈项;本地条目替换before,事务条目调用服务端undo或重做。 + * 重做可能创建新子board,使用alias将后续历史引用转到新身份,避免继续操作已归档旧子图。 */ 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; + /** 沿别名链解析最新boardID并防循环;只用于历史重放,不篡改任意元素ID。 */ 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; }; + /** 重做本地快照时更新子图board引用,其它节点内容保留。 */ 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; + // 待上传图片若已存在于writer基线,纯本地快照切换已不够,需按归属路径逐跳请求成员移动后再转栈。 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)); @@ -452,6 +553,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo stack.pop();destination.push(entry);tick((v)=>v+1);return; } } + // 服务端事务撤销调用transactions/{id}/undo;重做执行原命令并保存新的事务ID/参与快照身份。 if ("id" in entry) { if (isRedo) { const result = await command(entry.boardId, entry.path, entry.payload, entry.path === "subgraphs" ? undefined : entry.participants, false); @@ -464,6 +566,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo await command(entry.boardId, `transactions/${entry.id}/undo`, {}, entry.participants, false); stack.pop(); destination.push(entry); } + // 普通本地历史用当前文档作为反向条目,连同related画布一起发布,保证下一次redo可还原。 } 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(); @@ -472,6 +575,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo } tick((v) => v + 1); } + /** 重试普通writer并触发子图读取effect;不在这里自动重发成员任务或未确认command,它们有各自入口。 */ const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); }; + // 对外暴露投影、动作、恢复、事务及writer引用,旧版UI依赖此组合接口;busy合并独占、待确认命令与迁移门闩。 + // transactionBusy/canUndo/canRedo反映成员队列与栈是否有项,最终undoAction仍再次检查实际准入。 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 }; } -- 2.52.0