From 570cd53fc30bfbc088042bc8a55df409f0ef37b7 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 21:51:13 +0800 Subject: [PATCH] Fix canvas transfer timeouts and move images out of snapshots --- PERFORMANCE.md | 46 +++++++++++++++++++ README.md | 7 +++ VALIDATION.md | 17 +++++++ src/app/api/foundry/[...path]/route.ts | 37 ++++++++++++--- src/components/canvas/canvas.module.css | 4 ++ src/components/canvas/graph-canvas.tsx | 51 ++++++++++++++------- src/components/canvas/material-node.tsx | 6 ++- src/components/canvas/plain-image-node.tsx | 6 ++- src/components/canvas/use-canvas-tree.ts | 32 ++++++++++--- src/lib/api.ts | 28 ++++++++++-- src/lib/canvas-assets.ts | 43 ++++++++++++++++++ src/lib/canvas-response.ts | 52 ++++++++++++++++++++++ src/lib/canvas-sync.ts | 36 +++++++++++---- src/lib/canvas-v2.ts | 4 +- src/lib/canvas.ts | 6 ++- tests/api-smoke.mjs | 11 ++++- tests/canvas-assets.test.mjs | 37 +++++++++++++++ tests/canvas-response.test.mjs | 39 ++++++++++++++++ tests/canvas-sync.test.mjs | 14 +++++- tests/canvas-ui-check.mjs | 1 + tests/canvas-v2-scenario.mjs | 31 ++++++++++++- 21 files changed, 460 insertions(+), 48 deletions(-) create mode 100644 src/lib/canvas-assets.ts create mode 100644 src/lib/canvas-response.ts create mode 100644 tests/canvas-assets.test.mjs create mode 100644 tests/canvas-response.test.mjs diff --git a/PERFORMANCE.md b/PERFORMANCE.md index cb4084d..9a624ac 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -23,3 +23,49 @@ - 生产构建在临时副本进行,不改动当前开发服务器产物。最终执行结果记录于 VALIDATION.md。 本轮没有真实登录会话的公网前后对照,也没有浏览器视觉/手感验收,因此不能给出“线上打开从 11.4 秒降至某个时间”的结论。若更新后仍等待数秒,应结合 Network 中 Server-Timing 的 upstream 值、响应体下载时间和体积进一步追踪公网链路/后端;前端减少重复工作不等于消除了上游延迟。 + +## 2026-11-06:传输中断与误保存实测 + +旧 15 秒 deadline 同时约束响应头和整个响应体。流式响应先发出 200 后,正文仍可能被该 deadline 中断;此前客户端把 JSON 读取失败吞成 null,随后误报 invalid_canvas_snapshot。已改为响应头 15 秒、正文总计 90 秒、单次无数据等待 30 秒,并保留客户端取消。读取中断和 JSON 格式错误分别报告,不制造空快照。 + +`[canvas-transfer]` 只记录 trace_id、资源路径、毫秒、字节数、chunk 数、结果/错误类型;不记录令牌、Cookie、节点内容或图片数据。first_chunk_ms 为收到响应头后到首段正文的时间;transfer_ms 为代理消费正文的时间,包含链路和下游背压,不能独自区分网络节点。 + +用户真实父画布样本(约 477,400 字节,466 KiB): + +| 样本 | 响应头前 | 正文 | 合计 | 结果 | +| --- | ---: | ---: | ---: | --- | +| e342dd5b | 707ms | 25.925s | 26.632s | 读到 364,538 字节后 ResponseAborted | +| 000949da | 632ms | 32.119s | 32.751s | 完整 477,441 字节 | +| e33e4a53 | 713ms | 1.951s | 2.663s | 完整 477,385 字节 | +| 7b5e0787 | 956ms | 15.407s | 16.363s | 读到 282,618 字节后 ResponseAborted | +| 4f77aafc | 657ms | 22.591s | 23.248s | 完整 477,385 字节 | + +同一画布大小基本不变,正文平均吞吐从约 14.5 KiB/s 到约 239 KiB/s 波动。慢样本大部分时间在正文传输,而非页面路由(17–39ms)或首响应前。ResponseAborted 表示连接/消费端被取消,不能等同于我们设置的 TimeoutError,也不能仅凭它判定用户主动点击取消。开发热更新可取消旧请求,但具体请求的取消来源尚未证实。 + +子画布只返回 727 字节,正文约 1ms;首响应等待约 0.27–1.46s。未携带凭据的健康检查三个新连接样本:DNS 1.6–1.9ms、TCP 累计约 190–197ms、TLS 累计约 465–1,454ms、首字节累计约 723–1,842ms。健康检查只能作小响应链路基线,不能代替登录后的快照或 SQL 测量。 + +### 为什么只看页面也会 PATCH + +实际浏览器 `[canvas-dirty]` 抓到多次仅 viewport=true,nodes=[]、edges=false 的保存。原保存器把视角变化与内容变化一同比较,因此平移、缩放也上传整个约 466 KiB 快照。已改为内容指纹决定是否 PATCH;只改变视角时保存在独立本机键中,不上传,也不产生内容恢复冲突。节点/连线发生真实修改时仍按版本保存完整快照。初始化的无来源视口回调、微小浮点差异和非用户尺寸测量均不作为服务器内容修改。 + +新的 `[canvas-save]` 样本中,客户端请求体读取 1.5–7.5ms、JSON 编码 1.6–2.1ms、上传到上游并等待响应头 334–1,440ms、响应体读取解析 0.6–0.8ms;请求约 477,370 字节。中间阶段包含上传、后端排队/SQL/提交和返回链路,前端不能进一步给出各自占比。 + +### 尚需服务端对照 + +- Core canvas GET 调用 lockMaterial,源码先对 workspace 行 FOR UPDATE,且事务延续到 reply 返回。这是可能的同空间排队点;当前慢请求首响应前不足 1 秒,不能将其当作 22–32 秒正文慢的已证实主因。 +- 需 ECS 本机与公网读取同一快照的分段对照,并记录数据库取数、JSON 编码、写响应耗时及出口/网关限速。Gitea 与 API 解析到不同 IP,不能把 Gitea 大文件测试冒充同服务器对照。 +- 上传源码仍支持将图片内嵌为 data URL,可能放大快照;该父画布的实际图片占比尚需只统计字节、不输出内容的检查。迁到 managed assets 可让结构与图片独立读取,但不得为此在打开页面时暗中发起修改。 +- 此次修复没有宣称公网传输速度已经稳定,也未修改 Core 或用户线上图片存储。 + + +## 同步服务端对照:2026-11-06 13:23 UTC + +父画布 trace `1462ea3c`:13:23:04.973 → 13:23:21.652 UTC,首响应 219.8ms,正文 16,458.6ms,共 477,388 字节。服务端记录位于 `/tmp/endoleap-canvas-diag-20261106T131903Z`(只含计数、TCP 状态与聚合等待,不含 HTTP 正文/令牌)。 + +- 公网 TCP RTT 约 172ms,累计重传从 1 增至 31,拥塞窗口最低 2 个报文段,发送队列最高 138,240 字节;包丢失/重传导致 CUBIC 反复收缩窗口,解释了长正文传输和吞吐波动。 +- 内部 Core→Traefik 链路出现接收窗口背压,但最后完成约 481KB 的应用响应;不是数据库在逐字节生成 16 秒的数据。 +- 对齐窗口的 PostgreSQL 聚合记录没有持续 Lock 等待;只捕获一次约 154ms 的活动。只读 EXPLAIN ANALYZE 的本机快照体积计算执行 6.23ms、规划 11.4ms,不能将几十秒归因于该 SQL。 +- 同期 eth0 出口每秒约 14–62KB,无宿主队列丢弃增量;这不足以判定具体哪一段运营商或云端限速产生丢包,未擅自更改共享反代/内核网络配置。 +- 数据库聚合确认父图含 9 个节点、2 条边、2 张内嵌图片。JSON 文本约 477,646 字节,图片 src 合计 474,073 字符,约占 99.3%。原先设计让两张图片阻塞整张画布的数据读取。 + +修复采用普通 personal SaaS 附件模式:一份原始附件、一份按需显示预览,快照仅引用附件。前端添加真实附件上传及显式旧图迁移;Core PR #15 提供单一 1024px 预览、权限检查和有界缓存。原图不替换,AI 继续取原始内容。图片独立加载后,结构读取不再受两张完整图片的传输时间阻塞。生产部署和指定画布迁移后的实际字节数/时间须重测后记录,不能把预计缩减当作已完成实测。 diff --git a/README.md b/README.md index 305ba45..2c43a6a 100644 --- a/README.md +++ b/README.md @@ -121,3 +121,10 @@ Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVA ## 会话期限 会话为可撤销的随机令牌。30 天闲置续期、90 天绝对期限由 Core 控制,代理按剩余期限更新 HttpOnly Cookie;既有过期会话不恢复。详细本地说明为 `docs/SESSION_LIFETIME.md`。 + + +## 图片附件与加载 + +真实画布新上传的图片存入 Foundry 附件 API,快照只保存原始附件引用,避免 Base64 随每次打开和保存传输。原图保留;画布按需读取单一最长边 1024px 的预览,放大后可读取原图。预览权限与原图一致,Core 的配套预览功能通过 PR #15 交付,部署前服务端仍可能返回原图。 + +已有内嵌图片通过画布提示中的“优化图片加载”明确迁移一次:上传原始字节,然后按当前版本保存相同节点、尺寸、位置与连线。打开页面不会自动迁移或 PATCH。只改变视角保存在本机;真实内容修改才自动保存到服务器。 diff --git a/VALIDATION.md b/VALIDATION.md index 75527b8..b92158b 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -34,3 +34,20 @@ - 编辑器拆成动态模块,与元数据/快照并行加载;成功 Canvas GET 代理采用流式转发。 - 实际证据、性能边界及未执行的公网/浏览器验收见 PERFORMANCE.md;不将本地模拟测试当作公网提速实测。 - 本次最终检查通过:TypeScript、73 项单元测试、React Flow DOM 全量回归、隔离 Webpack 生产构建及 API 冒烟(含上游响应体暂停时首段透传、目录分页参数)。 + +## 传输中断与只读打开修正 + +- 大快照分离响应头、正文总时限和正文空闲时限;加入仅含时间/计数的传输、保存和 dirty 字段诊断。 +- JSON 读取失败不再吞成 null;区分传输中断、不可解析响应和真实快照校验失败。 +- 内容指纹忽略单独的 viewport 变化;视角保存在本机,初始化与缩放不会上传快照。浏览器日志已确认原误保存字段仅为 viewport。 +- 新增慢正文、空闲超时、请求取消、失败 200、204、视口浮点与真实内容保存的测试;DOM 覆盖父/子图打开与缩放零 PATCH。 +- 用户完整读取已成功(父图 477,385 字节),但链路样本仍有 2.66–32.75 秒波动;ECS/公网对照尚待补充,详见 PERFORMANCE.md。 + + +## 附件与预览修正 + +- 新图片使用受管附件;旧图仅在用户明确点击后迁移,字节保持原样,快照不保存预览地址。 +- 单元测试增加字节一致性、重复图片去重、幂等重试、失败不替换、V1 兼容和渲染 URL 分离;共 84 项通过。 +- DOM 回归确认只读打开/缩放零 PATCH、显式迁移一次上传、位置和边不变、画布使用预览而快照保存原图引用;原有 AI/子图/连线回归通过。 +- 同步 TCP/数据库证据及隐私边界见 PERFORMANCE.md。Core PR #15 的最新远程检查、部署及用户画布迁移后实测仍在进行。 +- 最终前端检查:TypeScript、84 项测试、完整 DOM 回归、隔离 Webpack 构建和 API 冒烟均通过(包括 15.2 秒慢正文、原图/预览参数及响应头)。 diff --git a/src/app/api/foundry/[...path]/route.ts b/src/app/api/foundry/[...path]/route.ts index 4812e96..414a495 100644 --- a/src/app/api/foundry/[...path]/route.ts +++ b/src/app/api/foundry/[...path]/route.ts @@ -1,3 +1,4 @@ +import { fetchCanvasResponse } from "@/lib/canvas-response"; import { sessionCookie, sessionMaxAge, SESSION_EXPIRY_HEADER } from "@/lib/session-cookie"; import { NextRequest, NextResponse } from "next/server"; @@ -36,6 +37,8 @@ async function handler( req: NextRequest, { params }: { params: Promise<{ path: string[] }> }, ) { + const requestStarted = performance.now(); + const requestStartedAt = new Date().toISOString(); const responseHeaders = new Headers({ "Cache-Control": "no-store" }); const json = (body: unknown, status: number) => NextResponse.json(body, { status, headers: responseHeaders }); @@ -61,6 +64,7 @@ async function handler( return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400); } } + const bodyReceived = performance.now(); const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com"; const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"; const headers: Record = { "Content-Type": asset ? (req.headers.get("content-type") || "application/octet-stream") : "application/json" }; @@ -72,20 +76,31 @@ async function handler( if (value !== null) headers[h] = value; } const query = new URLSearchParams(); - for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor"]) { + for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor", "variant", "width"]) { const values = req.nextUrl.searchParams.getAll(key); if (values.length === 1 && values[0].length < 300) query.set(key, values[0]); } + const encodingStarted = performance.now(); + const outboundBody = req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {}); + const encodeMs = performance.now() - encodingStarted; const started = performance.now(); try { - const upstream = await fetch( + const snapshotRead = canvas && path.endsWith("/canvas") && req.method === "GET"; + const streamedRead = snapshotRead || asset && req.method === "GET"; + const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH"; + const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : ""; + const requestUpstream = streamedRead ? (url: string, options: RequestInit) => fetchCanvasResponse(url, options, undefined, fetch, (trace) => { + console.info(snapshotRead ? "[canvas-transfer]" : "[asset-transfer]", JSON.stringify({ trace_id: traceId, resource: path, ...trace })); + }) : fetch; + if (streamedRead || snapshotWrite) responseHeaders.set("X-Canvas-Trace-Id", traceId); + const upstream = await requestUpstream( origin + "/projects/" + encodeURIComponent(project) + "/" + path + (query.size ? "?" + query.toString() : ""), { method: req.method, headers, - body: req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {}), - signal: AbortSignal.any([AbortSignal.timeout(15000), req.signal]), + body: outboundBody, + signal: streamedRead ? req.signal : AbortSignal.any([AbortSignal.timeout(15000), req.signal]), redirect: "manual", cache: "no-store", }, @@ -103,8 +118,11 @@ async function handler( } if (asset && upstream.ok && req.method === "GET") { const type = upstream.headers.get("content-type") || ""; - if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) return json({ error: "invalid_asset" }, 502); + if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) { await upstream.body?.cancel(); return json({ error: "invalid_asset" }, 502); } responseHeaders.set("Content-Type", type); responseHeaders.set("X-Content-Type-Options", "nosniff"); + const variant = upstream.headers.get("x-asset-variant"); + if (variant === "original" || variant === "preview") responseHeaders.set("X-Asset-Variant", variant); + responseHeaders.set("Server-Timing", `upstream;dur=${(performance.now() - started).toFixed(1)}`); return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders }); } const headersReceived = performance.now(); @@ -116,6 +134,15 @@ async function handler( return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders }); } const body = upstream.status === 204 ? null : await upstream.json(); + if (snapshotWrite) console.info("[canvas-save]", JSON.stringify({ + trace_id: traceId, resource: path, status: upstream.status, started_at: requestStartedAt, finished_at: new Date().toISOString(), + client_body_ms: Math.round((bodyReceived - requestStarted) * 10) / 10, + encode_ms: Math.round(encodeMs * 10) / 10, + request_bytes: typeof outboundBody === "string" ? new TextEncoder().encode(outboundBody).length : 0, + upload_and_server_ms: Math.round((headersReceived - started) * 10) / 10, + response_body_ms: Math.round((performance.now() - headersReceived) * 10) / 10, + total_ms: Math.round((performance.now() - requestStarted) * 10) / 10, + })); responseHeaders.set( "Server-Timing", `upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`, diff --git a/src/components/canvas/canvas.module.css b/src/components/canvas/canvas.module.css index 7d8d943..afdf0a1 100644 --- a/src/components/canvas/canvas.module.css +++ b/src/components/canvas/canvas.module.css @@ -187,3 +187,7 @@ .framePort:global(.connectionindicator) { pointer-events: auto; } .rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; } .canvasBusy button { margin: 8px 5px; padding: 6px 9px; border: 1px solid #ded7ce; border-radius: 5px; background: #fffefb; cursor: pointer; } + +.imageMigration { position: absolute; bottom: 88px; left: 50%; transform: translateX(-50%); z-index: 120; display: flex; align-items: center; gap: 12px; max-width: min(600px,90%); padding: 10px 14px; background: #fffdf9; border: 1px solid #ded5cb; border-radius: 10px; color: #796e63; font-size: 12px; box-shadow: 0 4px 18px #372f2410; } +.imageMigration button { flex-shrink: 0; border: 0; border-radius: 6px; padding: 6px 9px; background: #eee9e2; color: #51483d; cursor: pointer; } +.imageMigration button:disabled { opacity: .5; cursor: wait; } diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx index 840d075..171e5ac 100644 --- a/src/components/canvas/graph-canvas.tsx +++ b/src/components/canvas/graph-canvas.tsx @@ -1,4 +1,5 @@ "use client"; +import { inlineImageCount, uploadCanvasImage } from "@/lib/canvas-assets"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { @@ -46,13 +47,19 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap const [initial] = useState(() => { try { const local = readLocalRecovery(localStorage, storageKey, snapshot); - return { document: snapshot?.document || local || emptyCanvas(), recovery: snapshot ? local : null, error: "" }; + let document = snapshot?.document || local || emptyCanvas(); + try { + const view = JSON.parse(localStorage.getItem(storageKey + ":viewport") || "null"); + if (view && [view.x, view.y, view.zoom].every(Number.isFinite) && Math.abs(view.x) <= 1e6 && Math.abs(view.y) <= 1e6 && view.zoom >= .1 && view.zoom <= 4) document = { ...document, viewport: view }; + } catch { /* A broken local view never blocks the server document. */ } + return { document, recovery: snapshot ? local : null, error: "" }; } catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; } }); const [tool, setTool] = useState<"select" | "hand">("select"); const [error, setError] = useState(initial.error); const [branchRequest, setBranchRequest] = useState(null); + const [imageUploading, setImageUploading] = useState(false); const [iconPicker, setIconPicker] = useState(false); const [resizing, setResizing] = useState(null); const [rotating, setRotating] = useState(null); @@ -98,6 +105,13 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap return () => clearTimeout(timer); }, [membershipNotice]); const duration = reduceMotion ? 0 : 180; + const rememberViewport = (viewport: import("@xyflow/react").Viewport) => { + treeRef.current.dispatch({ type: "viewport", viewport }); + try { localStorage.setItem(storageKey + ":viewport", JSON.stringify(viewport)); } catch { /* View changes do not require server writes. */ } + }; + const changeView = (operation: () => Promise) => { + void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message)); + }; const latest = useRef(state.present); latest.current = state.present; @@ -272,13 +286,15 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap const file = event.target.files?.[0]; event.target.value = ""; if (!file) { pendingImage.current = null; return; } - if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > 2_000_000) { - pendingImage.current = null; setError("请选择小于 2 MB 的 PNG、JPEG、WebP 或 GIF 图片。"); return; + 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; } const request = pendingImage.current; pendingImage.current = null; const kind = imageKind.current; + setImageUploading(true); try { - const src = await new Promise((resolve, reject) => { + const uploaded = snapshot ? await uploadCanvasImage(api, board.workspace_id, file, file.name) : null; + const src = uploaded?.url || await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error("无法读取图片。")); @@ -288,13 +304,16 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap if (kind === "plain-image" && !request) { size = await new Promise((resolve, reject) => { const image = new window.Image(); - image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); }; - image.onerror = () => reject(new Error("无法读取图片尺寸。")); - image.src = src; + const localSource = uploaded ? URL.createObjectURL(file) : src; + const clean = () => { if (uploaded) URL.revokeObjectURL(localSource); }; + image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); clean(); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); }; + image.onerror = () => { clean(); reject(new Error("无法读取图片尺寸。")); }; + image.src = localSource; }); } if (request) createBranch(request, "image", src); else addNode(kind, src, size, kind === "plain-image" ? file.name : undefined); - } catch { pendingImage.current = null; setError("无法读取图片,请重新选择。"); } + } catch (error) { pendingImage.current = null; setError(errorText(error)); } + finally { setImageUploading(false); } } async function importProject(event: ChangeEvent) { const file = event.target.files?.[0]; @@ -396,7 +415,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap onConnectEnd={connectionEnd} onPaneClick={() => { closeBranch(); setRotating(null); setResizing(null); }} onNodeClick={(_, node) => { if (node.id !== rotating) setRotating(null); if (node.id !== resizing) setResizing(null); }} onMoveStart={closeBranch} - onMoveEnd={(_, viewport) => dispatch({ type: "viewport", viewport })} + onMoveEnd={(event, viewport) => { if (event) rememberViewport(viewport); }} defaultViewport={initial.document.viewport} minZoom={0.1} maxZoom={4} panOnDrag panOnScroll zoomOnScroll={false} zoomOnPinch zoomActivationKeyCode={["Meta", "Control"]} nodesDraggable={tool === "select" && !tree.busy} nodesConnectable={tool === "select" && !tree.busy} @@ -420,10 +439,10 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap dispatch({ type: "undo" })} /> dispatch({ type: "redo" })} />