diff --git a/headless/consumer.mjs b/headless/consumer.mjs index cb973c3..7faa658 100644 --- a/headless/consumer.mjs +++ b/headless/consumer.mjs @@ -26,6 +26,7 @@ const files=new Map(),requests=new Map(); /** 通过对象身份及active校验本地leader句柄;仅演示单进程fence,不是数据库条件写。 */ const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);}; // StoragePort示例:claim取得独占句柄,recover读克隆镜像,commit先准备镜像/文件再一次同步发布。 +// 文件值直接保存传入 bytes 引用;示例要求宿主提供不可变字节载荷,不演示流式文件读写或增量日志。 const storage={ /** 只允许一个活跃leader并递增fence;release幂等失效该句柄,不清除已提交镜像。 */ async claim(requested){assert.equal(leader,undefined);assert.deepEqual(requested,scope);let active=true;const handle={scope:requested,fence:++fence,get active(){return active;},async release(){active=false;if(leader===handle)leader=undefined;}};leader=handle;return handle;}, @@ -59,6 +60,7 @@ const transport=async input=>{ if(known){assert.equal(known.body,input.body);return{status:200,body:known.receipt};} // 示例只含一个canvas版本,先要求版本一致,再应用变更并验证整个文档;每次接受使版本递增一次。 assert.deepEqual(body.expected_versions,{[scope.projectId]:version}); + // server 赋值在 validate 之前,断言失败不会恢复旧 server;此替身只用于下方合法协议路径,不是生产事务实现。 server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));validateDocument(server,registry);version++; // 回执逐一覆盖operations逻辑ID,并携complete=false的模型切片;记录原body方便重试校验。 const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{...model(),complete:false}}; @@ -96,4 +98,5 @@ try{ const hit=hitProjectedCanvas(projection,queries,registry,'portable-view',{space:'scene',point:{x:40,y:10},zoom:1});assert.equal(hit.complete,true);assert.ok(hit.hits.some(hit=>hit.target.id===target.id)); // 输出可机器读取的结果摘要;finally即使断言失败也关闭engine并验证存储领导权已释放。 console.log(JSON.stringify({status:'ok',portableBindings:true,elements:2,count:2,undoDepth,reopened:true,transientViews:0,protocol:2,requests:requests.size,sourceFiles:files.size})); +// 顺序等待 close 后再检查 Storage;若 close 本身拒绝,storage.close 不会执行,错误向脚本调用者传播。 }finally{await engine.close();storage.close();} diff --git a/next.config.ts b/next.config.ts index 42a2bb6..1e8417f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,6 +8,7 @@ const config: NextConfig = { reactStrictMode: true, poweredByHeader: false, /** 在现有规则后追加匹配ReactFlow ESM入口的loader,再返回原配置对象,保留框架其它规则。 */ webpack(config) { + // 规则只按路径匹配,不按客户端/服务端分支;loader 自身再检查安装版本和四段源码锚点。 config.module.rules.push({ test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/, use: [{ loader: require.resolve("./scripts/loaders/xyflow-retained.cjs") }], @@ -25,6 +26,7 @@ const config: NextConfig = { }, /** 只给版本目录内带12位十六进制摘要的JSON分片配置一年immutable缓存,文件内容变化应生成新文件名。 * 不对图标目录manifest、页面或全部JSON一概设置immutable。 */ + // 这里只匹配 URL 外观,不重新读取文件验证摘要;重生成文件名和旧资源保留由资源脚本/发布流程负责。 async headers() { return [{ source: "/canvas-icons/:version/:asset([a-z0-9-]+-[a-f0-9]{12}\\.json)", diff --git a/scripts/build-headless.mjs b/scripts/build-headless.mjs index 0b1e2d8..fe9b677 100644 --- a/scripts/build-headless.mjs +++ b/scripts/build-headless.mjs @@ -9,6 +9,7 @@ import { join, resolve } from 'node:path'; // 路径基于脚本URL而非当前终端目录;tsc仍显式以仓库根作为cwd解析项目依赖。 const root=resolve(fileURLToPath(new URL('..',import.meta.url))),output=join(root,'.headless-dist'); +// 原产物先删除,后续任一步失败不会恢复上一版;调用方须通过脚本成功退出和包验收后才采用目录。 await rm(output,{recursive:true,force:true}); // 同步等待TypeScript生成ESM及声明;继承终端输出,编译失败直接中止后续处理。 execFileSync(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'-p',join(root,'tsconfig.headless.json')],{cwd:root,stdio:'inherit'}); @@ -28,6 +29,7 @@ async function declarations(directory){ } } // 原生编译器处理运行时import后缀,声明文件仍需此补充;完成后再修URL字面量里的Worker源后缀。 +// 后处理逐文件直接写入,未使用临时目录整体 rename;中途错误可留下部分已改写的输出。 await declarations(join(output,'modules')); // 两个浏览器适配器各应含一个预期Worker URL;要求目标.js文件存在并且源字符串只出现一次才改写。 for(const [module,worker]of [['browser-digests','digest.worker'],['browser-images','image.worker']]){ diff --git a/scripts/canvas-icon-labels.mjs b/scripts/canvas-icon-labels.mjs index 377351c..98d92ff 100644 --- a/scripts/canvas-icon-labels.mjs +++ b/scripts/canvas-icon-labels.mjs @@ -2,6 +2,7 @@ * 图标资源生成时使用的中文标注表。作为静态元数据输入给generate-canvas-icons,不在运行时逐个请求翻译。 */ /** 完整PascalCase图标名到中文标签的人工覆盖表;未列出的图标由生成脚本回退slug拆词名称。 */ +// 两张表都是共享的普通对象,const 不冻结字段;生成器只读取,修改映射后需显式重新生成目录。 export const chinese = { "Star": "星星", "Heart": "爱心", diff --git a/scripts/generate-canvas-icons.mjs b/scripts/generate-canvas-icons.mjs index 81dec1b..e88afd3 100644 --- a/scripts/generate-canvas-icons.mjs +++ b/scripts/generate-canvas-icons.mjs @@ -34,6 +34,7 @@ const categories = [ /** Lucide常用集合用于默认分类;Tabler试用集稍后全部标记common。 */ const common = new Set(["Star", "Heart", "Lightbulb", "Sparkles", "Zap", "Target", "Check", "Smile", "Coffee", "Leaf", "Sun", "Moon", "Globe", "MapPin", "ArrowRight", "BookOpen", "FileText", "Folder", "Calendar", "Users", "Image", "Camera", "Music", "Palette"]); // Map以唯一name聚合元数据和SVG节点,Lucide加载器的__iconData提供路径,尚未在此写最终分片。 +// Map 以 PascalCase 名称去重,同名后项覆盖;不单独报告名称转换碰撞,输入包格式须按固定版本审查。 const entries = new Map(); for (const [slug, load] of Object.entries(dynamicIconImports)) { const name = pascal(slug); @@ -67,8 +68,10 @@ const catalog = records.map(({ node, ...entry }) => { }); // 输出目录带两套版本,manifest把分片逻辑key映射到真实文件名;不在运行时猜hash路径。 const assetDir = "public/canvas-icons/lucide-1.69.0-tabler-3.50.0"; +// 输出在仓库当前工作目录逐文件写入,不是整体原子替换;失败时可能保留新分片和旧 manifest 的组合。 await mkdir(assetDir, { recursive: true }); let totalBytes = 0; +// 文件名摘要用于资源版本命名,不把截断的12位摘要当作完整校验/授权凭据;消费者按 manifest 定位。 const assets = {}; // 每个分片用紧凑JSON计算SHA-256前12位作文件名,写文件额外加换行;totalBytes统计未加换行的内容字节。 for (const [key, nodes] of shards) { diff --git a/scripts/loaders/xyflow-retained.cjs b/scripts/loaders/xyflow-retained.cjs index a0cc24d..250e941 100644 --- a/scripts/loaders/xyflow-retained.cjs +++ b/scripts/loaders/xyflow-retained.cjs @@ -5,6 +5,7 @@ // App-only extension for the pinned @xyflow/react 12.12.1 ESM renderer. This // transforms compiler input; shared node_modules and the headless SDK are untouched. // 读取安装包版本,固定12.12.1;库升级必须人工审查目标实现,不能凭相同函数名继续替换。 +// require 在 loader 模块求值时读取一次版本,不在每个输入转换时重新扫描安装目录。 const version = require('@xyflow/react/package.json').version; /** webpack/Turbopack loader接收源码并返回改写后的源码;任何版本或锚点不符立即失败,防止悄悄失去保留能力。 */ module.exports = function retainVisibility(source) { @@ -20,6 +21,7 @@ module.exports = function retainVisibility(source) { } // 节点追加未hidden且显式保留的ID并Set去重;边以保留标记或原可见判断二选一,维持原生索引。 // 最后一处改为空数组不订阅完整选择集合,减少无选择监听时的不必要全图selector工作。 + // 返回新字符串,不写磁盘;锚点检查只确认当前四段文本的形状,不证明升级后所有语义仍等价。 return source .replace(nodes, `Array.from(new Set([ ...${nodes}, diff --git a/src/app/api/foundry/[...path]/route.ts b/src/app/api/foundry/[...path]/route.ts index 11e018c..cb3ebec 100644 --- a/src/app/api/foundry/[...path]/route.ts +++ b/src/app/api/foundry/[...path]/route.ts @@ -41,6 +41,7 @@ async function readBody(req: NextRequest, limit = bodyLimit, binary = false) { } // 空body普通模式用空对象,binary模式保留空字节;无reader则早已返回空对象。 if (!size) return binary ? new Uint8Array() : {}; + // chunks 与合并 Buffer 在此可能同时占内存;limit 约束载荷字节,不是整个请求处理的堆内存上限。 const bytes = Buffer.concat(chunks); return binary ? bytes : JSON.parse(bytes.toString("utf8")); } @@ -67,6 +68,7 @@ async function handler( return json({ error: "invalid_origin" }, 403); } } + // Origin 比对只使用 host(含端口),不要求 scheme 相同;也不在代理本地解析会话账户权限。 const path = (await params).path.join("/"); if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404); // operations/metadata返回JSON,不能被当成原图流,也不使用图片multipart请求体上限。 @@ -75,6 +77,7 @@ async function handler( const asset = assetRoute.test(path) && !assetReceipt && !assetMetadata; const canvas = canvasRoute.test(path); const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path); + // frozenAI 仅匹配 ai/runs 分支,ai/operations 查询不由此标记;新文档 model 路径整体由 canonical 覆盖。 const frozenAI = canvas && /\/canvas\/ai\/runs(?:\/|$)/.test(path); let requestBody: unknown; // 在网络转发前完成请求体限额:原始图片含multipart开销,新模型/AI写入保留原请求字节。 @@ -108,6 +111,7 @@ async function handler( const query = new URLSearchParams(); 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); + // 不合规则的已知参数和名单外参数都静默不转发,不在这里返回 invalid_request。 if (values.length === 1 && values[0].length < 300) query.set(key, values[0]); } const encodingStarted = performance.now(); @@ -119,6 +123,7 @@ async function handler( try { // 模型/旧快照与图片读取使用分层超时流;其它请求组合15秒总超时和客户端取消。 const snapshotRead = canvas && (path.endsWith("/canvas") || canonical) && req.method === "GET"; + // canonical GET 包括操作回执查询;只有这些 GET 使用分层流超时,写入仍用下方15秒总时限。 const streamedRead = snapshotRead || asset && req.method === "GET"; const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH"; const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : ""; @@ -151,12 +156,14 @@ async function handler( const renewal = upstream.headers.get(SESSION_EXPIRY_HEADER); if (session && upstream.status !== 401 && renewal !== null && /^\d+$/.test(renewal)) { const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https"; + // 纯数字也可能超出安全整数,sessionMaxAge 此时回退默认30天;续期不代表这里验证了账户资料。 responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewal), secure)); } // 成功素材GET只透传允许的图片MIME并加nosniff;不是任意内容的同源文件托管。 if (asset && upstream.ok && req.method === "GET") { const type = upstream.headers.get("content-type") || ""; if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) { await upstream.body?.cancel(); return json({ error: "invalid_asset" }, 502); } + // MIME 白名单只看响应头,不解码图片或核对实际字节类型;内容权威校验属于上游资源协议。 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); @@ -173,6 +180,7 @@ async function handler( return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders }); } // 普通响应整体解JSON,因为登录/交换成功时须把access_token换成HttpOnly Cookie并从body移除。 + // 普通响应没有预解析字节上限;请求体 limit 不能用来证明上游 JSON 响应也受同样约束。 const body = upstream.status === 204 ? null : await upstream.json(); // 旧快照写入trace分出接收/重编码/上游/响应耗时,不输出请求正文;字节数仅对字符串body计算。 if (snapshotWrite) console.info("[canvas-save]", JSON.stringify({ @@ -190,6 +198,7 @@ async function handler( ); const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https"; // 登录/交换令牌只进Set-Cookie,不回到浏览器可读JSON;其它会话响应按上游剩余时间更新。 + // 按字段存在处理,不仅限定 auth 路径/成功状态;原始流透传分支不进入此令牌移除逻辑。 if (body?.access_token) { responseHeaders.set("Set-Cookie", sessionCookie(body.access_token, sessionMaxAge(body.expires_in), secure)); delete body.access_token; @@ -197,6 +206,7 @@ async function handler( const renewed = upstream.headers.get(SESSION_EXPIRY_HEADER); if (renewed !== null && /^\d+$/.test(renewed)) responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewed), secure)); } + // 401 不在这里主动清 Cookie;错误响应保持状态,客户端再决定登录/重试流程。 // 仅上游确认登出成功后清Cookie,不把一次网络失败当成成功退出。 if (path === "auth/logout" && upstream.ok) responseHeaders.set( diff --git a/src/app/api/oauth/[provider]/route.ts b/src/app/api/oauth/[provider]/route.ts index 3cc881c..1218fd1 100644 --- a/src/app/api/oauth/[provider]/route.ts +++ b/src/app/api/oauth/[provider]/route.ts @@ -12,6 +12,7 @@ export async function GET( ) { const { provider } = await params; const challenges = req.nextUrl.searchParams.getAll("challenge"); + // 这里只验证 challenge 字符表示,不验证 verifier/登录 state 或交换身份;额外查询参数也不会透传上游。 const challenge = challenges[0]; if ( !["google", "slack"].includes(provider) || diff --git a/src/app/dashboard/[workspace]/[board]/page.tsx b/src/app/dashboard/[workspace]/[board]/page.tsx index 0411059..39fb66f 100644 --- a/src/app/dashboard/[workspace]/[board]/page.tsx +++ b/src/app/dashboard/[workspace]/[board]/page.tsx @@ -5,7 +5,8 @@ import { Suspense } from "react"; import GraphPage from "@/components/workspace/graph-page"; -/** params按当前Next版本await;Suspense给客户端画布准备过程提供状态占位,不在此发模型请求。 */ +/** params按当前Next版本await;Suspense为子树渲染时的挂起提供占位,不在此发模型请求。 + * params 的 await 在此边界外;GraphPage 在 effect 中加载模型时的进度/错误由自身状态呈现。 */ export default async function Page({ params }: { params: Promise<{ workspace: string; board: string }> }) { const { workspace, board } = await params; return ( diff --git a/src/app/dashboard/dashboard.tsx b/src/app/dashboard/dashboard.tsx index e946ec0..74f0fef 100644 --- a/src/app/dashboard/dashboard.tsx +++ b/src/app/dashboard/dashboard.tsx @@ -55,6 +55,7 @@ export default function DashboardSidebar() { // 路径workspace优先于查询和本地选择;slug由目录解析,活动board可补充该路由引用的真实ID。 const workspaceRef = params.workspace || searchParams.get("workspace") || selectedWorkspace; const currentWorkspace = workspaces.find((item) => matchesRef(item, workspaceRef)); + // 目录未解析时可暂用路由 ref;这里的字符串回退不是“空间存在/有权限”的证明,后续服务请求仍需验证。 const workspace = currentWorkspace?.id || (activeBoard?.workspace_ref === workspaceRef ? activeBoard.workspace_id : workspaceRef); /** demo用本地资料,真实模式并行加载profile/空间;active+signal阻止卸载后的资料结果写入。 */ @@ -68,6 +69,7 @@ export default function DashboardSidebar() { } let active = true; const controller = new AbortController(); + // 只切换加载状态,不清空之前 profile/workspaces/error;成功读取也不会在这里自动清除旧 error。 setStatus("loading"); loadWorkspaceHome(api, controller.signal) .then(({ profile, workspaces }) => { @@ -89,6 +91,7 @@ export default function DashboardSidebar() { /** 每次目录加载先留本空间项,再取完整分页;仅最新listVersion可采用列表,换空间时中断旧请求。 */ useEffect(() => { if (!workspace || status !== "ready") return; + // listVersion 只保护目录结果采用,不是网络取消或账号代次;创建成功时也递增它,防较旧列表覆盖新项。 const version = ++listVersion.current; let active = true; setBoards((list) => list.filter((board) => board.workspace_id === workspace)); @@ -120,6 +123,7 @@ export default function DashboardSidebar() { setCreating(true); setError(""); try { + // 捕获触发本次创建的 workspace/demo;await 后不另核对挂载/账户,宿主生命周期应避免旧操作采用到新界面。 const board = demo ? { id: crypto.randomUUID(), workspace_id: workspace, title: DEFAULT_BOARD_TITLE, kind: "board" as const, version: 1 } : await createBoard(api, DEFAULT_BOARD_TITLE, workspace, (created) => { @@ -128,6 +132,7 @@ export default function DashboardSidebar() { }); listVersion.current += 1; setBoards((list) => [board, ...list]); + // demo 已先更新 React 列表,随后 Storage 写失败也不回滚该列表;错误仅报告在侧栏。 if (demo) localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify([board, ...readDemoBoards(workspace)])); setQuery(""); router.push(boardHref(currentWorkspace?.slug || board.workspace_id, board.slug || board.id, demo)); @@ -158,6 +163,7 @@ export default function DashboardSidebar() { } /** 真实退出先retireIdentity并完成本地关闭/保存,再请求服务登出;失败留在当前界面报告,不跳过保存。 */ + // 此函数无独立 busy/ref 互斥;多次点击仍可调用。退休身份成功但上游登出失败时也不会复活旧 runtime。 async function logout() { try { if (!demo) { await runtimeHost.retireIdentity(); await api("auth/logout", { method: "POST" }); } @@ -177,6 +183,7 @@ export default function DashboardSidebar() { /** 成功后同时更新本地目录与匹配的活动board元数据;错误转换成文案交EditableTitle保留草稿显示。 */ async function renameTitle(title: string) { + // 优先使用 activeBoard.id;编辑按钮先约束路由/空间匹配,此函数本身不重新执行同一组匹配检查。 const id = activeBoard?.id || params.board; if (!id || !workspace) return; try { @@ -184,6 +191,7 @@ export default function DashboardSidebar() { const list = readDemoBoards(workspace); localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify(list.map((board) => board.id === id ? { ...board, title } : board))); } else await renameBoard(api, workspace, id, title); + // 目录更新按 ID 匹配,活动元数据更新额外比 workspace;此处不递增 listVersion,旧目录读取仍可能随后到达。 setBoards((list) => list.map((board) => board.id === id ? { ...board, title } : board)); setActiveBoard((board) => board?.id === id && board.workspace_id === workspace ? { ...board, title } : board); } catch (error) { throw new Error(errorText(error)); } @@ -196,6 +204,7 @@ export default function DashboardSidebar() { const transition = { duration: reduceMotion ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] as const }; const roleLabel = currentWorkspace?.role === "admin" ? "管理员" : currentWorkspace?.role === "editor" ? "编辑者" : currentWorkspace?.role === "viewer" ? "查看者" : "工作空间"; + // 折叠只改变显示和键盘可达性,侧栏仍挂载,账户/目录状态和 effect 不因收起而销毁。 // 展示分为浮动条、侧栏目录、共享账户popover和账户面板;路由/空间作为key让标题及目录局部状态重置。 return ( <> diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 099b018..9311a54 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -8,7 +8,8 @@ import { ActiveBoardProvider } from "@/components/workspace/active-board"; import DashboardSidebar from "./dashboard"; import styles from "@/components/workspace/workspace.module.css"; -/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。 */ +/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。 + * 该边界响应侧栏渲染期间的 suspend,不自动等待侧栏在 effect 中发起的普通请求。 */ export default function DashboardLayout({ children }: { children: ReactNode }) { return ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 020a238..508607f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -22,7 +22,8 @@ export const metadata: Metadata = { }, }; -/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。 */ +/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。 + * children 作为插槽传入客户端宿主,不会因此把所有子页面模块变成客户端代码;宿主的释放逻辑在自身模块。 */ export default function RootLayout({ children }: { children: ReactNode }) { return ( diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 28393f8..038ef3d 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -35,6 +35,7 @@ export default function Login() { verify = hash.get("verify_email"), failure = hash.get("error"); if (!code && !verify && !failure) return; + // 不只删除认证片段,也删除原 query;当前页面没有保留/恢复任意 returnTo 的流程。 history.replaceState(null, "", location.pathname); if (failure) { setError("第三方登录未完成,请重新尝试。"); @@ -68,6 +69,7 @@ export default function Login() { * HTML输入限制给即时反馈,服务端仍负责真实凭证/注册校验。 */ async function submit(e: FormEvent) { e.preventDefault(); + // busy 是显示状态,不是函数入口互斥锁;按钮禁用限制常规点击,submit 本身不检查已在执行。 setBusy(true); setError(""); setMessage(""); @@ -76,6 +78,7 @@ export default function Login() { method: "POST", body: JSON.stringify({ email, password }), }); + // 使用触发本次提交时 render 闭包里的 register;等待期间切换模式不会改变这次请求的后续分支。 if (register) setMessage("验证邮件已请求发送,请检查邮箱并点击验证链接。"); else router.push("/dashboard"); @@ -98,6 +101,7 @@ export default function Login() { setError("无法启动登录,请重新尝试。"); } } + // submit/oauth 同样没有卸载取消或结果代次校验;回调链结束前可继续调用 router/setState。 // 模式切换只清提示,已有邮箱/密码和可见性独立保留。 // 渲染分为品牌故事、模式切换、受控凭证表单、OAuth按钮与demo入口;错误用alert,非错误提示用status。 return ( diff --git a/src/app/page.tsx b/src/app/page.tsx index 15b8cfe..69d06c5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -21,12 +21,14 @@ export default async function Home() { signal: AbortSignal.timeout(15000), }); // 按响应状态确定是否已认证,再丢弃不需要的body;此处不解析profile字段。 + // 先保存 HTTP 成功判定;随后 cancel 若抛错,catch 不会把 authenticated 重置为 false。 authenticated = response.ok; await response.body?.cancel(); } catch { // An unverified session must not hide the public landing page. } } + // redirect 放在上方 try/catch 外,避免把框架用于导航的控制流异常当作 profile 请求失败吞掉。 // 仅在确认成功后跳工作台,未认证时沿用同一静态展示组件。 if (authenticated) redirect("/dashboard"); return ; diff --git a/src/components/canvas/ai-panel.tsx b/src/components/canvas/ai-panel.tsx index 0243071..450bbb2 100644 --- a/src/components/canvas/ai-panel.tsx +++ b/src/components/canvas/ai-panel.tsx @@ -14,6 +14,7 @@ import type { CanvasModel, useCanvasAI } from "./use-canvas-ai"; import styles from "./canvas.module.css"; /** 以boardId+node.id定位run步骤,列表最后一项作为当前任务;NodeToolbar将面板附着在显示节点下方。 */ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string; node: CanvasNode; workspace: string; boardId: string; ai: ReturnType; save: (patch: NonNullable) => void }) { + // 配置只用初次挂载的 node 初始化,不随外部 data.ai 自动重置;切换目标时应由宿主重建/隔离面板。 const [models, setModels] = useState([]); const [model, setModel] = useState(node.data.ai?.modelId || ""); const [prompt, setPrompt] = useState(node.data.ai?.prompt || ""); @@ -24,6 +25,7 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string const image = ["image", "plain-image"].includes(node.data.kind); const relevant = [...ai.runs.values()].filter((run) => run.steps?.some((step) => step.board_id === boardId && step.node_id === node.id)); const run = relevant.at(-1), step = run?.steps?.find((step) => step.board_id === boardId && step.node_id === node.id); + // starting 是 React 状态,按钮禁用在重渲染后生效,不是函数入口的同步互斥标记。 // starting防止本地重复启动,之后以步骤queued/running判断生成忙碌;这不是整份画布的同步锁。 const busy = starting || step?.status === "queued" || step?.status === "running"; // 演示/非live路径不请求模型;真实路径按输出模态筛选,并保留仍兼容选择或回退到首个可用模型。 @@ -39,6 +41,7 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string return () => controller.abort(); }, [workspace, boardId, image, ai.live]); /** 构造当前输入配置,参数固定为空对象;状态切换事件需显式替换刚改变的值,避免读取旧React state。 */ + // 每次保存都显式 parameters:{},不会保留节点先前的自定义参数。 const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} }); /** 输入blur时,有模型且live才调用save;仅持久化配置,不启动服务端任务。 */ const persist = () => { if (model && ai.live) save(config()); }; diff --git a/src/components/canvas/branch-menu.tsx b/src/components/canvas/branch-menu.tsx index ca7aa4c..219cf83 100644 --- a/src/components/canvas/branch-menu.tsx +++ b/src/components/canvas/branch-menu.tsx @@ -21,11 +21,14 @@ export function BranchMenu({ request, close, choose }: { request: BranchRequest if (!request) return; const focus = document.activeElement as HTMLElement | null; ref.current?.querySelector("button[data-choice]")?.focus(); + // outside 不阻止事件,关闭后原指针仍可交给被点击的画布/控件;这是非模态浮层,不提供焦点圈定。 const outside = (event: PointerEvent) => { if (!ref.current?.contains(event.target as Node)) close(); }; const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); focus?.focus(); } }; window.addEventListener("pointerdown", outside); window.addEventListener("keydown", escape, true); return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape, true); }; }, [request, close]); + // choose 不在本组件内调用 close;异步创建、异常报告和关闭时机由宿主回调负责。 + // close 引用或 request 变化会重装监听并重新捕获焦点,不只在空→非空时执行。 // 无document时不创建Portal。菜单位置按预设宽高限制在视口边距内;上下键循环所有按钮(含关闭)。 // 减少动态效果偏好禁用位移/缩放动画;退出动画仍通过AnimatePresence管理。 if (typeof document === "undefined") return null; diff --git a/src/components/canvas/canvas-edge.tsx b/src/components/canvas/canvas-edge.tsx index e574176..4dc649c 100644 --- a/src/components/canvas/canvas-edge.tsx +++ b/src/components/canvas/canvas-edge.tsx @@ -59,6 +59,7 @@ export function CanvasEdge(props: EdgeProps) { const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5); const color = appearance.color || "#aa968b", width = appearance.width || 1.5; // 清洗边ID供SVG marker引用;动画尊重减少动态效果,executing模式只在active集合含本边时播放。 + // marker 命名未加入 viewId/实例 ID,且字符替换可能碰撞;调用方须维持当前 SVG 文档内标识可用。 const markerId = `edge-${props.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`; const animated = !reduce && (animation.mode === "always" || animation.mode === "executing" && editing.active.has(props.id)); const knots = data.path?.knots || []; @@ -76,6 +77,7 @@ export function CanvasEdge(props: EdgeProps) { const capture = event.currentTarget.closest('[data-headless-canvas]') ?? event.currentTarget.closest('.react-flow') ?? event.currentTarget; // begin返回false拒绝;返回对象则保存租约,其余返回值走旧接口,控制点选择仍是组件局部状态。 const lease = editing.begin(props.id); if (lease === false) return; + // begin 已执行后才安装监听/取得捕获;后续动作实现应不抛错,否则这里没有总括 try/finally 回滚。 const controller = new AbortController(); gesture.current = { id, part, capture, controller, pointerId: event.pointerId, lease: lease && typeof lease === 'object' ? lease : undefined }; setSelectedKnot(id); /** 原生监听先按pointerId过滤,再交当前处理器;取消和lostpointercapture仅在手势仍存在时处理。 */ @@ -92,6 +94,7 @@ export function CanvasEdge(props: EdgeProps) { if (!ownsPointerCapture(current,event)) return; if(!current)return; const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); + // 读的是最新 render 的 path,不是 pointerdown 快照;需要父层把 preview 更新投影回来以便后续事件继续编辑。 const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] }); // 端部控制柄保存相对端点的向量;中间点位置为绝对画布点,中间控制柄保存相对knot.position的向量。 if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point); @@ -119,6 +122,7 @@ export function CanvasEdge(props: EdgeProps) { function stop(event: PointerEvent) { if(!gesture.current)return;event.stopPropagation(); if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; current.controller.abort(); + // 提交/取消动作先于 release,且未用 finally 包裹;动作抛错可能跳过捕获释放,不能称无条件清理。 if (current.lease) current.lease.commit(); else editing.finish?.(); releasePointerCapture(current.capture,current.pointerId); } @@ -132,6 +136,7 @@ export function CanvasEdge(props: EdgeProps) { if (current.lease) current.lease.cancel(); else editing.cancel?.(); releasePointerCapture(current.capture,current.pointerId); } + // 工具栏/键盘 patch 不是拖动 lease 流程,是否形成一次 history 单元仍由 Provider 决定。 // 每次渲染刷新原生事件转发目标;控制点自身也挂React事件,所有处理器均检查手势/捕获归属。 pointerHandlers.current = { move, stop, cancel }; /** 控制点屏幕半径与描边按zoom反向补偿。点击选择点,指针可拖;键盘仅移动曲线点位置或端部柄。 @@ -147,6 +152,7 @@ export function CanvasEdge(props: EdgeProps) { editing.patch(props.id, { ...data, path: next }); } }} />; + // selectedKnot 不随外部 knots 删除自动重置;工具栏显示模式可退到最后一点,删除回调仍优先使用旧选中 ID。 // 双击路径按点击位置插点;选中描边和动画不接收指针,BaseEdge提供较宽的交互区域。 // flow用SVG animateMotion沿path运行;dash/pulse使用CSS动画,方向与时长来自连线数据。 // 选中时绘制工具栏、所有knot位置和首尾柄,仅被选中knot显示自己的in/out柄。 @@ -178,6 +184,7 @@ export function CanvasEdge(props: EdgeProps) { /** 连接拖动中的临时预览线。两端命中Handle时使用兼容连接点,尚未命中时以指针位置作终点并反转起点法向。 * 只绘制单段connectorBezier,不创建或保存实际连线;调用方样式可覆盖默认线色/线宽。 */ export function CanvasConnectionLine(props: ConnectionLineComponentProps) { + // 本临时线仍使用兼容端点算法,与上方已建连线的 __headlessRecord 原生坐标分流不同。 const from = connector(props.fromNode, props.fromHandle.id, "right"); const to = props.toNode && props.toHandle ? connector(props.toNode, props.toHandle.id, "left") : { point: { x: props.toX, y: props.toY }, normal: { x: -from.normal.x, y: -from.normal.y } }; return ; diff --git a/src/components/canvas/canvas-icon.tsx b/src/components/canvas/canvas-icon.tsx index ea58c69..c334c0b 100644 --- a/src/components/canvas/canvas-icon.tsx +++ b/src/components/canvas/canvas-icon.tsx @@ -19,6 +19,8 @@ export function CanvasIcon({ name = "Star", size = 24, width = size, height = si useEffect(() => { const controller = new AbortController(); setResult({ name, paths: peekIcon(name) }); + // 清理通过订阅 signal 取消,但 then 没有额外 active/代次检查;已兑现 Promise 的回调仍可能运行。 + // result.name 只能隔离不同名称,同名重试之间不靠此字段区分代次。 loadIcon(name, controller.signal).then((paths) => setResult({ name, paths }), (error) => { if (error.name !== "AbortError") setResult({ name, error: true }); }); @@ -27,8 +29,10 @@ export function CanvasIcon({ name = "Star", size = 24, width = size, height = si // effect可能还没处理新name,故读取结果前再次比对名称;失败标记也必须属于当前图标。 const paths = result.name === name ? result.paths : undefined; const failed = result.name === name && result.error; + // retry 不清浏览器 HTTP 缓存;同名已有成功分片时,loadIcon 仍可能直接复用共享缓存。 // 以24×24为标准坐标;absoluteStrokeWidth在size为数值时按尺寸反向补偿描边宽度。 // 成功时保留调用方onClick;失败时点击改为停止冒泡并重试,避免同时触发外层节点点击。 + // props 在默认 SVG 属性之后展开,可覆盖 viewBox 等;data-icon-* 和最终 onClick 在其后固定。 // 加载中用虚线方框,失败用叉号与title,data-icon-state供样式或检查观察显示状态。 return ; loaded export function CanvasImage({ id, src, title, className }: { id: string; src?: string; title: string; className: string }) { // 本地源按元素id而非远端URL索引,同一资源在不同节点中可拥有各自的显示交接状态。 const previews = useContext(ImagePreviews), local = previews.sources.get(id); + // onLoad 只携带元素 id,不附带 src/资源代次;迟到加载能否清掉当前预览需由 Provider 的 loaded 实现判定。 const remote = {title} previews.loaded(id)} />; + // 没有 onError 状态或自动重试;底图失败时若上层仍保留 local,覆盖图可继续显示。 // 没有预览或预览已等于正式源时,只绘制一张图片,避免冗余叠层。 if (!local || local === src) return remote; // 正式图片继续在底层加载,本地预览绝对定位覆盖在上方;Provider清掉该id后自然移除覆盖。 diff --git a/src/components/canvas/canvas-overlays.tsx b/src/components/canvas/canvas-overlays.tsx index 4bca157..7f1c63d 100644 --- a/src/components/canvas/canvas-overlays.tsx +++ b/src/components/canvas/canvas-overlays.tsx @@ -16,6 +16,7 @@ export function useCanvasOverlay(targetId:string,placement:string){ const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{}); // 订阅视口变化触发重渲染,后续layout effect再读取浮层屏幕矩形。 useViewport(); + // 此处 getSnapshot 只读取当前 render;不单独订阅元素代次,外层须在元素变化时让节点重新渲染。 const incarnation=scope?.engine.getSnapshot().document.elements.get(targetId)?.incarnation; // 每次换DOM或作用域先清理旧注册;缺少DOM/Provider/元素生命周期时保持空注册。 const ref=useCallback((element:HTMLElement|null)=>{cleanup.current();refresh.current=()=>{};cleanup.current=()=>{};if(!element||!scope||incarnation===undefined)return; @@ -26,10 +27,12 @@ export function useCanvasOverlay(targetId:string,placement:string){ // 注册返回lease,后续测量只更新这一条;composition事件同步更新组合态供快捷键路由参考。 const lease=scope.index.register(instance,value()),measure=()=>lease.update(value()),start=()=>{composing=true;measure();},end=()=>{composing=false;measure();};refresh.current=measure; // focusout后等微任务再测量,让新焦点落定;ResizeObserver与首帧测量补充尺寸/挂载后的变化。 + // 已排队的 blur 微任务无法取消,清理后仍会测量;索引租约的 token 检查使旧 update 不再发布。 const blur=()=>queueMicrotask(measure),observer=new ResizeObserver(measure);observer.observe(element);element.addEventListener('focusin',measure);element.addEventListener('focusout',blur);element.addEventListener('compositionstart',start);element.addEventListener('compositionend',end);let frame=requestAnimationFrame(measure); // 清除本绑定拥有的DOM标记、首帧任务、观察器和所有监听,再释放索引租约;不删除目标元素。 cleanup.current=()=>{if(element.dataset.canvasOverlayOwner===marker){delete element.dataset.canvasOverlayView;delete element.dataset.canvasOverlayOwner;delete element.dataset.canvasOverlayTarget;delete element.dataset.canvasOverlayPlacement;}cancelAnimationFrame(frame);observer.disconnect();element.removeEventListener('focusin',measure);element.removeEventListener('focusout',blur);element.removeEventListener('compositionstart',start);element.removeEventListener('compositionend',end);lease.release();}; },[scope,instance,targetId,incarnation,placement]); + // ResizeObserver 只观察尺寸,普通页面滚动/位置动画不一定触发它;边界还依赖视口重渲染与布局提交刷新。 // 每次布局提交刷新边界;卸载清理使用ref中的最新函数,兼容期间发生的DOM/目标替换。 useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref; } diff --git a/src/components/canvas/canvas-renderers.tsx b/src/components/canvas/canvas-renderers.tsx index e667edd..56be7c8 100644 --- a/src/components/canvas/canvas-renderers.tsx +++ b/src/components/canvas/canvas-renderers.tsx @@ -21,4 +21,5 @@ const nodes={material:MaterialNode,plainText:PlainTextNode,plainImage:PlainImage /** 每项保留原始类型/结构描述,node按rendererId查表,connection绑定CanvasEdge。 * render与preview目前共用同一个组件;createReactRenderers负责生成供ReactFlow使用的注册结果。 * 本表没有自定义类型的通配组件,兼容占位由投影/渲染选择链路处理。 */ +// 模块级初始化一次,供多个宿主复用;preview 选择同一组件不代表自动只读,交互权限仍由外层上下文限制。 export const defaultCanvasRenderers=createReactRenderers(builtinRendererDefinitions.map(definition=>{const render=definition.structure==='node'?nodes[definition.rendererId as keyof typeof nodes]:CanvasEdge;return{...definition,render,preview:render} as ReactRendererDefinition;})); diff --git a/src/components/canvas/canvas-text-editor.tsx b/src/components/canvas/canvas-text-editor.tsx index c162eef..e3e5bbd 100644 --- a/src/components/canvas/canvas-text-editor.tsx +++ b/src/components/canvas/canvas-text-editor.tsx @@ -29,10 +29,13 @@ export function CanvasTextEditor({ id, data, selected, plain = false }: { id: st element?.setSelectionRange(element.value.length, element.value.length); }, [editing]); /** 通知控制器开始文本编辑会话,再开启本地输入态;真正可写还要求节点保持选中。 */ + // begin 没有成功返回值约定;即使作用域包装静默拒绝动作,本地 editing 仍被设 true,文档修改仍由动作护栏判定。 function begin() { actions.begin(id); setEditing(true); } // 同时满足editing与selected才允许输入;展示时textarea只读且不进入Tab顺序。 const active = editing && selected; const label = plain ? "纯文本" : data.title; + // 本组件未监听 compositionstart/end;正文变化直接转交 edit,输入法合并与 history 分组由 Provider 决定。 + // textarea 的 Escape 处理也不检查 isComposing,仅用于退出输入态。 // 输入最多100000字符;onChange立即发动作,blur退出并通知endEditing。Escape只结束编辑,不回滚已输入文本。 // 非编辑态覆盖层不接管单击逻辑,让外层画布选择/拖动;双击使用浏览器原生时序并按移动标记过滤。 // 键盘Enter/F2仅在已选中时进入;双击未选中节点会先发select,再开始编辑。 diff --git a/src/components/canvas/canvas.module.css b/src/components/canvas/canvas.module.css index 5697732..24babcb 100644 --- a/src/components/canvas/canvas.module.css +++ b/src/components/canvas/canvas.module.css @@ -82,6 +82,7 @@ .colorPicker { position: relative; display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; cursor: pointer; } .colorPicker span { width: 17px; height: 17px; border: 1px solid #bfb4aa; border-radius: 50%; } .colorPicker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; } +/* z-index 只在相关层叠上下文中排序;Portal、transform/zoom 或原生 top layer 可改变跨容器的叠放关系。 */ /* 分支菜单固定定位并高于普通工具条,菜单项整行点击;定位坐标由组件计算。 */ .branchMenu { position: fixed; z-index: 60; width: 210px; padding: 7px; border: 1px solid #ded7ce; border-radius: 10px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a1a; } .branchMenu header { display: flex; align-items: center; justify-content: space-between; padding: 3px 6px 6px; color: #92877c; font-size: 11px; } @@ -134,6 +135,7 @@ /* 归属变更通知居中放在底部工具栏上方;空内容隐藏,非空也不接管指针。 */ .membershipNotice { position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); z-index: 20; max-width: min(540px, calc(100vw - 32px)); padding: 8px 12px; border: 1px solid #c8d9ca; border-radius: 8px; background: #f3f8f3; color: #3e684b; font-size: 12px; pointer-events: none; } .membershipNotice:empty { display: none; } +/* 以下后代选择器依赖实际 DOM 祖先;移到 body Portal 的浮层不会仅因 React 上下文关系命中 .handMode。 */ /* 手形模式统一关闭文本、缩放、中心点和样式工具条的编辑入口。 */ .handMode .plainText { pointer-events: none; } .handMode .rotationHint, .handMode .centerHandle { visibility: hidden; } @@ -150,6 +152,7 @@ .frameNode.selectedFrame { border-color: #a47764; box-shadow: 0 0 0 2px #a477641a; } /* 名称放在分区顶部外侧并以左下角缩放;文字截断但标签自身可拖动。 */ .frameLabel { position: absolute; bottom: calc(100% + 8px); left: 0; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 22px; color: #92806d; transform-origin: left bottom; pointer-events: auto; cursor: grab; } +/* 这里的10px属于局部 CSS 坐标,外层画布/viewScale 缩放后不保证仍为屏幕10px。 */ /* 四条 10px 命中带跨在视觉边框两侧,扩大拖动区域,不给整个分区内部铺点击层。 */ .frameBorderHit { position: absolute; pointer-events: auto; cursor: grab; } .frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; } @@ -217,6 +220,7 @@ @keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } } /* 第二组减少动态效果规则关闭滑块过渡和两种连线动画。 */ @media (prefers-reduced-motion: reduce) { .frameLabelSlider { transition: none; } .edgeDashAnimation, .edgePulseAnimation { animation: none; } } +/* 覆盖层能拦截其区域的指针,不自动使背后 DOM inert 或禁用全部键盘命令;准入仍须由事件/命令层处理。 */ /* 全画布 busy 覆盖层是独立呈现态;成员局部同步使用后部 memberSync,不应从这个类推断所有任务都会锁画布。 */ .canvasBusy { position: absolute; inset: 0; display: flex; align-items: start; justify-content: center; padding-top: 80px; z-index: 200; background: #fffefb35; cursor: progress; color: #78523f; font-size: 12px; } /* 子图显示比例输入窄排布;折叠子图的摘要占内部 inset 区域并可点击。 */ diff --git a/src/components/canvas/content-handles.tsx b/src/components/canvas/content-handles.tsx index b950fcd..455ebb3 100644 --- a/src/components/canvas/content-handles.tsx +++ b/src/components/canvas/content-handles.tsx @@ -7,5 +7,6 @@ import styles from "./canvas.module.css"; /** 四边均使用source Handle以配合画布连接模式;selected控制能否开始连线以及读屏可见性。 * 不在这里创建端点记录,也不判断跨分区连线的归属。 */ export function ContentHandles({ selected }: { selected: boolean }) { + // selected 只设置 isConnectableStart/aria-hidden;未显式设置结束端能力或 tabIndex,仍须遵守外层 ReactFlow 配置。 return <>{([["top", Position.Top], ["right", Position.Right], ["bottom", Position.Bottom], ["left", Position.Left]] as const).map(([id, position]) => )}; } diff --git a/src/components/canvas/draft-recovery.tsx b/src/components/canvas/draft-recovery.tsx index df2df4d..77cb76c 100644 --- a/src/components/canvas/draft-recovery.tsx +++ b/src/components/canvas/draft-recovery.tsx @@ -11,6 +11,7 @@ export function DraftRecovery({ restore, discard, canRestore = true, exportDraft const dialog = useRef(null); // 挂载时showModal启用浏览器模态行为,卸载时close;不额外保存一份open状态。 useEffect(() => { const element = dialog.current; element?.showModal(); return () => element?.close(); }, []); + // 点击按钮只同步调用宿主,不自动 close,也没有 busy/重复点击互斥;宿主完成选择后负责卸载对话框。 // 无document不创建Portal;取消事件阻止默认Escape关闭,要求用户通过明确的版本选择按钮处理。 if (typeof document === "undefined") return null; return createPortal( event.preventDefault()}> diff --git a/src/components/canvas/edge-editing.tsx b/src/components/canvas/edge-editing.tsx index 538e58f..b3ca68e 100644 --- a/src/components/canvas/edge-editing.tsx +++ b/src/components/canvas/edge-editing.tsx @@ -9,5 +9,6 @@ import type { EdgeData } from "@/lib/canvas-v2"; /** begin可拒绝、使用旧版无返回值模式,或返回绑定手势的EditingLease;patch的transient区分临时预览。 * finish/cancel可选,active为当前活动连线ID集合;默认Set不代表全项目状态仓库。 */ const defaultEditing = { finish: undefined as undefined | (() => void), cancel: undefined as undefined | (() => void), begin: (_id?: string): boolean | void | EditingLease> => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set() }; +// 默认 active Set 与动作对象是共享引用,未冻结;消费方只能读取,不应通过修改默认对象来发布状态。 /** Context的类型保留可选结束钩子,兼容旧画布与新版租约式交互,不在此创建编辑任务队列。 */ export const EdgeEditing = createContext & Partial>>(defaultEditing); diff --git a/src/components/canvas/edge-toolbar.tsx b/src/components/canvas/edge-toolbar.tsx index cde0d06..0404449 100644 --- a/src/components/canvas/edge-toolbar.tsx +++ b/src/components/canvas/edge-toolbar.tsx @@ -12,6 +12,7 @@ import styles from "./canvas.module.css"; export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count, knotMode, changeKnotMode }: { id: string; x: number; y: number; data: EdgeData; change: (data: EdgeData) => void; add: () => void; remove: () => void; count: number; knotMode?: string; changeKnotMode: (mode: "corner" | "smooth" | "symmetric") => void }) { const a = data.appearance || {}, animation = data.animation || {}; /** 合并appearance子对象并保留其它连线数据;箭头/箭尾各自更新时仍需在事件内保留其嵌套字段。 */ + // 合并基于本次 render 捕获的 data;change 必须由外层正确采用,不能把旧回调当成读取最新文档的入口。 const appearance = (patch: NonNullable) => change({ ...data, appearance: { ...a, ...patch } }); /** 动画属性单独合并,调整一个设置不覆盖已有方向、效果等其它字段。 */ const animate = (patch: NonNullable) => change({ ...data, animation: { ...animation, ...patch } }); @@ -20,6 +21,7 @@ export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count // 宽度校验0.5—20,端部尺寸4—32,动画时长250—30000ms;透明度范围由range控件给出。 // head/tail分别合并type/size/filled;传播开关将execution替换为当前enabled对象。 // 无曲线点时禁用模式和删除,达到64点时禁用添加;外层仍负责相应操作的实际合法性。 + // 每个控件即时调用回调,不负责 history 分组或持久化;数值范围检查也不要求步长整数倍。 // 所有指针/键盘冒泡止于工具栏,避免颜色或输入操作同时触发画布编辑。 return
event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}> diff --git a/src/components/canvas/editing-lease.ts b/src/components/canvas/editing-lease.ts index 2fceebc..674dda4 100644 --- a/src/components/canvas/editing-lease.ts +++ b/src/components/canvas/editing-lease.ts @@ -6,7 +6,8 @@ /** preview产生临时显示,commit结束并提交,cancel撤销本次预览;具体语义由创建租约的编辑器实现。 */ export type EditingLease = { preview(value: T): void; commit(): void; cancel(): void }; -/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略,不能结束另一场交互。 */ +/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略。 + * 是否识别精确交互由 isCurrent 的实现保证;这里只冻结外壳,不复制 actions 或吞掉检查/动作异常。 */ export function guardEditingLease(isCurrent: () => boolean, actions: EditingLease): EditingLease { return Object.freeze({ // 指针移动可能持续产生事件,必须逐次检查,不能只在创建租约时检查一次。 diff --git a/src/components/canvas/frame-feedback.tsx b/src/components/canvas/frame-feedback.tsx index e90e31a..12f7582 100644 --- a/src/components/canvas/frame-feedback.tsx +++ b/src/components/canvas/frame-feedback.tsx @@ -20,6 +20,7 @@ export function MembershipBadge({ id, frameId, selected }: { id: string; frameId const name = target ? names.get(target) : undefined; if (!drop && !selected) return null; // 目标有名称时区分“松开加入”与“属于”;没有目标且原来有归属则提示移出,否则不绘制。 + // 提示依赖 names 完整:目标有 ID 却缺名称时也会落入下面的移出/空提示分支,不证明真实归属已改变。 const label = name ? `${drop && drop.to !== drop.from ? "松开加入" : "属于"}:${name}` : drop?.from ? `松开移出:${names.get(drop.from) || "分区"}` : null; return label &&
{label}
; } diff --git a/src/components/canvas/frame-label-menu.tsx b/src/components/canvas/frame-label-menu.tsx index 547d78f..b94fad9 100644 --- a/src/components/canvas/frame-label-menu.tsx +++ b/src/components/canvas/frame-label-menu.tsx @@ -17,7 +17,9 @@ const options = [ export function FrameLabelMenu({ value, change }: { value: FrameLabelMode; change: (mode: FrameLabelMode) => void }) { // 以数组保存三个真实按钮引用,键盘选择后直接聚焦;滑块索引在未知value时回退0。 const items = useRef<(HTMLButtonElement | null)[]>([]); + // 未知值只让视觉索引回退0,下面 radio 的 aria-checked/tabIndex 仍按原 value 比较,不会自动修复模型。 const current = Math.max(0, options.findIndex((option) => option.value === value)); + // 键盘计算基于受控 value 而非实际焦点;change 应让宿主及时发布新值,连续按键才沿新索引前进。 // 左/上为-1,右/下为+1,通过加3再取模实现循环;Home/End直接使用0/2。 // 阻止默认滚动并停止冒泡,避免选择模式的按键同时移动画布;滑块每项28px。 return
{ diff --git a/src/components/canvas/frame-node.tsx b/src/components/canvas/frame-node.tsx index e175c7d..0ccce0e 100644 --- a/src/components/canvas/frame-node.tsx +++ b/src/components/canvas/frame-node.tsx @@ -38,15 +38,19 @@ export function FrameNode(props: NodeProps & { directAnchors?: boole const cancelBlur = useRef(false); const mode = data.labelMode || "fixed"; /** 去除首尾空白,空名称回退“未命名分区”;只有与模型不同才派发修改,再归一化输入显示。 */ + // 这里只 trim/补默认名,maxLength 是输入控件限制;UTF-8 字节和数据合法性仍由命令/格式层负责。 function saveTitle() { const name = title.trim() || "未命名分区"; if (name !== data.title) editing.frame(id, { title: name }); setTitle(name); } + // title effect 无论是否正编辑都跟随外部值,外部改名可能替换未提交草稿;局部保存后也不等待远端 ACK。 // 选中时显示设置栏,动画尊重减少动态效果偏好;栏内事件停止冒泡,避免触发画布快捷键或拖动。 // 折叠切换前按viewScale还原当前尺寸,存入原状态对应的expandedSize/collapsedSize供后续恢复。 // 概览比例输入限定5%—400%,只更新presentation.contentScale;链接单独打开子画布。 + // 折叠按钮只提交 presentation 候选,不直接隐藏/删除后代;实际尺寸恢复和投影由动作实现处理。 // fixed名称与拖放提示用1/(zoom*viewScale)抵消缩放,scaled跟随画布,hidden不绘制名称。 + // 所有端口都用 source 类型配合外层连接模式;方向差异由 Start/End 能力开关表达。 // 四侧边框提供拖动命中区域;ports按同方向均匀分布,输出在右侧可起连线,输入在左侧可收连线。 return <> {selected && 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) => { diff --git a/src/components/canvas/headless-ai-panel.tsx b/src/components/canvas/headless-ai-panel.tsx index 15e3095..64c4402 100644 --- a/src/components/canvas/headless-ai-panel.tsx +++ b/src/components/canvas/headless-ai-panel.tsx @@ -19,11 +19,13 @@ const object = (value: Json | undefined) => value && typeof value === 'object' & /** 配置输入从元素当前ai字段初始化;模型输出模态按image/plain-image与其它组件区分。 * 元素数据来自父层投影,本组件直接读取engine根用于显示任务,不再创建额外轮询器。 */ export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: ProjectEngine; element: ElementRecord; close(): void; viewId:string }) { + // getSnapshot 是本次 render 的读取,不建立订阅;任务状态更新是否触发重渲染由父层负责。 const saved = object(element.data.ai), root = engine.getSnapshot(); const [models, setModels] = useState([]), [loading, setLoading] = useState(true), [error, setError] = useState(''); const [model, setModel] = useState(typeof saved.modelId === 'string' ? saved.modelId : ''); const [prompt, setPrompt] = useState(typeof saved.prompt === 'string' ? saved.prompt : ''); const [propagate, setPropagate] = useState(saved.propagate === true); + // 输入 state 仅从初次 saved 初始化;同一挂载内外部 ai 配置变化不会自动覆盖本地草稿。 const image = ['image','plain-image'].includes(element.componentId); // 用runContainsTarget核对目标身份,选择相关任务列表的最后一项;不是按时间戳重新排序。 const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element)); @@ -60,6 +62,8 @@ export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: Pr const appliedOutput=typeof output.output_id==='string'&&run?.appliedOutputIds?.includes(output.output_id); // 状态文案先展示未解决取消/采用,再展示拒绝、已采用、等待受理与任务进度,防止把请求排队说成已执行。 const message = cancelling ? '取消请求已保存,等待确认…' : applying ? '正在应用生成结果…' : rejectedStart ? `生成未受理:${rejectedStart.blockers.join('、')}` : appliedOutput ? '此生成结果已应用' : pending.length ? pending.some(record => record.phase === 'blocked') ? '生成受理需要处理' : '正在等待同步并受理生成…' : run?.blocked ? '生成结果已保留,当前编辑需要协调' : run?.error ? '任务跟踪需要处理,可重试' : run?.hasMore ? '正在同步任务状态…' : run ? ({queued:'已受理,等待生成',running:'正在生成…',succeeded:'已更新当前元素',failed:'生成未完成,原内容已保留',conflict:'内容已改变,生成结果已保留',cancelled:'已取消生成',partially_succeeded:'部分步骤已完成'}[run.status]) : '结果将显示在当前元素'; + // 保存配置与 startRun 是两个同步命令,没有合成一笔事务;配置成功而启动拒绝时,配置仍已修改。 + // 命令接受也不等于服务端已受理或已持久确认,后续状态以 ledger/run 为准。 // 输入blur保存配置,执行按钮再保存一次且成功后startRun;忙碌时锁定配置和启动,关闭按钮仍可用。 // queued/running提供取消请求入口;冲突输出保留展示,只有事件读完且未采用时才允许显式替换。 // 跟踪重试调用engine.retry,不新建run.start;pointer/keydown止于面板,避免触发画布手势或快捷键。 diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 8e0f320..25b14f0 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -149,8 +149,10 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi // 保留调用方renderer表,并显式安装unsupported节点/边占位,未知内容仍可在场景中呈现。 const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]); // 稳定动作闭包从current/engine读取最新状态,runtime-only发布不必重建全部卡片编辑上下文。 + // current 是最近一次 React render 的快照,frame 合并期间可落后于 engine;需要强身份保证的入口仍须实时查询。 const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions }; /** 将命令拒绝或能力不可用显示为错误;成功结果无需额外复制进本地state。 */ + // 这里只呈现 rejected/disabled,不捕获同步异常,也不在成功时自动清除旧错误文案。 const report = useCallback((result: Result | { enabled: boolean; code?: string }) => { if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code))); }, []); @@ -204,6 +206,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const width = (change.width ?? displayed.width!) / t.scale, height = (change.height ?? displayed.height!) / t.scale; const position = change.position ?? displayed.position, center = toLocal(t, { x: position.x + width * t.scale / 2, y: position.y + height * t.scale / 2 }); const patch: Partial = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) }; + // 入参 fontSize 未进入这个 Geometry patch;字体大小修改应走单独 data/style 命令,而不是几何租约。 report(engine.sessions.update(viewId, target(id), patch)); } /** 创建内置节点,可加入调用方已有事务;frame同时创建以节点为入口的子canvas,保证结构一起提交。 @@ -217,6 +220,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } }; /** tx内创建元素及可选canvas关系;无外部tx时包装为一个Add element事务。 */ const create = (transaction: NonNullable) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); }; + // 返回分配的 ID 不代表创建成功:无 tx 时这里只 report 拒绝,调用方若要依赖结果须检查事务状态。 if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' })); return id; } @@ -229,6 +233,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } })); } // 用节点ID+assetId序列作为显示订阅键,资源分辨率/URL交资源视图决定;组件不把Blob URL写入元素data。 + // 资源键只反映当前投影的 nodeId/assetId,身份和 URL 可用性由 resourceView/engine 重新核验。 const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId])); // 每个engine/view建立资源显示器,回调只接受当前实例;清理dispose解除订阅及显示资源占用。 useEffect(() => { @@ -245,6 +250,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); } // 同步显示来自ledger:未resolved算待处理;waiting_digest单独等待不算阻断,无定时重试的错误和conflicts要提示处理。 const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved'); + // “已同步”文案依据这些 ledger/冲突计数,不是所有资源解码、所有后台任务或远端生成全部完成的证明。 const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0; // 全局失焦或Escape仅取消本视图拥有的节点/连接手势;不干扰同engine其它视图的交互。 useEffect(() => { @@ -286,6 +292,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi // 单选时才启用图片适配/AI工具;图片要求已知组件、resize能力和声明的assetId资源字段,不能猜同名业务字段。 const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined; const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined; + // canFitImage 在显示上因任意视图的 gesture 都禁用;真正命令能否执行仍由 engine.fitImage 再判断。 const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture; const aiElement = selected?.structure === 'node' && engine.editor.registry.resolve(selected) === 'known' && ['text','plain-text','script','image','plain-image'].includes(selected.componentId) ? selected : undefined; // Runtime-only publications must not invalidate every card's editing context. @@ -301,6 +308,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }), beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); }, // 样式和分区补丁走data,模式rotate/resize只在sessions中切换;开启一个前先清同视图该类旧模式。 + // data 命令收到的是这次传入的 textStyle 对象;本层不读取并深合并旧样式,合并语义由 editor 定义。 style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)), rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); }, resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); }, @@ -318,6 +326,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!; const transform = current.current.projection.transforms.get(element.ownerCanvasId)!; /** token护栏绑定该条连接手势,预览路径先从场景空间变回owner局部空间。 */ + // owner 的坐标 transform 在 begin 时捕获;token 护栏只核手势身份,归属变化是否合法由 sessions 校验。 const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token; return guardEditingLease(ownsGesture, { preview: (path: NonNullable) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') }); }, @@ -338,12 +347,14 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi }),[engine,viewId,report]); // 自定义renderer的dispatch/transaction也经过editor,并统一report拒绝;选项只传label/groupId。 const rendererCommands=useMemo(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]); + // rendererCommands 直接委托 editor;它不是额外按 viewId/渲染目标绑定的权限层,扩展 renderer 必须遵守调用契约。 // 本组件Hook全部调用后才判断视图关闭,保留React Hook调用顺序;已detach时不挂载交互surface。 if (!liveView) return

画布已关闭

; // surface集中处理焦点epoch、拖动起点和键盘路由:blur微任务只清旧epoch,多触点取消当前手势。 // 快捷键按输入上下文优先级处理Escape、H/V、撤销重做及删除;只有认领成功才阻止默认行为。 // ReactFlow位移变化只转session预览,测量单独缓存;拖动结束依据起始位置补最后位移后commit。 // 工具栏撤销/深复制/导出走各自editor/runtime接口,视口缩放只改view;导出完整文档JSON,不含文件字节。 + // 复制完成回调只比较 engine/viewId,未另设卸载信号;文件暂存、复制和导出均不因关闭面板而自动回滚。 // 文件选择调用stageFiles,引用与图片节点在回调事务里一起创建;导入/AI/资源/恢复面板按加载与选择条件挂载。 return
{ if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }} @@ -369,6 +380,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)} onNodeDragStart={(event, node, nodes) => { + // 起始位置表借用投影 position 引用;后续 preview 必须保持不可变更新,不能原地篡改这些起点。 const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id)); // 同时选中分区入口与后代时只对最上层目标启动手势,后代位置由结构投影带动。 const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); }); @@ -419,9 +431,11 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi /** 挂载期生成稳定viewId并attach,借用initialViewport只初始化一次;detach只释放视图,不关闭engine。 * 交接视口存在时禁用加载初次fit,避免从只读预览切入后可视位置突然跳变。 */ export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) { + // 初始视口只捕获一次;同一挂载内 props.initialViewport 变化不会重新定位,切项目应由宿主正确重建/切会话。 const [handoffViewport]=useState(initialViewport); const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false); // attach完成后才挂载CanvasView所需Provider;清理时detach视图,CanvasView卸载清理自身订阅。 + // attach/viewport 在 effect 中同步执行,未捕获异常;attached 是显示门闩,不负责服务端加载或持久化就绪。 useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]); return attached ? :

正在准备画布…

; } diff --git a/src/components/canvas/headless-imports.tsx b/src/components/canvas/headless-imports.tsx index 9f6f97c..e270121 100644 --- a/src/components/canvas/headless-imports.tsx +++ b/src/components/canvas/headless-imports.tsx @@ -22,13 +22,17 @@ export function HeadlessImports({ engine, canvasId, viewId }: { engine: ProjectE const input = useRef(null), [open, setOpen] = useState(false), [error, setError] = useState(""); // 每次渲染更新engine/canvas身份,用于异步文件探测和控制回调完成后避免更新另一画布的错误显示。 const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId }; + // scope 只比较 engine/canvas,不带挂载代次;同一目标卸载后的已兑现回调并非由此 ref 自动取消。 const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse(); /** 统一承接runtime控制Promise的成功/失败;这里只更新面板错误,不自行重排或重启任务。 */ const control = (work: Promise) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); }; + // control 接收已经开始的 Promise:同步调用抛错不在它的 catch 内;这里也不阻止多次点任务按钮。 // canStart决定文件入口可用性;input每次先清value,允许再次选择同一个文件。 // .json读取前64KiB探测首个非空白字符:对象走文档导入,数组走记录数组;其它后缀走NDJSON。 // 探测前捕获canvas父关系及入口生命周期,await后重新核对,防止向已被替换/移走的目标启动任务。 + // 64KiB 只是后缀为 .json 时的格式探测;真正解析、校验、文件持久保存和逐步加入由 runtime 负责。 // integrityVersion=1源校验未完成显示校验中;=2允许内容继续编辑,但提示校验完成后同步。 + // “重试源文件校验/保存”调用 engine.retry,作用于项目待重试工作,不是只重试当前展示的 job。 // 任务按钮按runtime状态显示;失败详情最多列前10项,停止提示明确已加入内容继续同步。 return