From 9b243197477d5395958c13dbeef2982a49141e50 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:04:32 +0800 Subject: [PATCH 01/55] docs(canvas): explain legacy resources and geometry ownership --- src/lib/canvas-assets.ts | 4 +++- src/lib/canvas-curves.ts | 2 ++ src/lib/canvas-elements.ts | 3 +++ src/lib/canvas-frames.ts | 3 +++ src/lib/canvas-geometry.ts | 3 +++ src/lib/canvas-icon-loader.ts | 5 ++++- 6 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/lib/canvas-assets.ts b/src/lib/canvas-assets.ts index 4e37adb..73e0f50 100644 --- a/src/lib/canvas-assets.ts +++ b/src/lib/canvas-assets.ts @@ -4,7 +4,7 @@ */ /** 由应用注入JSON/FormData请求端口,凭证与错误形状沿用宿主API封装。 */ type Request = (path: string, options?: RequestInit) => Promise; -/** 旧上传接口的素材响应形状,原件规范URL与尺寸由返回前校验。 */ +/** 旧上传接口的素材响应形状;返回前检查 ID、URL 的源站/路径/查询和尺寸,不完整验证所有字段。 */ export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string }; /** 上传原文件大小上限20MiB,空文件同样拒绝。 */ export const ASSET_LIMIT = 20 * 1024 * 1024; @@ -12,6 +12,7 @@ export const ASSET_LIMIT = 20 * 1024 * 1024; * 调用方可提供key以跨尝试复用;默认key只在这次函数调用开始时生成,不是持久恢复记录。 */ export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal, key = crypto.randomUUID()): Promise { if (!file.size || file.size > ASSET_LIMIT) throw new Error("asset_too_large"); + // 文件大小是本地发送前检查;不在这里解码图片、计算内容摘要或确认 mime_type。 const form = new FormData(); form.append("image", file, name); /** 两次发送共用同一字节、文件名和key,避免丢响应后新建第二份素材。 */ const send = () => request(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal }); @@ -25,5 +26,6 @@ export async function uploadCanvasImage(request: Request, workspace: string, fil let url: URL; try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); } if (!/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(asset.id) || url.origin !== "https://api.foundry.entroia.com" || url.pathname !== `/projects/${process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"}/workspaces/${workspace}/assets/${asset.id}` || url.search || !Number.isFinite(asset.width) || asset.width < 1 || !Number.isFinite(asset.height) || asset.height < 1) throw new Error("invalid_asset_response"); + // bytes/mime_type 未在此逐项核验,URL hash/用户名密码也未另设拒绝条件;不能视为新版资源权威证明。 return asset; } diff --git a/src/lib/canvas-curves.ts b/src/lib/canvas-curves.ts index da5211f..2a8ed8e 100644 --- a/src/lib/canvas-curves.ts +++ b/src/lib/canvas-curves.ts @@ -26,6 +26,7 @@ export function curvePath(segments: Cubic[]) { return `M ${segments[0][0].x},${s export function insertCurveKnot(from: Connector, to: Connector, path: EdgeData["path"], point?: XYPosition) { const segments = curveSegments(from, to, path); let selected = Math.floor(segments.length / 2), t = .5, distance = Infinity; + // 只采内部参数 .025~.975;距离相同时保留先遇到的段/采样点,不会选择端点 t=0 或1。 if (point) segments.forEach((segment, index) => { for (let step = 1; step < 40; step++) { const p = cubicPoint(segment, step / 40), d = Math.hypot(p.x - point.x, p.y - point.y); if (d < distance) { distance = d; selected = index; t = step / 40; } @@ -33,6 +34,7 @@ export function insertCurveKnot(from: Connector, to: Connector, path: EdgeData[" // 逐层插值求左右子曲线,anchor成为新结点,abc/bcd决定其两侧相对切线。 const [a, b, c, d] = segments[selected]; const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t), abc = lerp(ab, bc, t), bcd = lerp(bc, cd, t), anchor = lerp(abc, bcd, t); + // clone 隔离已有 knots;返回的 knot 同时就是 next.knots 中插入的对象,二者并非独立副本。 const next = structuredClone(path || { type: "bezier-chain" as const, knots: [] }); const knot = { id: crypto.randomUUID(), position: anchor, in: minus(abc, anchor), out: minus(bcd, anchor), mode: "smooth" as const }; // 同时调整相邻旧结点或首尾控制向量,否则插入后无法保留原曲线。 diff --git a/src/lib/canvas-elements.ts b/src/lib/canvas-elements.ts index 42c7602..f0e356a 100644 --- a/src/lib/canvas-elements.ts +++ b/src/lib/canvas-elements.ts @@ -25,11 +25,13 @@ export abstract class CanvasElement { readonly src?: string; /** 构造业务创建参数;position在toNode输出时复制,尚未创建节点身份或渲染对象。 */ constructor(position: XYPosition, text = "", src?: string) { + // 构造器暂借调用者的位置对象;若转换前被外部修改,toNode 会采用修改后的坐标。 this.position = position; this.text = text; this.src = src; } /** 每次转换都生成新UUID与独立position/data/style,字体默认随plain-text与卡片类型选择。 */ + // 同一个业务实例多次 toNode 也会生成不同节点身份,并非按实例 ID 幂等转换。 toNode(): CanvasNode { return { id: crypto.randomUUID(), type: this.nodeType, position: { ...this.position }, @@ -87,6 +89,7 @@ export class PlainImageElement extends CanvasElement { /** 封闭内置kind→构造器表,是旧元素工厂入口,不接受任意未知kind动态执行。 */ export const elementClasses = { text: TextCardElement, image: ImageCardElement, script: ScriptCardElement, "plain-text": PlainTextElement, icon: IconElement, frame: FrameElement, "plain-image": PlainImageElement }; /** 按kind读取静态能力,调用方需先保证kind属于内置枚举。 */ +// readonly 只约束静态属性重新赋值;能力对象仍是共享引用,调用方不能原地改动返回值。 export function elementCapabilities(kind: ElementKind) { return elementClasses[kind].capabilities; } /** 创建指定内置类型的空内容节点,src仅作为创建参数传入;不自动上传文件或登记history。 */ export function createCanvasElement(kind: ElementKind, position: XYPosition, src?: string): CanvasNode { diff --git a/src/lib/canvas-frames.ts b/src/lib/canvas-frames.ts index ca24733..7a18f57 100644 --- a/src/lib/canvas-frames.ts +++ b/src/lib/canvas-frames.ts @@ -11,6 +11,7 @@ import { nodeCenter, nodeSize } from "./canvas-geometry.ts"; /** 非frame节点按中心点找包含它的分区,多个重叠候选选面积最小者;frame本身不成为另一frame成员。 */ export function containingFrame(node: CanvasNode, nodes: CanvasNode[]) { if (node.data.kind === "frame") return undefined; + // 以未旋转布局矩形判包含,边界算内部;不是对旋转后的可见形状做精确命中。 const center = nodeCenter(node); return nodes.filter((frame) => { if (frame.data.kind !== "frame") return false; @@ -44,8 +45,10 @@ export function assignFrameMembership(nodes: CanvasNode[], droppedIds: string[]) export function applyFrameNodeChanges(changes: NodeChange[], previous: CanvasNode[]) { const nodes = applyNodeChanges(changes, previous); const before = new Map(previous.map((node) => [node.id, node])); + // 仅带实际 position 值的变更计入显式移动;dragging 标记变化本身不触发成员平移。 const positioned = new Set(changes.flatMap((change) => change.type === "position" && change.position ? [change.id] : [])); const frames = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node])); + // 单次 map 按原 frameId 计算直接父位移,不递归累积嵌套分区;合法旧数据约定 frame 不归另一 frame。 return nodes.map((node) => { const frameId = node.data.frameId; if (!frameId) return node; diff --git a/src/lib/canvas-geometry.ts b/src/lib/canvas-geometry.ts index 43c50c2..53da682 100644 --- a/src/lib/canvas-geometry.ts +++ b/src/lib/canvas-geometry.ts @@ -46,10 +46,12 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection, const rotation = node.data.rotation || 0; // 指针位移先消除节点旋转,才能区分拖动的是本地宽还是高。 const local = rotateVector(delta, -rotation); + // delta 是手势起点到当前的场景位移,屏幕缩放换算需由调用者提前完成。 const horizontal = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0; const vertical = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0; const plain = node.data.kind === "plain-text"; const icon = node.data.kind === "icon" || node.data.kind === "plain-image"; + // viewScale 只用于尺寸约束;此处不校验正有限值,0 会按 || 回退成1。 const viewScale = node.data.viewScale || 1; // 约束随显示viewScale缩放;frame可更大,普通卡片/文字/图标最小尺寸各有不同。 const maximum = node.data.kind === "frame" ? 100000 * viewScale : 3000 * viewScale; @@ -84,6 +86,7 @@ export type Connector = { point: XYPosition; normal: XYPosition }; export function nodeConnector(node: CanvasNode, side: ConnectorSide): Connector { const { width, height } = nodeSize(node); const vectors = { top: { x: 0, y: -1 }, right: { x: 1, y: 0 }, bottom: { x: 0, y: 1 }, left: { x: -1, y: 0 } }; + // side 由联合类型限制,不在运行时回退未知端口;这里的法线假设为四个单位轴向量。 const local = vectors[side]; const angle = node.data.rotation || 0; const normal = rotateVector(local, angle); diff --git a/src/lib/canvas-icon-loader.ts b/src/lib/canvas-icon-loader.ts index 4b306a8..3d3305a 100644 --- a/src/lib/canvas-icon-loader.ts +++ b/src/lib/canvas-icon-loader.ts @@ -9,6 +9,7 @@ import { createResourceLoader } from "./resource-loader.ts"; export type IconPaths = [string, Record][]; /** Tabler统一分片,其它名字用稳定31进制散列落入96个Lucide分片;散列仅用于定位而非安全校验。 */ export function iconAsset(name: string) { + // 每步 >>>0 约束为无符号32位;名称大小写参与散列,不能擅自归一化后再查分片。 let hash = 0; for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; const key = name.startsWith("Tabler") ? "tabler" : `lucide-${hash % 96}`; @@ -31,9 +32,11 @@ export function validateIconShard(value: unknown): Record { } } } + // 校验通过后原对象直接返回,不复制/冻结路径和属性;消费者必须把共享结果当只读数据。 return value as Record; } -/** 单例分片加载器使用HTTP强缓存,响应失败或校验失败不写入成功缓存。 */ +/** 单例加载器以 force-cache 请求浏览器缓存,并在内存缓存校验后的分片;不保证服务端缓存期限。 + * 响应失败或校验失败不写入成功缓存;先整体读取 JSON,不限制下载字节数或分片图标总数。 */ const resources = createResourceLoader(async (url, signal) => { const response = await fetch(url, { signal, cache: "force-cache" }); if (!response.ok) throw new Error("图标加载失败"); -- 2.52.0 From 5d82655e0e1e2f303bc89a58b509d3e859b510e6 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:08:02 +0800 Subject: [PATCH 02/55] 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: "灵感收集" }, -- 2.52.0 From cc2172319e45aca39fd1de460df49bedfb36260c Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:11:24 +0800 Subject: [PATCH 03/55] docs(ui): explain page and shared component lifecycle boundaries --- src/app/dashboard/[workspace]/[board]/page.tsx | 3 ++- src/app/dashboard/layout.tsx | 3 ++- src/app/layout.tsx | 3 ++- src/app/login/page.tsx | 4 ++++ src/app/page.tsx | 2 ++ src/components/landing-page.tsx | 1 + src/components/ui/app-feedback.tsx | 3 ++- src/components/ui/button.tsx | 1 + src/components/ui/dialog.tsx | 3 ++- src/components/ui/icon-button.tsx | 1 + 10 files changed, 19 insertions(+), 5 deletions(-) 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/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/landing-page.tsx b/src/components/landing-page.tsx index d3da5f9..6be2c63 100644 --- a/src/components/landing-page.tsx +++ b/src/components/landing-page.tsx @@ -28,6 +28,7 @@ export default function LandingPage() { const [active, setActive] = useState(0); // null表示全部收起;点击当前问题置null,点击其它问题改索引。hidden与aria-expanded/controls对应同一状态。 const [faq, setFaq] = useState(null); + // FAQ 的 hidden 只切换可见性,问答节点仍保留在 DOM;页面没有请求型加载或异步清理任务。 // 页面顺序:导航→首屏交互预览→理念横条→三个功能说明→工作步骤→FAQ→行动入口→页脚。 // 功能卡与步骤通过静态内容展示;FAQ以问题文本作key,步骤以序号作key,预览以说明作key。 // 状态仅在本次组件挂载期间保留;切换预览不自动轮播,FAQ展开不发网络请求。 diff --git a/src/components/ui/app-feedback.tsx b/src/components/ui/app-feedback.tsx index 5bc937d..7cf0416 100644 --- a/src/components/ui/app-feedback.tsx +++ b/src/components/ui/app-feedback.tsx @@ -13,7 +13,8 @@ export const FeedbackContext = createContext(false); /** 包住页面一次即可共享提示延迟;400ms首次出现,200ms内切换提示可跳过等待。 * Toaster放在右下角,最多展示三条通知,默认4500ms消失并允许关闭;颜色读取主题变量。 - * children正常透传,本组件不订阅文档、队列或服务端状态。 */ + * children正常透传,本组件不订阅文档、队列或服务端状态。 + * visibleToasts 限制同时可见数量,不是业务事件队列容量;通知创建/撤销仍通过通知适配器调用。 */ export function AppFeedback({ children }: { children: ReactNode }) { return diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index b287e0c..f9eb122 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -53,6 +53,7 @@ export interface ButtonProps const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { // 只选择宿主,不另包一层DOM;cn合并变体和调用方样式,业务逻辑不进入这个封装。 + // asChild 不把链接转换成原生按钮:disabled 属性本身不保证链接无法导航,调用方须按宿主处理行为。 const Comp = asChild ? Slot : "button"; return ( , React.ComponentPropsWithoutRef diff --git a/src/components/ui/icon-button.tsx b/src/components/ui/icon-button.tsx index 1490afe..4257a89 100644 --- a/src/components/ui/icon-button.tsx +++ b/src/components/ui/icon-button.tsx @@ -21,6 +21,7 @@ export type IconButtonProps = Omit, "chi export const IconButton = forwardRef( function IconButton({ icon: Icon, label, title = label, className = "", type = "button", ...props }, ref) { // Provider存在时由Radix显示提示,移除原生title以免出现两层提示;图标自身对读屏隐藏。 + // 后置 ...props 可以覆盖 aria-label 等同名属性;label 是默认名称,最终 DOM 语义由调用者共同负责。 const feedback = useContext(FeedbackContext); const button = ( : } diff --git a/src/components/workspace/canvas-runtime-host.tsx b/src/components/workspace/canvas-runtime-host.tsx index 46e233e..2a0d96f 100644 --- a/src/components/workspace/canvas-runtime-host.tsx +++ b/src/components/workspace/canvas-runtime-host.tsx @@ -23,6 +23,7 @@ const Renderers=createContext(defaultCanvasRenderers); export function useCanvasRenderers(){return useContext(Renderers);} /** extensions仅在useState惰性初始化时捕获;后续prop换引用不会热替换已打开项目的schema/renderer。 */ export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) { + // 惰性初始化只装配对象/工厂,不在 render 直接打开项目;实际账户绑定和持久化租约由后续调用取得。 const [{host,renderers}] = useState(() => { // 先组合内置与扩展模型,再按精确版本/结构验证renderer,冲突在打开项目之前失败。 const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry); @@ -51,6 +52,7 @@ export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactN * 真正卸载才发起host.close,保存失败由宿主错误回调/日志报告,不在此丢弃根或强行释放leader。 */ useEffect(() => { const generation = ++lifetime.current; + // cleanup 不等待 close 完成,也不主动重试;异步失败经 host 的错误通道和此处 catch 报告。 // Strict Mode replays effects synchronously; only a real detach retires the host. return () => { queueMicrotask(() => { if (lifetime.current === generation) void host.close().catch(error => console.error("[canvas-host]", error.message)); }); }; }, [host]); diff --git a/src/components/workspace/editable-title.tsx b/src/components/workspace/editable-title.tsx index b9e088d..83a1c68 100644 --- a/src/components/workspace/editable-title.tsx +++ b/src/components/workspace/editable-title.tsx @@ -53,6 +53,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: { cancelled.current = true; setError(""); setMode("selected"); + // 不保存帧句柄做卸载取消;执行时通过 ref 可选访问,目标已卸载则不聚焦。 requestAnimationFrame(() => button.current?.focus()); } /** 同步ref挡重复提交,trim后校验非空及UTF-8≤300字节;同名不发请求。 @@ -70,6 +71,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: { setMode("saving"); setError(""); try { + // onSave 没有本组件的 signal/代次护栏;切换目标或卸载后的结果隔离需由宿主回调保证。 await onSave(name); cancelled.current = true; setMode("idle"); @@ -78,6 +80,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: { setMode("editing"); } finally { pending.current = false; } } + // title prop 改变不自动覆盖正在编辑的 draft;同名判断使用本次 save 闭包看到的 title。 // saving保留input但只读;输入法组合不把Enter当提交,键盘事件停止冒泡避免触发画布快捷键。 return (
diff --git a/src/components/workspace/graph-page.tsx b/src/components/workspace/graph-page.tsx index 9fd23d2..cea6999 100644 --- a/src/components/workspace/graph-page.tsx +++ b/src/components/workspace/graph-page.tsx @@ -61,6 +61,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin // latest合并待采用的预览,pumping串行openPartial/persist,防快流把多个partial打开交错到同一项目。 let latest:{value:LiveModelPreview;board:Board;accountId:string}|undefined,pumping:Promise|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType|undefined; /** 后台任务失败仅在页面仍活跃时显示,离开页面不再更新本页error。 */ + // 此错误显示只检查页面 active;它不像结果采用分支那样再核对 currentIdentity。 const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));}; /** 已建立partial engine且身份仍有效时,交工作区继续完整读取;视图离开不等于关闭可恢复项目。 */ const completeInBackground=()=>{if(partialEngine?.loading.getState()&¤tIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);}; @@ -68,6 +69,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin function pump(){ if(pumping)return pumping; const task=(async()=>{while(latest){const next=latest;latest=undefined;if(!currentIdentity?.())return;const runtime=runtimeHost.workspace(next.accountId,next.board.workspace_id);partialRuntime=runtime;const engine=await runtime.openPartial(next.value.document,next.value.versions,next.value.loading);partialEngine=engine;if(active&¤tIdentity?.()){setLoaded(previous=>previous?.route===route&&previous.engine===engine?previous:{board:next.board,engine,route});setPreview(null);}else{completeInBackground();return;}await engine.persist();}})(); + // finally 清空运行句柄,再对等待槽续泵;latest 只留最近预览,并非逐块持久操作队列。 pumping=task.finally(()=>{pumping=undefined;if(latest&&active)void pump().catch(reportBackground);});return pumping; } @@ -91,6 +93,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin catch(error){reportBackground(error);} } // 完整模型到来先等partial采用链结束,再open同一项目,避免尾部覆盖尚在发布的前缀根。 + // 任一次泵拒绝会沿外层 catch 显示错误,不继续把完整结果冒充已经成功采用。 while(pumping)await pumping; if(!active||!currentIdentity?.())return; const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions,{sourceToken:result.model.sourceToken}); @@ -101,12 +104,14 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin return () => { active = false;latest=undefined; controller.abort();completeInBackground(); }; }, [workspaceId, boardId, route, demo, retry, router, runtimeHost]); // 即使effect清理/新读取尚未完成,只显示route匹配的结果,不短暂闪出上个board。 + // route 只包含两个路径参数,不包含 demo/retry/authEpoch;同路由这些值变化时由 effect 清状态和取消护栏处理。 const current = loaded?.route === route ? loaded : null; const currentPreview=preview?.route===route?preview:null; const board = current?.board??currentPreview?.board; /** 发布当前board元数据供侧栏标题使用,卸载/换board归还该Context显示状态,不删除服务端board。 */ useEffect(() => { if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title }); + // 这是当前页面的无条件清空,不删除 runtime 或目录记录,也不是按 boardId 比对的条件撤回。 return () => setActiveBoard(null); }, [board, workspaceId, setActiveBoard]); // 本board在侧栏改名后优先读共享新标题;其它board的活动状态不能覆盖当前名称。 diff --git a/src/components/workspace/user-avatar.tsx b/src/components/workspace/user-avatar.tsx index 361542f..0682dad 100644 --- a/src/components/workspace/user-avatar.tsx +++ b/src/components/workspace/user-avatar.tsx @@ -7,13 +7,15 @@ import { useState } from "react"; import styles from "./workspace.module.css"; -/** 同一失败URL不反复重载;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。 */ +/** 只记住最近一次失败URL;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。 + * 换 URL 可以再试,若之后回到较早失败的 URL 也可能重试;不是永久失败地址集合。 */ export function UserAvatar({ name, url, large = false }: { name: string; url?: string | null; large?: boolean; }) { const [failedUrl, setFailedUrl] = useState(null); + // fallback 用 slice(0,1) 取首个 UTF-16 代码单元,并非完整 Unicode 字素;空名字得到空占位。 const showImage = Boolean(url && url !== failedUrl); return ( -- 2.52.0 From f28d3810d4daa38488d444dc1470a2f09576c3b8 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:18:24 +0800 Subject: [PATCH 05/55] docs(canvas): clarify editing guards and projection lifetimes --- src/components/canvas/canvas-text-editor.tsx | 3 +++ src/components/canvas/editing-lease.ts | 3 ++- src/components/canvas/node-editing.ts | 4 +++- src/components/canvas/node-internals.ts | 4 +++- src/components/canvas/node-title.tsx | 2 ++ src/components/canvas/retained-projection.ts | 2 ++ src/components/canvas/text-appearance.ts | 1 + 7 files changed, 16 insertions(+), 3 deletions(-) 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/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/node-editing.ts b/src/components/canvas/node-editing.ts index 124d635..2f16981 100644 --- a/src/components/canvas/node-editing.ts +++ b/src/components/canvas/node-editing.ts @@ -41,12 +41,14 @@ export const NodeEditing = createContext{if(!scope)return editing; // 动作当刻检查engine未关闭、视图仍存在且仍在原canvas、元素生命周期与owner未变。 // candidate必须是本节点(或允许的空参数),阻止一个节点的包装替其它目标发起操作。 const current=(candidate:string|null|undefined=id)=>{const durability=scope.engine.getDurability(),view=scope.engine.sessions.getSnapshot().views.get(scope.index.viewId),record=scope.engine.getSnapshot().document.elements.get(id);return !durability.closing&&!durability.closed&&!!view&&view.canvasId===canvas&&incarnation!==undefined&&record?.incarnation===incarnation&&record.ownerCanvasId===owner&&(candidate===undefined||candidate===null||candidate===id);}; // finish/cancel没有id参数,额外确认当前手势属于本视图,且目标列表含本id/incarnation。 + // 这里不捕获 gesture token;同视图同目标的新手势也能通过,精确手势身份须由创建者的租约保护。 const ownGesture=()=>scope.engine.sessions.getSnapshot().gesture?.viewId===scope.index.viewId&&scope.engine.sessions.getSnapshot().gesture?.targets.some(target=>target.id===id&&target.incarnation===incarnation); // 拷贝展示信息,仅替换动作入口;文本/样式/几何等动作逐次检查,不缓存“曾经有效”的结论。 const scoped:typeof editing={...editing, @@ -61,7 +63,7 @@ export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){ // begin取得的租约由创建者管理;这里不把旧事件转给当前另一个手势。 if(editing.beginTransform)scoped.beginTransform=candidate=>current(candidate)?editing.beginTransform!(candidate):false; if(editing.endEditing)scoped.endEditing=candidate=>{if(current(candidate))editing.endEditing!(candidate);}; - // 结束手势要同时通过对象有效性与手势归属检查,防止迟到的pointerup结束新交互。 + // 结束手势要同时通过对象有效性与归属检查;这不是对任意迟到事件的完整 token 隔离。 if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();}; if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();}; // 包装只负责入口检查;事务合法性、持久化和失败报告仍由Provider对应的动作实现负责。 diff --git a/src/components/canvas/node-internals.ts b/src/components/canvas/node-internals.ts index aa630b0..c60beb4 100644 --- a/src/components/canvas/node-internals.ts +++ b/src/components/canvas/node-internals.ts @@ -19,9 +19,11 @@ export function useNodeInternalsUpdate(id:string){ // 首个节点创建store级batch,每次挂载取得本id的owner;清理仅释放该owner。 useEffect(()=>{ let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);} + // batch 首次创建时捕获 update;同 store 后续使用者沿用该更新器,不在每次 effect 中替换。 const owner=batch.acquire(id);lease.current=owner; return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;}; },[scope,update,id]); - // 回调通过ref读取当前租约,避免调用方保存旧闭包后继续刷新已卸载节点。 + // 回调通过ref读取当前租约;旧调用者保存的同一回调,在本组件 id 改变后会转向新 id 的租约。 + // effect 尚未安装/已清理则无动作;它不把回调永久绑定到最初 id。 return useCallback(()=>lease.current?.invalidate(),[]); } diff --git a/src/components/canvas/node-title.tsx b/src/components/canvas/node-title.tsx index 8177aa5..4fb7328 100644 --- a/src/components/canvas/node-title.tsx +++ b/src/components/canvas/node-title.tsx @@ -17,11 +17,13 @@ export function NodeTitle({ id, title, selected,renderedTarget }: { id: string; useEffect(() => { if (!selected) setEditing(false); }, [selected]); useEffect(() => { if (editing) { input.current?.focus(); input.current?.select(); } }, [editing]); /** blur是统一提交入口:忽略取消、空名称与无变化名称,随后复位取消标记并退出输入态。 */ + // rename 是同步动作入口,不等待远端 ACK;若实现抛错,下方标记复位与退出也不会自动执行。 function save() { if (!cancelled.current) { const name = value.trim(); if (name && name !== title) actions.rename(id, name); } cancelled.current = false; setEditing(false); } // Enter在非输入法组合态触发blur保存,Escape先置取消标记再blur;按键不继续冒泡到画布。 + // maxLength 是浏览器 UTF-16 长度限制;此 save 只 trim/去空/比旧值,不另做 UTF-8 字节校验。 // 标题长度限制300字符,nodrag/nopan/nowheel避免文本操作被当成画布手势。 if (editing) return setValue(event.target.value)} onBlur={save} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter" && !event.nativeEvent.isComposing) event.currentTarget.blur(); if (event.key === "Escape") { cancelled.current = true; event.currentTarget.blur(); } }} />; diff --git a/src/components/canvas/retained-projection.ts b/src/components/canvas/retained-projection.ts index 94ddffa..5f60334 100644 --- a/src/components/canvas/retained-projection.ts +++ b/src/components/canvas/retained-projection.ts @@ -8,9 +8,11 @@ export function createRetainedProjection(){ // 弱引用键避免缓存永久占住已被替换的投影对象;只按对象身份缓存,不监测对象内部原地修改。 const tagged=new WeakMap(); + // previous 强引用最近一批输入/输出;WeakMap 不等于整个实例完全不持有投影,空 ids 才清这份整批引用。 let previous:{nodes:readonly object[];edges:readonly object[];ids:Set;result:{nodes:object[];edges:object[]}}|undefined; /** 输入数组和保留ID集合生成节点/边数组;ids为空时直接返回原数组并清除整批缓存。 */ return (nodes:N[],edges:E[],ids:ReadonlySet):{nodes:N[];edges:E[]}=>{ + // 清 previous 不清 tagged;同一对象日后再次被保留,仍可能复用已有带标记副本。 if(!ids.size){previous=undefined;return{nodes,edges};} // 数组引用及ID成员都相同才复用;快照复制ids,避免调用方之后原地修改Set影响比较基准。 if(previous?.nodes===nodes&&previous.edges===edges&&previous.ids.size===ids.size&&[...ids].every(id=>previous!.ids.has(id)))return previous.result as {nodes:N[];edges:E[]}; diff --git a/src/components/canvas/text-appearance.ts b/src/components/canvas/text-appearance.ts index 50136a1..f38ef6e 100644 --- a/src/components/canvas/text-appearance.ts +++ b/src/components/canvas/text-appearance.ts @@ -7,6 +7,7 @@ import type { CanvasNodeData } from "@/lib/canvas-elements"; * 字体家族只识别serif/mono,其它继承外层;颜色、背景、对齐与行高使用各自默认值。 * 使用||回退,因此0和空字符串也走默认;这里是显示映射,不承担schema校验。 */ export function textAppearance(data: CanvasNodeData): CSSProperties { + // 每次生成新的扁平 CSS 对象;不缓存节点引用,也不会写回默认值到 data。 const style = data.textStyle || {}; return { fontSize: data.fontSize || (data.kind === "plain-text" ? 24 : 13), color: style.color || "#37352f", fontWeight: style.bold ? 700 : 400, fontStyle: style.italic ? "italic" : "normal", textDecoration: style.underline ? "underline" : "none", textAlign: style.align || "left", lineHeight: style.lineHeight || 1.5, fontFamily: style.fontFamily === "serif" ? 'Georgia, "Songti SC", serif' : style.fontFamily === "mono" ? 'ui-monospace, "SFMono-Regular", monospace' : "inherit", backgroundColor: style.background || "transparent" }; -- 2.52.0 From c34d0b7ce0c50040736925026a154ddca56245f1 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:21:06 +0800 Subject: [PATCH 06/55] docs(canvas): explain node rendering and resource handoffs --- src/components/canvas/canvas-icon.tsx | 4 ++++ src/components/canvas/canvas-image.tsx | 2 ++ src/components/canvas/canvas-renderers.tsx | 1 + src/components/canvas/content-handles.tsx | 1 + src/components/canvas/material-node.tsx | 2 ++ src/components/canvas/plain-image-node.tsx | 1 + src/components/canvas/unsupported-elements.tsx | 2 ++ 7 files changed, 13 insertions(+) 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-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/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/material-node.tsx b/src/components/canvas/material-node.tsx index ac15077..9c1f045 100644 --- a/src/components/canvas/material-node.tsx +++ b/src/components/canvas/material-node.tsx @@ -31,12 +31,14 @@ export function MaterialNode(props: NodeProps) { const editing = useScopedNodeEditing(id,data.__headlessTarget); const { zoom } = useViewport(); // 以卡片宽度(默认260)×视口缩放×设备像素比估算显示成本;1024以内优先预览源。 + // 像素比在 render 时读取,不单独监听其变化;zoom 订阅或其它重渲染才重新计算此启发式。 const preview = (props.width || 260) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024; // 仅记录连接点按下的屏幕坐标,用小于5px的位移区分点击与拖动;不把它写进文档或history。 const pointerStart = useRef({ x: 0, y: 0 }); const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type; // 旧target/source Handle禁用新连接但保留定位;四向Handle使用方向作ID,选中时允许开始连线。 // 指针点击还检查branchable能力和位移;键盘Enter/空格使用Handle中心打开菜单,由动作层再处理合法性。 + // pointerStart 不按 pointerId 分组,属于点击位移辅助;键盘入口也不在这里检查 branchable,由动作实现兜底。 // 点击/按键停止冒泡,避免分支操作同时触发节点或画布交互;文本内容的编辑生命周期由子组件负责。 return ( diff --git a/src/components/canvas/plain-image-node.tsx b/src/components/canvas/plain-image-node.tsx index 5bfbe05..99cb084 100644 --- a/src/components/canvas/plain-image-node.tsx +++ b/src/components/canvas/plain-image-node.tsx @@ -14,6 +14,7 @@ export function PlainImageNode(props: NodeProps) { // 节点宽度未提供时按320估算,再乘zoom与设备像素比;不超过1024时请求预览源。 // 这是显示宽度启发式,不检查图片原始尺寸;无window时像素比按1处理。 const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024; + // 该估算只影响托管 URL 的 variant;不是独立下载任务,不会为普通外部图片生成预览资源。 // managedImageSource按资源格式选择可用地址,移动/缩放等编辑行为仍由TransformableNode处理。 return ; } diff --git a/src/components/canvas/unsupported-elements.tsx b/src/components/canvas/unsupported-elements.tsx index c0eb586..2c11899 100644 --- a/src/components/canvas/unsupported-elements.tsx +++ b/src/components/canvas/unsupported-elements.tsx @@ -17,6 +17,7 @@ const positions={top:Position.Top,right:Position.Right,bottom:Position.Bottom,le * 仍绘制已知连接点供现有连线定位,但禁止从这些Handle发起或接收新连接;不推断未知内容字段。 */ export function UnsupportedNode({data,selected}:NodeProps){ // fallback来自bindings投影;缺失时仍显示通用提示,不尝试从data猜测组件结构。 + // 只读取公共投影信封;此组件本身没有 TransformableNode,也不安装任何编辑动作处理器。 const fallback=data.__headlessFallback; return
{data.title}此元素需要兼容的组件版本才能编辑 @@ -27,6 +28,7 @@ export function UnsupportedNode({data,selected}:NodeProps){ /** 直接使用ReactFlow推导的端点生成贝塞尔路径,附可访问标题;不调用未知类型的编辑/样式逻辑。 * BaseEdge绘制统一颜色的可见线条,数据保留与版本兼容仍归模型层负责。 */ export function UnsupportedEdge(props:EdgeProps){ + // 采用端点派生的通用曲线,不重放未知模型的自定义 path/marker;保留数据与视觉完全还原是两件事。 const [path]=getBezierPath(props); return 原始连线内容已保留; } -- 2.52.0 From 97fefaa5195eae3f248454ddd8097cefd54b85ad Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:24:09 +0800 Subject: [PATCH 07/55] docs(canvas): explain menu focus and toolbar update boundaries --- src/components/canvas/branch-menu.tsx | 3 +++ src/components/canvas/edge-toolbar.tsx | 2 ++ src/components/canvas/frame-feedback.tsx | 1 + src/components/canvas/frame-label-menu.tsx | 2 ++ src/components/canvas/icon-catalog.ts | 1 + src/components/canvas/icon-picker.tsx | 3 +++ src/components/canvas/node-style-toolbar.tsx | 2 ++ 7 files changed, 14 insertions(+) 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/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/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/icon-catalog.ts b/src/components/canvas/icon-catalog.ts index cae59ef..46e7716 100644 --- a/src/components/canvas/icon-catalog.ts +++ b/src/components/canvas/icon-catalog.ts @@ -17,6 +17,7 @@ export type IconSource = "all" | "lucide" | "tabler"; * 筛选同时满足来源和分类;common看common标记,other指没有分类,all跳过对应限制。 * 保留目录原顺序,不计算相关性分数,也不限制结果数量;虚拟列表由调用方实现。 */ export function searchIcons(query: string, category = "all", source: IconSource = "all") { + // 每次扫描全部目录并归一化检索串,无预建倒排索引;返回新数组但条目仍共享静态记录引用。 const needle = query.trim().toLowerCase().replace(/[-_\s]/g, ""); return iconCatalog.filter((icon) => (source === "all" || icon.source === source) && (category === "all" || (category === "common" ? icon.common : category === "other" ? !icon.categories.length : icon.categories.includes(category))) diff --git a/src/components/canvas/icon-picker.tsx b/src/components/canvas/icon-picker.tsx index a280419..2f74d8e 100644 --- a/src/components/canvas/icon-picker.tsx +++ b/src/components/canvas/icon-picker.tsx @@ -40,6 +40,7 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name const rows = Math.ceil(matches.length / columns); const firstRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 1); const lastRow = Math.min(rows, Math.ceil((scrollTop + height) / rowHeight) + 1); + // 虚拟化只限制挂载按钮,matches 仍保留完整同步筛选结果;不是分批解析/下载目录。 const visible = matches.slice(firstRow * columns, lastRow * columns); /** 先钳制目标索引,再按行把目标滚入视口;等下一帧虚拟按钮挂载后调用focus。 * 空结果没有可聚焦按钮,最终可选链自然不执行focus;不凭空创建占位项目。 */ @@ -52,8 +53,10 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name else if (top + rowHeight > grid.current.scrollTop + height) grid.current.scrollTop = top + rowHeight - height; setScrollTop(grid.current.scrollTop); } + // 帧未单独取消且不带筛选代次;执行时读最新 grid,筛选变化可能让同一 index 指向另一条结果。 requestAnimationFrame(() => grid.current?.querySelector(`[data-index="${next}"]`)?.focus()); } + // Portal 直接使用 document.body,仅供客户端挂载;dialog 角色不自动提供焦点陷阱或背景 inert。 // 分类点击同时清空搜索,来源选择保留查询;网格按钮按全局结果索引绝对定位,不按切片局部索引。 // 左右移动一项、上下移动一行,Home/End跳至结果首尾;只有当前focused项进入Tab顺序。 // 每个按钮由CanvasIcon加载路径,页脚结果数来自筛选数组;choose后是否关闭由调用方决定。 diff --git a/src/components/canvas/node-style-toolbar.tsx b/src/components/canvas/node-style-toolbar.tsx index 6b7dbbf..d7a1355 100644 --- a/src/components/canvas/node-style-toolbar.tsx +++ b/src/components/canvas/node-style-toolbar.tsx @@ -24,7 +24,9 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps) // 只有text/script/plain-text展示文字排版;图片不展示前景/背景色,图标可调颜色但不展示拉伸开关。 const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text"; /** 统一样式动作入口;字号作为独立可选参数,避免把它混进textStyle的字段空间。 */ + // 每次控件变化立即派发;没有在本组件合并拖动取色等多次输入为一个 undo 项,也没有等待服务端确认。 const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize); + // 显示默认值不写回 data;命令校验仍由 editing 实现,select 的类型断言不是运行时 schema 验证。 // 颜色输入用现有值或默认色;字号仅提交8—160范围,透明背景在取色器中显示白色但不自动写回模型。 // 加粗/斜体/下划线按当前值取反,对齐和行高直接提交所选值;清除背景明确写transparent。 // 常规事件停止冒泡;Escape只在非组合输入、非文本编辑且未被处理时关闭当前节点的变换模式。 -- 2.52.0 From d2068d416a45805ecb2a3aa9323fcdb99761b610 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:27:01 +0800 Subject: [PATCH 08/55] docs(canvas): explain recovery and import UI lifecycle --- src/components/canvas/draft-recovery.tsx | 1 + src/components/canvas/headless-imports.tsx | 4 ++++ src/components/canvas/headless-recovery-dialog.tsx | 4 ++++ src/components/canvas/legacy-canvas-migration.tsx | 4 ++++ 4 files changed, 13 insertions(+) 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/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