From 5d82655e0e1e2f303bc89a58b509d3e859b510e6 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:08:02 +0800 Subject: [PATCH] docs(canvas): explain legacy document and sync state boundaries --- src/lib/canvas-subgraphs.ts | 4 ++++ src/lib/canvas-sync.ts | 6 ++++++ src/lib/canvas-tree.ts | 6 +++++- src/lib/canvas-v2.ts | 5 ++++- src/lib/canvas.ts | 9 +++++++++ src/lib/demo-workspace.ts | 2 ++ 6 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/lib/canvas-subgraphs.ts b/src/lib/canvas-subgraphs.ts index 5d20707..d842ae7 100644 --- a/src/lib/canvas-subgraphs.ts +++ b/src/lib/canvas-subgraphs.ts @@ -19,6 +19,7 @@ export function subgraphMoveParticipants(documents: Map, for (const node of documents.get(board)?.nodes || []) if (node.data.subgraph) addDescendants(node.data.subgraph.boardId, seen); }; for (const node of documents.get(source)?.nodes || []) if (ids.includes(node.id) && node.data.subgraph) addDescendants(node.data.subgraph.boardId); + // 祖先端口影响只检查 parent 的直接上一层及其一层内端口映射,不是无限上溯的依赖闭包。 const ancestor = parents.get(parent); if (ancestor) { const frame = documents.get(ancestor.boardId)?.nodes.find((node) => node.id === ancestor.frameId); @@ -36,6 +37,7 @@ export function subgraphMovePath(documents: Map, source: const parents = subgraphParents(documents); /** 包含自身的父链,重复board立即拒绝,避免循环遍历。 */ const chain = (board: string) => { const result = [board]; while (parents.has(board)) { board = parents.get(board)!.boardId; if (result.includes(board)) throw new Error("subgraph_cycle"); result.push(board); } return result; }; + // 相同 source/target 得到空路径;父索引只反映已加载 documents,缺数据不能据此认定全局无共同根。 const from = chain(source), to = chain(target), common = from.find((id) => to.includes(id)); if (!common) throw new Error("ownership_conflict"); const path: { parent: string; frame: string; source: string; target: string }[] = []; @@ -56,6 +58,7 @@ export function exposedConnection(documents: Map, source while (boardId !== owner) { const parent = parents.get(boardId)!; const frame = documents.get(parent.boardId)?.nodes.find((node) => node.id === parent.frameId); + // handle 按 === 精确匹配,包括 undefined 与 null 的差别;不会在这里规范化端口表示。 const port = frame?.data.ports?.find((port) => port.nodeId === id && port.handle === handle && port.direction === direction); if (!port) throw new Error("boundary_port_required"); id = parent.frameId; handle = `port:${port.id}`; boardId = parent.boardId; @@ -71,5 +74,6 @@ export function connectionTransactionPlan(documents: Map const path = (id: string) => { const result = [id]; while (parents.has(id)) { id = parents.get(id)!.boardId; if (result.includes(id)) throw new Error("subgraph_cycle"); result.push(id); } return result; }; const from = path(source.boardId), to = path(target.boardId), owner = from.find((id) => to.includes(id)); if (!owner) throw new Error("ownership_conflict"); + // 参与集按 source→owner、target→owner 首次出现顺序去重;这只是规划,不带服务版本或原子提交证明。 return { boardId: owner, participants: [...new Set([...from.slice(0, from.indexOf(owner) + 1), ...to.slice(0, to.indexOf(owner) + 1)])], source: { board_id: source.boardId, node_id: source.id, handle: source.handle }, target: { board_id: target.boardId, node_id: target.id, handle: target.handle } }; } diff --git a/src/lib/canvas-sync.ts b/src/lib/canvas-sync.ts index ab2a2a4..3558e11 100644 --- a/src/lib/canvas-sync.ts +++ b/src/lib/canvas-sync.ts @@ -73,12 +73,14 @@ export class CanvasSync { constructor(request: CanvasRequest, path: string, initial: CanvasSnapshot, notify: (state: SyncState) => void) { this.request = request; this.path = path; this.version = initial.version; this.saved = canvasFingerprint(initial.document); this.savedContent = canvasFingerprint(initial.document, false); this.notify = notify; + // 保留文档引用,不做构造时解码/深复制;宿主须传入合法快照且避免原地修改在途对象。 this.document = initial.document; } /** 只有持久内容变化才需PATCH,单纯平移/缩放视口不主动写;内容保存时payload仍携当前viewport。 */ needsSave(document: CanvasDocument) { return canvasFingerprint(document, false) !== this.savedContent; } /** 替换等待槽,有错误时只保留内容而不自动发送;无错误且未运行时启动一个微任务drain。 */ enqueue(document: CanvasDocument): Promise { + // 等待槽借用传入引用;若宿主随后原地修改,drain 读到的内容也会改变,必须以新对象提交编辑。 this.pending = document; if (this.error) return Promise.resolve(); if (!this.running) this.running = Promise.resolve().then(() => this.drain()).finally(() => { this.running = null; if (this.pending && !this.error) void this.enqueue(this.pending); }); @@ -99,6 +101,7 @@ export class CanvasSync { private async drain() { while (this.pending) { const document = this.pending; this.pending = null; + // saving 通知在 try 外;notify 应不抛错,否则 running 会拒绝且本次 document 不进入失败回填。 this.notify({ status: "saving", version: this.version }); try { const fingerprint = canvasFingerprint(document); @@ -110,6 +113,7 @@ export class CanvasSync { const result = await this.request<{ version: number }>(this.path, { method: "PATCH", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${this.version}"` }, body }); if (!Number.isSafeInteger(result?.version) || result.version !== this.version + 1) throw new Error("invalid_canvas_snapshot"); // 只有通过版本回执后推进保存基线;在途期间新enqueue留在pending供下一轮处理。 + // 下方保存状态回调仍在 try 内;它若抛错会走 catch,此时版本和部分保存状态已经推进。 this.version = result.version; this.saved = fingerprint; this.savedContent = canvasFingerprint(document, false); this.document = canvasPayload(document); this.notify({ status: "saved", version: this.version }); @@ -128,6 +132,7 @@ export class CanvasSync { * 格式错误继续抛出,不将损坏草稿当作空白文档自动覆盖。 */ export function readLocalRecovery(storage: Pick, key: string, snapshot?: CanvasSnapshot): CanvasDocument | null { const record = storage.getItem(key + ":recovery"); + // 新键存在且内容非空便独占此次恢复判断;即使返回 null 也不再尝试旧键。 if (record) { const value = JSON.parse(record); const doc = decodeCanvas(JSON.stringify(value.document)); @@ -144,5 +149,6 @@ export function readLocalRecovery(storage: Pick, key: string export function canRestoreCanvasDraft(draft: CanvasDocument, remote: CanvasDocument) { if (draft.schema_version < remote.schema_version) return false; const relations = (document: CanvasDocument) => document.nodes.filter((node) => node.data.subgraph).map((node) => ({ id: node.id, subgraph: node.data.subgraph, ports: node.data.ports || [] })).sort((a, b) => a.id.localeCompare(b.id)); + // 端口数组保留顺序,JSON 表示参与比较;不比较正文、几何或所有跨图端点。 return JSON.stringify(relations(draft)) === JSON.stringify(relations(remote)); } diff --git a/src/lib/canvas-tree.ts b/src/lib/canvas-tree.ts index 7a05f93..a8aab50 100644 --- a/src/lib/canvas-tree.ts +++ b/src/lib/canvas-tree.ts @@ -12,7 +12,7 @@ export type CanvasDocuments = Map; /** 根board保留原ID,其余加board::前缀,避免旧协议不同board里的同名元素显示碰撞。 */ export const displayID = (root: string, board: string, id: string) => root === board ? id : `${board}::${id}`; /** 路径位置点应用平移+缩放,切线/首尾控制量是向量只缩放;inverse将显示路径转换回owner空间。 - * 没有path时复制data,避免调用方修改投影时改变原始对象。 */ + * 没有path时深复制data;有path时只重建路径几何层,其余扩展对象仍可能与原data共享引用。 */ export function transformEdgeData(data: EdgeData | undefined, reference: CanvasReference, inverse = false): EdgeData | undefined { if (!data?.path) return data ? structuredClone(data) : undefined; const { origin, scale } = reference; @@ -51,6 +51,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) { for (const edge of document.edges) pending.push({ edge, ref: reference }); } visit(root, { x: 0, y: 0 }, 1, new Set()); + // 仅遍历祖先链防环,不全局去重同一子 board 的多入口;调用方须先保证单父树和显示 ID 合法。 const visible = new Set(nodes.map((node) => node.id)); /** 展开入口且端口有效时递归寻内层显示端点;找不到已显示内层就保留外侧入口端口。 */ function endpoint(board: string, nodeId: string, handle: string | null | undefined, depth = 0): { id: string; handle?: string | null } { @@ -75,6 +76,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) { /** A selected child and its selected parent frame must not receive the same drag twice. */ /** 父框与子节点同时选中拖动时,先递归取得父框移动后的显示原点,避免同一拖动位移叠加两次。 */ export function referenceAfterMoves(root: string, documents: CanvasDocuments, boards: Map, boardId: string, moves: Map): CanvasReference { + // boards 必须来自合法投影;此递归不另建 seen 或深度限制,不接受任意含环父引用表。 const reference = boards.get(boardId); if (!reference) throw new Error("invalid_reference"); if (boardId === root || !reference.parentBoard || !reference.parentFrame) return reference; @@ -94,6 +96,7 @@ const equal = (a: unknown, b: unknown) => JSON.stringify(a, (_key, value) => val export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument, remote: CanvasDocument): CanvasDocument { /** 仅普通对象可逐字段递归,数组/标量两侧不同改动不会“最后写入获胜”,而是抛version_conflict。 */ const merge = (base: unknown, local: unknown, remote: unknown): unknown => { + // 未改变的分支直接借用另一侧对象;本合并不是全图深复制,采用后仍须遵守文档不可变约定。 if (equal(local, base)) return remote; if (equal(remote, base) || equal(local, remote)) return local; if ([base, local, remote].every((v) => !!v && typeof v === "object" && !Array.isArray(v))) { @@ -109,6 +112,7 @@ export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument } /** 排除选择/拖动/测量/style这些框架字段,它们不该引发业务字段合并冲突。 */ const strip = (value: T) => { const { selected, dragging, measured, style, ...clean } = value; return clean; }; + // schema 取两侧最大值只选择表示版本,不执行格式迁移或最终整图解码验证。 const result = { schema_version: Math.max(local.schema_version, remote.schema_version) as 1 | 2, nodes: collection(base.nodes.map(strip), local.nodes.map(strip), remote.nodes.map(strip)) as CanvasNode[], diff --git a/src/lib/canvas-v2.ts b/src/lib/canvas-v2.ts index 84d199c..ff7bc41 100644 --- a/src/lib/canvas-v2.ts +++ b/src/lib/canvas-v2.ts @@ -19,9 +19,10 @@ export const pointValid = (value: unknown): value is XYPosition => !!value && ty export function fields(value: unknown, keys: string[]): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).every((key) => keys.includes(key)); } -/** 所有v2校验失败统一抛invalid_canvas,不返回半份有效扩展。 */ +/** 显式条件校验失败统一抛 invalid_canvas;JSON.stringify/structuredClone 等原生异常仍可能直接传播。 */ function check(value: boolean) { if (!value) throw new Error("invalid_canvas"); } /** 字段缺失可通过,字段存在时必须满足校验;存在但undefined不等同于缺失。 */ +// 使用 in 同时识别继承字段;外部 JSON 解码结果通常是普通自有字段对象,勿把此助手视为原型净化。 function optional(object: Record, key: string, valid: (value: unknown) => boolean) { check(!(key in object) || valid(object[key])); } /** 构造封闭枚举校验器,不接受任意扩展字符串。 */ const choice = (...values: unknown[]) => (value: unknown) => values.includes(value); @@ -76,6 +77,7 @@ export function decodeNodeExtensions(data: Record, schema: numb check(["text", "script", "plain-text", "image", "plain-image"].includes(String(data.kind)) && fields(data.ai, ["enabled", "modelId", "prompt", "propagate", "parameters"])); const ai = data.ai as Record; optional(ai, "enabled", bool); optional(ai, "propagate", bool); optional(ai, "modelId", (v) => typeof v === "string" && v.length > 0 && v.length <= 256 && v.trim() === v); optional(ai, "prompt", (v) => typeof v === "string" && [...v].length <= 16000); optional(ai, "parameters", (v) => !!v && typeof v === "object" && !Array.isArray(v) && new TextEncoder().encode(JSON.stringify(v)).length <= 16384); + // parameters 这里只检查对象外壳和 JSON 编码大小;不解析各模型参数 schema 或证明服务端支持。 extensions.ai = structuredClone(ai) as AIConfig; } if (["subgraph", "ports", "presentation"].some((key) => key in data)) check(data.kind === "frame"); @@ -99,6 +101,7 @@ export function decodeNodeExtensions(data: Record, schema: numb * 内联、demo或其它地址原样返回,不把任意HTTPS资源都伪装成受管资源。 */ export function managedImageSource(src?: string, preview = false) { if (!src) return src; + // 此兼容路径固定项目名 endoleap,不读取 NEXT_PUBLIC_FOUNDRY_PROJECT;命中后舍弃原查询/片段。 try { const url = new URL(src); const match = url.pathname.match(/^\/projects\/endoleap\/(workspaces\/[A-Za-z0-9_-]+\/assets\/[A-Za-z0-9_-]+)$/); if (url.origin === "https://api.foundry.entroia.com" && match) return "/api/foundry/" + match[1] + (preview ? "?variant=preview" : ""); } catch { /* inline and demo images are already browser-readable */ } diff --git a/src/lib/canvas.ts b/src/lib/canvas.ts index 5b9cb1e..e5d14ee 100644 --- a/src/lib/canvas.ts +++ b/src/lib/canvas.ts @@ -45,12 +45,14 @@ export function canvasStorageKey(workspace: string, board: string) { return `endoleap_canvas_v1:${workspace}:${board}`; } /** 把旧present压入past,截留最近40项并清空redo;checkpoint可记录相同文档对象作为手势起点。 */ +// 仅保存文档引用,不深复制或做相等判断;调用方必须保持历史快照不可变,空修改也可能占一个撤销项。 function recorded(state: CanvasHistory, present: CanvasDocument): CanvasHistory { return { past: [...state.past, state.present].slice(-40), present, future: [] }; } /** 纯快照归约。动作是否产生撤销单元由各分支明确决定,不能假定每次pointermove或字符输入都入栈。 */ export function canvasReducer(state: CanvasHistory, action: CanvasAction): CanvasHistory { switch (action.type) { + // patch 直接合入 data 并升级表示版本;reducer 不调用 schema 解码,输入合法性由动作发起方保证。 case "node-data": return recorded(state, { ...state.present, schema_version: 2, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, ...action.patch } } : node) }); // 连线预览可只改present并清redo,最终逻辑编辑则记录旧快照。 case "edge-data": { @@ -58,7 +60,9 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva return action.transient ? { ...state, present, future: [] } : recorded(state, present); } // 放下内容才重新算旧frameId归属;拖动/缩放分区本身不在此改变成员关系。 + // drop 沿用已有 past/future,不清 redo;宿主须将它与实际拖动的 checkpoint/transform 边界配合使用。 case "drop": return { ...state, present: { ...state.present, nodes: assignFrameMembership(state.present.nodes, action.ids) } }; + // 重命名/分区/样式动作直接记录,不因目标缺失或值相等自动省略历史。 case "rename-node": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, title: action.title } } : node) }); case "frame": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id && node.data.kind === "frame" ? { ...node, data: { ...node.data, ...(action.title !== undefined ? { title: action.title } : {}), ...(action.labelMode ? { labelMode: action.labelMode } : {}) } } : node) }); // 交互起点可显式入栈,后续edit/transform不逐帧增加历史项。 @@ -87,6 +91,7 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva case "style": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, textStyle: { ...node.data.textStyle, ...action.style }, ...(action.fontSize !== undefined ? { fontSize: action.fontSize } : {}) } } : node) }); // 连续几何变化同步修改width/height、measured及style;子图frame保存对应展开/折叠尺寸。 case "transform": { + // 宽高必须同时给出才更新尺寸;只给单轴时该轴也不生效,position/rotation/fontSize 各自独立。 const patch = action.transform; return { ...state, future: [], present: { ...state.present, nodes: state.present.nodes.map((node) => node.id !== action.id ? node : { ...node, @@ -146,6 +151,7 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction else position.x += 300; } // 仅选中新卡片并分配独立边ID;目标handle取旋转后主方向的反侧,源handle仍保留指定方向名。 + // at 一旦提供便覆盖上述避让位置;指定落点不会再验证重叠,返回文档也不自动登记 history。 const child = { ...makeCanvasNode(kind, at || position, src), selected: true }; const opposite: Record = { top: "bottom", bottom: "top", left: "right", right: "left" }; return { @@ -158,6 +164,7 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction /** 接受有限长度的允许格式base64内联图或无用户名/密码的HTTPS源,拒绝其它scheme。 */ function validImage(src: unknown): src is string { if (typeof src !== "string") return false; + // 只检查字符串表示和长度,不验证 base64 解码字节、图片格式或远端可访问性。 if (/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(src)) return src.length < 3_000_000; try { const url = new URL(src); return url.protocol === "https:" && !url.username && !url.password && src.length < 2048; } catch { return false; } @@ -182,6 +189,7 @@ export function decodeTextStyle(value: unknown): import("./canvas-elements.ts"). * 校验单board内容和其端口外壳,跨board子树/目标存在性需要上层联合加载与事务校验。 */ export function decodeCanvas(raw: string): CanvasDocument { if (new TextEncoder().encode(raw).length > 5 * 1024 * 1024) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。"); + // JSON.parse 异常直接传播;整个文件先入内存再解释,字节上限不是流式内存预算。 const value = JSON.parse(raw); if (!value || ![1, 2].includes(value.schema_version) || !Array.isArray(value.nodes) || !Array.isArray(value.edges) || value.nodes.length > 500 || value.edges.length > 2000) throw new Error("不是有效的啡跃图谱文件。"); @@ -221,6 +229,7 @@ export function decodeCanvas(raw: string): CanvasDocument { // frameId仅可引用本board真实frame,不能让旧平面成员关系指向一个普通节点。 const frameIds = new Set(nodes.filter((node) => node.data.kind === "frame").map((node) => node.id)); if (nodes.some((node) => node.data.frameId && !frameIds.has(node.data.frameId))) throw new Error("分区归属不存在。"); + // 以下校验本 board 的端点/端口语法,不检测生成执行图环或加载子 board 验证端口内部目标。 const nodeKinds = new Map(nodes.map((node) => [node.id, node.data.kind])); const edgeIds = new Set(); // 边身份唯一且两端存在;v2分区必须通过声明的input/output port连接,其它允许节点使用四向handle。 diff --git a/src/lib/demo-workspace.ts b/src/lib/demo-workspace.ts index ce4922c..b42f30f 100644 --- a/src/lib/demo-workspace.ts +++ b/src/lib/demo-workspace.ts @@ -9,10 +9,12 @@ import type { Board } from "./workspace"; export function readDemoBoards(workspace: string): Board[] { try { const stored = JSON.parse(localStorage.getItem("endoleap_demo_boards_" + workspace) || "null"); + // 合法空数组就是空目录,不触发样例回退;version 用 || 补默认,不验证正安全整数。 if (Array.isArray(stored)) return stored.map((board) => ({ ...board, workspace_id: workspace, kind: "board", version: board.version || 1, })); } catch { /* Fall back to the sample pages when browser storage is invalid. */ } + // map 中任一条目读取失败也由外围 catch 回退整份样例,不保留此前已映射的部分结果。 return [ { id: "morning", title: "城市清晨" }, { id: "ideas", title: "灵感收集" },