diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx index 14c9bf4..2450a3c 100644 --- a/src/components/canvas/graph-canvas.tsx +++ b/src/components/canvas/graph-canvas.tsx @@ -141,6 +141,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: // 对成员任务保护节点关闭拖动/删除/连接;tree.update内部仍保护相应数据,界面禁用不是唯一护栏。 const nodes = displayNodes.map((node) => { const ref = tree.views.nodeRefs.get(node.id); const blocked = ref && tree.protectedNodes(ref.boardId).has(ref.id); return blocked ? { ...node, draggable: false, deletable: false, connectable: false } : node; }); /** 上传任务的本地nodeID映射到当前场景ID;尚无投影时按根/原owner构造兼容显示ID。 */ + // 这里优先按 local node.id 搜索,未同时限定 owner;旧协议跨 board 重名时取首个匹配,不能当作全局唯一身份。 const uploadDisplayID = (job: typeof uploads.jobs[number]) => [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || displayID(board.id,job.owner,job.node.id); // 预览URL按显示ID提供;正式图片onLoad再映射回job,上传完成的任务才回收本地URL。 const imagePreviews = { sources: new Map(uploads.jobs.map((job) => [uploadDisplayID(job),job.localURL])), loaded: (id: string) => { const job = uploads.jobs.find((job) => uploadDisplayID(job)===id);if(job) uploads.loaded(job.node.id); } }; @@ -190,6 +191,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: /** 显示ID的选择交tree解析owner;稳定回调通过treeRef避免每次文档更新重建编辑上下文。 */ const select = useCallback((id: string) => treeRef.current.select(id), []); /** 对目标owner建立checkpoint,把后续连续编辑归到同一撤销起点;缺目标时使用根board。 */ + // checkpoint 只保存起点,没有对应 token 或原子结束动作;取消能力取决于旧节点组件的回退处理。 const beginEdit = useCallback((id?: string) => { const current = treeRef.current; const ref = id ? current.views.nodeRefs.get(id) || current.views.edgeRefs.get(id) : undefined; current.dispatch({ type: "checkpoint" }, ref?.boardId || board.id); }, [board.id]); /** 正文动作按投影ID定位原节点,reducer处理文本值与历史。 */ const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []); @@ -225,6 +227,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: const document = branchCanvasNode(tree.documents.get(tree.views.nodeRefs.get(request.id)!.boardId)!, tree.views.nodeRefs.get(request.id)!.id, request.direction, kind, src, request.at ? { x: (request.at.x - tree.views.nodeRefs.get(request.id)!.origin.x) / tree.views.nodeRefs.get(request.id)!.scale, y: (request.at.y - tree.views.nodeRefs.get(request.id)!.origin.y) / tree.views.nodeRefs.get(request.id)!.scale } : undefined); tree.dispatch({ type: "replace", document }, tree.views.nodeRefs.get(request.id)!.boardId); const child = document.nodes.at(-1)!; + // 使用 child 的 owner 局部坐标,未再投回根场景;Promise 的异步拒绝也不被外围同步 catch 捕获。 void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration }); setBranchRequest(null); } catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); } @@ -297,6 +300,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: * 异步移动失败保留错误提示,不宣称所有跨分区步骤一起回滚。 */ async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) { if (dragSession.current !== "active") return; + // 先恢复 idle 才 await 后续成员移动;第二次拖动的防护依赖 tree 的保护节点/队列,而非这个状态持续锁定。 const origins = new Map(dragOrigins.current); dragSession.current = "idle"; dragOrigins.current.clear(); setDraggedIds([]); tree.setDragging(false); if (!draggingFrame.current || snapshot && tree.rootDocument.schema_version === 2) { @@ -330,6 +334,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: } } // 不同owner组并行提出任务,每组沿父子路径逐跳等待;tree内部队列仍串行发送成员请求。 + // Promise.all 任一拒绝不取消其它组,也不回滚已完成的 hop;失败队首还可能保持未决 Promise 等待重试。 await Promise.all([...byOwner].map(async ([key, group]) => { let source = key.split("/")[0]; for (const hop of subgraphMovePath(tree.current.current, source, group.target)) { @@ -392,6 +397,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > (snapshot ? 20 * 1024 * 1024 : 2_000_000)) { pendingImage.current = null; setError(snapshot ? "请选择小于 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片。" : "演示模式请选择小于 2 MB 的图片。"); return; } + // 文件选择完成后才消费保存的分支意图;不带源节点代次,实际创建前只按当前显示 ID 查 owner。 const request = pendingImage.current; pendingImage.current = null; const kind = imageKind.current; // 真实图片上传先取得本地Blob预览并构造draft,随后把pendingUpload节点及可选分支边发布到owner。 @@ -417,6 +423,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: // 演示不上传服务端,FileReader读取全部字节为Data URL;纯图片另解码初始尺寸,finally释放读取中UI状态。 setImageUploading(true); try { + // 演示 FileReader/Image 没有卸载取消信号或 await 后身份检查;回调仍使用本次调用捕获的创建函数。 const src = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); @@ -447,6 +454,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: try { if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。"); if (tree.rootDocument.nodes.some((node) => node.data.subgraph)) throw new Error("当前画布包含真实子图,请先导出备份并通过子图事务恢复,避免覆盖子图关系。"); + // await 前检查的是本次 render 的根;读完不重新核对子图关系/画布身份,tree.dispatch 准入只能覆盖其自身护栏。 const document = decodeCanvas(await file.text()); if (document.nodes.some((node) => node.data.subgraph)) throw new Error("子图备份需要按原始画布关系恢复,不能直接导入到其他画布。"); dispatch({ type: "replace", document }); @@ -460,6 +468,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: async function exportProject() { let documents: Map; try { if([...tree.current.current.values()].some((doc)=>doc.nodes.some((node)=>node.pendingUpload))) throw new Error("图片仍在上传,完成后即可导出。"); documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; } + // 多 board bundle 仅导出文档 JSON;importProject 仍只接收普通单文档,并不是这个 bundle 的对称恢复器。 const content = JSON.stringify(documents.size > 1 ? { format: "endoleap-canvas-bundle", root: board.id, documents: Object.fromEntries(documents) } : { ...tree.rootDocument, viewport: flow.getViewport() }, null, 2); const url = URL.createObjectURL(new Blob([content], { type: "application/json" })); const link = document.createElement("a"); @@ -489,10 +498,12 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: return () => window.removeEventListener("keydown", keydown); }, []); + // 上传失败行的“取消”只尝试移除占位节点,未直接 uploads.remove;任务/文件/预览寿命仍由上传 Hook 和卸载处理。 // Provider把图片预览、节点动作、分区反馈及活动连线接到renderer;迁移时surface与底部创作栏设inert。 // ReactFlow删除由onBeforeDelete拦截真实子图/暴露端口节点,避免默认删除破坏结构;普通元素沿tree变化入口。 // 选择工具允许拖动/连接,抓手只平移;Space临时平移、Shift部分框选,滚轮默认平移、修饰键缩放。 // 工具栏创建/模板/导入走上述入口,顶部视口操作保存本地viewport;AI只给选中且支持的节点显示。 + // 迁移准入检查 UI 文件读取与上传;真正排空 writer/本地恢复由 prepareMigration 再检查。 // 恢复弹窗先根再子,逐项核查canRestore;迁移组件检查草稿、文件IO和未完成上传再排空writer。 return (
@@ -505,6 +516,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: onBeforeDelete={async ({ nodes: removing }) => { if (tree.busy || tree.transactionBusy) return false; // 选中分区入口要先dissolve保留内容;内部节点若被父port引用,先移回父board再删,清理边界引用。 + // 删除流程使用本次回调捕获的 tree 投影;await 后通过内存表补查,但没有全程冻结选择和历史的统一事务。 const linked = removing.filter((node) => node.data.subgraph); const parents = subgraphParents(tree.current.current); const referenced = removing.filter((node) => {