diff --git a/src/lib/canvas-elements.ts b/src/lib/canvas-elements.ts index 8782ada..42c7602 100644 --- a/src/lib/canvas-elements.ts +++ b/src/lib/canvas-elements.ts @@ -1,12 +1,20 @@ +/** + * 旧画布的元素创建类体系。类只提供内置类型、默认样式/尺寸和能力,不持有editor或DOM, + * 实际视图通过组合使用能力;新版可扩展registry与这些旧内置class并非同一个类型系统。 + */ import type { Node, XYPosition } from "@xyflow/react"; import type { ElementKind, CanvasNodeData } from "./canvas-bindings/compat-types"; export type {ElementKind,FrameLabelMode,TextStyle,CanvasNodeData} from "./canvas-bindings/compat-types"; +/** 旧React Flow节点加本地pendingUpload标记;该标记在snapshot保存前会被过滤。 */ export type CanvasNode = Node & { pendingUpload?: string }; +/** 旧UI四项能力声明:缩放、旋转、连线、分支;实际组件借用它,不继承React组件类。 */ export type ElementCapabilities = { resizable: boolean; rotatable: boolean; connectable: boolean; branchable: boolean }; /** Business inheritance only: React views reuse these capabilities by composition. */ +/** 所有旧元素的业务基类,默认240×72和plainText视图;子类声明kind/title并覆盖特定默认值。 */ export abstract class CanvasElement { + // 基类可缩放/旋转但不连线/分支;静态能力由类表读取,不为每个节点重复创建行为对象。 static readonly capabilities: ElementCapabilities = { resizable: true, rotatable: true, connectable: false, branchable: false }; abstract readonly kind: ElementKind; abstract readonly title: string; @@ -15,11 +23,13 @@ export abstract class CanvasElement { readonly position: XYPosition; readonly text: string; readonly src?: string; + /** 构造业务创建参数;position在toNode输出时复制,尚未创建节点身份或渲染对象。 */ constructor(position: XYPosition, text = "", src?: string) { this.position = position; this.text = text; this.src = src; } + /** 每次转换都生成新UUID与独立position/data/style,字体默认随plain-text与卡片类型选择。 */ toNode(): CanvasNode { return { id: crypto.randomUUID(), type: this.nodeType, position: { ...this.position }, @@ -30,42 +40,55 @@ export abstract class CanvasElement { }; } } +/** 文本/图片/脚本共享卡片视图、260×200尺寸和连线/分支能力。 */ export abstract class CardElement extends CanvasElement { static override readonly capabilities: ElementCapabilities = { ...CanvasElement.capabilities, connectable: true, branchable: true }; protected override readonly nodeType = "material" as const; protected override readonly size = { width: 260, height: 200 }; } +/** 文本卡片,只覆盖内容类型和默认标题,其余沿用CardElement。 */ export class TextCardElement extends CardElement { readonly kind = "text"; readonly title = "灵感笔记"; } +/** 图片卡片,与纯图片的不同是卡片布局及可分支能力。 */ export class ImageCardElement extends CardElement { readonly kind = "image"; readonly title = "视觉参考"; } +/** 脚本卡片,数据类型不同但复用同一卡片行为。 */ export class ScriptCardElement extends CardElement { readonly kind = "script"; readonly title = "脚本"; } +/** 无卡片外壳的文字元素,沿用基类尺寸和不连线能力。 */ export class PlainTextElement extends CanvasElement { readonly kind = "plain-text"; readonly title = "纯文本"; } +/** 图标用80×80图形视图,首次创建默认Star;具体SVG由图标加载器另读。 */ export class IconElement extends CanvasElement { readonly kind = "icon"; readonly title = "图标"; protected override readonly nodeType = "icon" as const; protected override readonly size = { width: 80, height: 80 }; + /** 先用基类分配身份/几何,再补初始图标字段,不共享已创建节点对象。 */ override toNode(): CanvasNode { const node = super.toNode(); return { ...node, data: { ...node.data, icon: "Star" } }; } } +/** 普通旧分区框640×400,不可旋转;子图入口/端口由其它流程按schema v2赋予。 */ export class FrameElement extends CanvasElement { static override readonly capabilities: ElementCapabilities = { ...CanvasElement.capabilities, rotatable: false }; readonly kind = "frame"; readonly title = "未命名分区"; protected override readonly nodeType = "frame" as const; protected override readonly size = { width: 640, height: 400 }; + /** 分区置底且内容区不截获子元素指针,标题默认固定显示;这不是新建子board操作。 */ override toNode(): CanvasNode { const node = super.toNode(); return { ...node, zIndex: -1, style: { ...node.style, pointerEvents: "none" }, data: { ...node.data, labelMode: "fixed" } }; } } +/** 无卡片外壳的320×240图片,是否按原图比例调整由解码/创建尺寸流程负责。 */ export class PlainImageElement extends CanvasElement { readonly kind = "plain-image"; readonly title = "图片"; protected override readonly nodeType = "plainImage" as const; protected override readonly size = { width: 320, height: 240 }; } +/** 封闭内置kind→构造器表,是旧元素工厂入口,不接受任意未知kind动态执行。 */ export const elementClasses = { text: TextCardElement, image: ImageCardElement, script: ScriptCardElement, "plain-text": PlainTextElement, icon: IconElement, frame: FrameElement, "plain-image": PlainImageElement }; +/** 按kind读取静态能力,调用方需先保证kind属于内置枚举。 */ export function elementCapabilities(kind: ElementKind) { return elementClasses[kind].capabilities; } +/** 创建指定内置类型的空内容节点,src仅作为创建参数传入;不自动上传文件或登记history。 */ export function createCanvasElement(kind: ElementKind, position: XYPosition, src?: string): CanvasNode { return new elementClasses[kind](position, "", src).toNode(); } diff --git a/src/lib/canvas-frames.ts b/src/lib/canvas-frames.ts index 0472eaf..ca24733 100644 --- a/src/lib/canvas-frames.ts +++ b/src/lib/canvas-frames.ts @@ -1,9 +1,14 @@ +/** + * 旧平面frameId成员关系的几何规则。内容放下才按中心点重新归组,分区移动带动成员,缩放不自动重分组。 + * 这是单board的旧分区外壳逻辑,与schema v2子board树和新版ownerCanvasId层级分别处理。 + */ import type { NodeChange } from "@xyflow/react"; import { applyNodeChanges } from "@xyflow/react"; import type { CanvasNode } from "./canvas-elements.ts"; import { nodeCenter, nodeSize } from "./canvas-geometry.ts"; /** Membership changes only on content drops, never on frame movement or resize. */ +/** 非frame节点按中心点找包含它的分区,多个重叠候选选面积最小者;frame本身不成为另一frame成员。 */ export function containingFrame(node: CanvasNode, nodes: CanvasNode[]) { if (node.data.kind === "frame") return undefined; const center = nodeCenter(node); @@ -14,6 +19,7 @@ export function containingFrame(node: CanvasNode, nodes: CanvasNode[]) { }).sort((a, b) => nodeSize(a).width * nodeSize(a).height - nodeSize(b).width * nodeSize(b).height)[0]; } +/** 纯计算选中内容的from/to反馈,尚未改frameId;用于拖动结束时判断归属变动。 */ export function frameDropFeedback(nodes: CanvasNode[], droppedIds: string[]) { const ids = new Set(droppedIds); return nodes.filter((node) => ids.has(node.id) && node.data.kind !== "frame").map((node) => ({ @@ -21,6 +27,7 @@ export function frameDropFeedback(nodes: CanvasNode[], droppedIds: string[]) { })); } +/** 仅改droppedIds中非frame节点,归属没变复用原引用,移出所有frame时删除frameId字段。 */ export function assignFrameMembership(nodes: CanvasNode[], droppedIds: string[]): CanvasNode[] { const dropped = new Set(droppedIds); return nodes.map((node) => { @@ -32,6 +39,8 @@ export function assignFrameMembership(nodes: CanvasNode[], droppedIds: string[]) }); } +/** 先用React Flow归约节点变更,再按旧成员关系传播分区平移。 + * 同时明确移动的子节点不再叠加父位移,防选中父子一起拖时移动两次。 */ export function applyFrameNodeChanges(changes: NodeChange[], previous: CanvasNode[]) { const nodes = applyNodeChanges(changes, previous); const before = new Map(previous.map((node) => [node.id, node])); @@ -41,10 +50,12 @@ export function applyFrameNodeChanges(changes: NodeChange[], previou const frameId = node.data.frameId; if (!frameId) return node; const frame = frames.get(frameId); + // frame被删除只撤销成员标记,不顺带删除内容节点。 if (!frame) { const { frameId: _old, ...data } = node.data; return { ...node, data }; } + // 只有分区收到明确position变化才传播;resize或新建分区不移动成员。 const oldFrame = before.get(frameId); if (!oldFrame || !positioned.has(frameId) || positioned.has(node.id)) return node; return { ...node, position: { x: node.position.x + frame.position.x - oldFrame.position.x, y: node.position.y + frame.position.y - oldFrame.position.y } }; diff --git a/src/lib/canvas-subgraphs.ts b/src/lib/canvas-subgraphs.ts index 111c40b..5d20707 100644 --- a/src/lib/canvas-subgraphs.ts +++ b/src/lib/canvas-subgraphs.ts @@ -1,12 +1,19 @@ +/** + * 旧跨board子图的父链与事务范围规划。共同祖先拥有跨分区连线,端点逐层通过分区端口向上暴露。 + * 这里只计算读写参与者、路径与请求描述,不创建元素/端口或发网络;调用方负责加载足够的文档。 + */ import type { CanvasDocument } from "./canvas.ts"; +/** 从入口节点建立子board→父board/frame索引;重复子ID后项覆盖,不替代单父归属的独立校验。 */ export function subgraphParents(documents: Map) { const parents = new Map(); for (const [boardId, document] of documents) for (const node of document.nodes) if (node.data.subgraph) parents.set(node.data.subgraph.boardId, { boardId, frameId: node.id }); return parents; } +/** 移动至少涉及parent/source/target,选中入口还纳入其后代;祖先端口引用受影响时再纳入上一层owner。 */ export function subgraphMoveParticipants(documents: Map, parent: string, source: string, target: string, ids: string[]) { const participants = new Set([parent, source, target]); const parents = subgraphParents(documents); + /** 递归收集选中入口后代,重复访问报错,防循环或重复树引用被误当作独立分支。 */ const addDescendants = (board: string, seen = new Set()) => { if (seen.has(board)) throw new Error("subgraph_cycle"); seen.add(board); participants.add(board); for (const node of documents.get(board)?.nodes || []) if (node.data.subgraph) addDescendants(node.data.subgraph.boardId, seen); @@ -24,8 +31,10 @@ export function subgraphMoveParticipants(documents: Map, } return [...participants]; } +/** 沿source上行到最近共同祖先,再下行到target,返回逐层父子移动描述;无共同根拒绝。 */ export function subgraphMovePath(documents: Map, source: string, target: string) { 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; }; const from = chain(source), to = chain(target), common = from.find((id) => to.includes(id)); if (!common) throw new Error("ownership_conflict"); @@ -34,11 +43,14 @@ export function subgraphMovePath(documents: Map, source: for (let i = to.indexOf(common) - 1; i >= 0; i--) { const relation = parents.get(to[i])!; path.push({ parent: relation.boardId, frame: relation.frameId, source: relation.boardId, target: to[i] }); } return path; } +/** 计算连线共同owner及其可引用的两端;每跨一层必须已有同方向匹配端口,不在此暗自补端口。 */ export function exposedConnection(documents: Map, source: { boardId: string; id: string; handle?: string | null }, target: { boardId: string; id: string; handle?: string | null }) { const parents = subgraphParents(documents); + /** 生成自身到根的链,最先共同项就是当前source/target的最近共同祖先。 */ const ancestors = (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 owner = ancestors(source.boardId).find((id) => ancestors(target.boardId).includes(id)); if (!owner) throw new Error("ownership_conflict"); + /** 从内部node/handle逐层换成入口frame/port:id,保持源output、目标input方向。 */ const expose = (endpoint: typeof source, direction: "input" | "output") => { let { boardId, id, handle } = endpoint; while (boardId !== owner) { @@ -52,8 +64,10 @@ export function exposedConnection(documents: Map, source }; return { boardId: owner, source: expose(source, "output"), target: expose(target, "input") }; } +/** 给服务端连接事务的原始端点描述与完整父链参与集;真正创建端口/边由事务执行层处理。 */ export function connectionTransactionPlan(documents: Map, source: { boardId: string; id: string; handle: string }, target: { boardId: string; id: string; handle: string }) { const parents = subgraphParents(documents); + /** 与其它父链查询一致地防循环,不因两点在不同子board就当成无共同owner。 */ 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"); diff --git a/src/lib/canvas-sync.ts b/src/lib/canvas-sync.ts index 47c1287..ab2a2a4 100644 --- a/src/lib/canvas-sync.ts +++ b/src/lib/canvas-sync.ts @@ -1,23 +1,38 @@ +/** + * 旧画布的完整快照保存与本地草稿恢复。一个实例串行PATCH一张board,pending只保留最近一次待保存快照, + * If-Match限制覆盖基线;失败停住,等待显式retry/adopt。它没有新版ledger的冻结请求身份/回执恢复保证。 + * 序列化会去除上传未完成节点及其边,并重建允许字段,避免把React Flow测量/选择状态发给旧接口。 + */ import { CANVAS_ACCEPT } from "./canvas-v2.ts"; import { decodeCanvas, type CanvasDocument } from "./canvas.ts"; +/** 服务端快照版本与旧格式文档;version是写入并发基线,不是schema_version。 */ export type CanvasSnapshot = { version: number; document: CanvasDocument }; +/** 宿主注入的通用HTTP请求端口,具体凭证、异常和取消由API封装负责。 */ export type CanvasRequest = (path: string, options?: RequestInit) => Promise; +/** 保存状态通知供界面显示;saved只代表本writer内容保存判断,不代表所有任务或资源均已完成。 */ export type SyncState = { status: "saved" | "saving" | "error"; version: number; error?: string }; +/** 编码工作区/board引用,生成旧snapshot读写端点。 */ export function canvasPath(workspace: string, board: string) { return `workspaces/${encodeURIComponent(workspace)}/materials/${encodeURIComponent(board)}/canvas`; } +/** 去除pendingUpload节点及连到它们的边,再经过decodeCanvas规范化;显式只输出节点持久字段。 + * pending图片仍可在本地显示,但不会以未完成资源引用覆盖服务端文档。 */ export function canvasPayload(document: CanvasDocument) { const pending = new Set(document.nodes.filter((node) => node.pendingUpload).map((node) => node.id)); const clean = decodeCanvas(JSON.stringify({ ...document, nodes: document.nodes.filter((node) => !pending.has(node.id)), edges: document.edges.filter((edge) => !pending.has(edge.source) && !pending.has(edge.target)) })); return { ...clean, nodes: clean.nodes.map(({ id, type, position, width, height, data }) => ({ id, type, position, ...(width !== undefined ? { width, height } : {}), data })) }; } +/** 对规范payload对象键排序后序列化,用于内容相等判断;includeViewport=false忽略纯视口差异。 + * 它不是密码学摘要,数组顺序仍有意义。 */ export function canvasFingerprint(document: CanvasDocument, includeViewport = true) { const { viewport, ...content } = canvasPayload(document); return JSON.stringify(includeViewport ? { ...content, viewport } : content, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value); } +/** 开发诊断用差异摘要,按节点字段报告增改删,边整体比较;不作为发送到服务端的增量协议。 */ export function canvasChanges(before: CanvasDocument, after: CanvasDocument) { const a = canvasPayload(before), b = canvasPayload(after); + /** 此诊断按JSON表示比较,正式脏判断另用稳定对象键的fingerprint。 */ const changed = (left: unknown, right: unknown) => JSON.stringify(left) !== JSON.stringify(right); return { viewport: changed(a.viewport, b.viewport), @@ -32,42 +47,55 @@ export function canvasChanges(before: CanvasDocument, after: CanvasDocument) { edges: changed(a.edges, b.edges), }; } +/** 校验服务器正整数版本并重解码文档,不能仅凭类型断言信任网络快照。 */ export function decodeSnapshot(value: CanvasSnapshot): CanvasSnapshot { if (!Number.isSafeInteger(value?.version) || value.version < 1) throw new Error("invalid_canvas_snapshot"); return { version: value.version, document: decodeCanvas(JSON.stringify(value.document)) }; } /** One writer per board. Coalesce pending snapshots; stop on any rejected write. */ +/** 单board旧写者。document/saved是最近确认基线,pending是可被后续enqueue替换的等待槽, + * running最多一个drain。不要将这里的快照合并理解成保留每项操作因果证据的可靠队列。 */ export class CanvasSync { + // version用于下一次If-Match;saved含viewport,savedContent专供忽略viewport的needsSave。 version: number; saved: string; private savedContent: string; error = ""; document: CanvasDocument; + // 在途请求不被新enqueue替换;只合并尚未发送的后续快照,失败时保留更新的pending优先。 private pending: CanvasDocument | null = null; private running: Promise | null = null; private request: CanvasRequest; private path: string; private notify: (state: SyncState) => void; + /** 接受已解码初始快照,建立两种指纹与状态回调;构造时不启动保存。 */ 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 { 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); }); return this.running; } + /** 清除错误并重试当前pending快照;不查询旧回执,也不更换版本,基线冲突仍由服务端拒绝。 */ retry() { this.error = ""; return this.pending ? this.enqueue(this.pending) : Promise.resolve(); } + /** 等调用时的running结束;不是持续追踪未来enqueue或全项目同步完成的屏障。 */ async settled() { await this.running; } + /** 仅在没有在途写时显式接纳另一个快照,清空pending/error并重置版本/指纹。 */ adopt(snapshot: CanvasSnapshot) { if (this.running) throw new Error("canvas_busy"); this.pending = null; this.error = ""; this.version = snapshot.version; this.document = snapshot.document; this.saved = canvasFingerprint(snapshot.document); this.savedContent = canvasFingerprint(snapshot.document, false); this.notify({ status: "saved", version: this.version }); } + /** 串行消费等待快照,跳过无持久内容变化者;按当前version条件PATCH,回执必须恰好+1。 + * 任意失败都停止,保留后来更近的pending;此处不自动合并远端冲突或推断请求是否已经生效。 */ private async drain() { while (this.pending) { const document = this.pending; this.pending = null; @@ -76,14 +104,17 @@ export class CanvasSync { const fingerprint = canvasFingerprint(document); if (!this.needsSave(document)) { this.notify({ status: "saved", version: this.version }); continue; } if (process.env.NODE_ENV === "development") console.info("[canvas-dirty]", JSON.stringify({ resource: this.path, ...canvasChanges(this.document, document) })); + // 仍发送整文档,限制5MiB;上方canvasChanges只是日志,不会把请求变成字段补丁。 const body = JSON.stringify({ document: canvasPayload(document) }); if (new TextEncoder().encode(body).length > 5 * 1024 * 1024) throw new Error("canvas_too_large"); 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供下一轮处理。 this.version = result.version; this.saved = fingerprint; this.savedContent = canvasFingerprint(document, false); this.document = canvasPayload(document); this.notify({ status: "saved", version: this.version }); } catch (error) { + // 已有更新快照时保留它,否则把失败快照放回;错误不伪造成功版本。 this.pending ||= document; this.error = error instanceof Error ? error.message : "request_failed"; this.notify({ status: "error", version: this.version, error: this.error }); @@ -93,6 +124,8 @@ export class CanvasSync { } } +/** 优先读取带dirty标记的新草稿记录,无记录才兼容旧键;忽略只改viewport或与远端相同的内容。 + * 格式错误继续抛出,不将损坏草稿当作空白文档自动覆盖。 */ export function readLocalRecovery(storage: Pick, key: string, snapshot?: CanvasSnapshot): CanvasDocument | null { const record = storage.getItem(key + ":recovery"); if (record) { @@ -106,6 +139,8 @@ export function readLocalRecovery(storage: Pick, key: string const doc = decodeCanvas(legacy); return !snapshot || canvasFingerprint(doc, false) !== canvasFingerprint(snapshot.document, false) ? doc : null; } +/** 恢复旧草稿前检查schema不倒退,且分区入口/端口关系与远端一致。 + * 只是必要的局部条件,不证明整棵跨board树可原子恢复。 */ 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)); diff --git a/src/lib/canvas-tree.ts b/src/lib/canvas-tree.ts index 402e881..7a05f93 100644 --- a/src/lib/canvas-tree.ts +++ b/src/lib/canvas-tree.ts @@ -1,23 +1,37 @@ +/** + * 旧多board画布树的显示投影、移动参考系和三路合并。每个owner保留独立CanvasDocument, + * 显示ID加board命名空间,投影节点/边不能直接回写为owner快照。分区展开/折叠通过旧端口映射呈现。 + */ import { nodeSize } from "./canvas-geometry.ts"; import type { CanvasDocument, CanvasNode } from "./canvas.ts"; import type { CanvasEdgeModel, EdgeData } from "./canvas-v2.ts"; +/** 显示ID到真实board/元素ID的映射与平移/缩放参考系;旧协议不在此表达父级旋转矩阵。 */ export type CanvasReference = { boardId: string; id: string; origin: { x: number; y: number }; scale: number; parentBoard?: string; parentFrame?: string }; +/** 已读取的各owner文档表,缺失子board不会凭空创建空文档。 */ 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,避免调用方修改投影时改变原始对象。 */ export function transformEdgeData(data: EdgeData | undefined, reference: CanvasReference, inverse = false): EdgeData | undefined { if (!data?.path) return data ? structuredClone(data) : undefined; const { origin, scale } = reference; + /** vector=true排除origin偏移,不能把切线当绝对点从而重复平移。 */ const point = (point: { x: number; y: number }, vector = false) => inverse ? { x: (point.x - (vector ? 0 : origin.x)) / scale, y: (point.y - (vector ? 0 : origin.y)) / scale } : { x: point.x * scale + (vector ? 0 : origin.x), y: point.y * scale + (vector ? 0 : origin.y) }; return { ...data, path: { ...data.path, ...(data.path.startControl ? { startControl: point(data.path.startControl, true) } : {}), ...(data.path.endControl ? { endControl: point(data.path.endControl, true) } : {}), knots: data.path.knots.map((knot) => ({ ...knot, position: point(knot.position), in: point(knot.in, true), out: point(knot.out, true) })) } }; } /** Projection is disposable view data. Only owner documents are ever serialized. */ +/** 从root递归展开已加载且未折叠的子图,输出显示对象、反向引用和board参考系。 + * 先完成可见节点,再处理暂存边,端口递归可映射到真正显示的内层节点。 */ export function projectCanvasTree(root: string, documents: CanvasDocuments) { const nodeRefs = new Map(), edgeRefs = new Map(); const nodes: CanvasNode[] = [], edges: CanvasEdgeModel[] = []; const boards = new Map(); + // 这里pending只是本次投影中延后处理的边,不是网络同步队列。 const pending: { edge: CanvasEdgeModel; ref: CanvasReference }[] = []; + /** 用祖先集合防循环并限制深度;子内容原点固定偏移24/36,contentScale乘父显示比例。 */ function visit(boardId: string, origin: { x: number; y: number }, scale: number, ancestors: Set, parentBoard?: string, parentFrame?: string) { if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle"); const document = documents.get(boardId); @@ -29,6 +43,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) { nodeRefs.set(id, { ...reference, id: node.id }); const width = nodeSize(node).width * scale, height = nodeSize(node).height * scale; const position = { x: origin.x + node.position.x * scale, y: origin.y + node.position.y * scale }; + // 只改投影id/尺寸/frameId/viewScale,nodeRefs保留真实owner身份供后续命令路由。 nodes.push({ ...node, id, position, width, height, style: { ...node.style, width, height }, data: { ...node.data, ...(parentBoard && parentFrame ? { frameId: displayID(root, parentBoard, parentFrame) } : {}), viewScale: scale } }); if (node.data.subgraph && !node.data.presentation?.collapsed) visit(node.data.subgraph.boardId, { x: position.x + 24 * scale, y: position.y + 36 * scale }, scale * (node.data.presentation?.contentScale || 1), next, boardId, node.id); @@ -37,6 +52,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) { } visit(root, { x: 0, y: 0 }, 1, new Set()); 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 } { const id = displayID(root, board, nodeId); if (depth > 8) return { id, handle }; @@ -48,6 +64,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) { } return { id, handle }; } + // 边引用仍指回原owner文档,显示端点可以在另一展开分区内,数据路径按本边owner变换。 for (const { edge, ref } of pending) { const id = displayID(root, ref.boardId, edge.id); edgeRefs.set(id, { ...ref, id: edge.id }); const source = endpoint(ref.boardId, edge.source, edge.sourceHandle), target = endpoint(ref.boardId, edge.target, edge.targetHandle); @@ -56,6 +73,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) { return { nodes, edges, nodeRefs, edgeRefs, boards }; } /** 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 { const reference = boards.get(boardId); if (!reference) throw new Error("invalid_reference"); @@ -67,10 +85,14 @@ export function referenceAfterMoves(root: string, documents: CanvasDocuments, bo return { ...reference, origin: { x: position.x + 24 * parent.scale, y: position.y + 36 * parent.scale } }; } +/** 三路合并的稳定键JSON比较,数组顺序仍参与比较;只供旧格式JSON风格数据使用。 */ const equal = (a: unknown, b: unknown) => JSON.stringify(a, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value) === JSON.stringify(b, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value); /** Three-way merge preserves local geometry while applying server AI output. */ +/** base/local/remote三路字段合并:一侧未改采用另一侧,两侧同值也接受,不兼容双改明确冲突。 + * nodes/edges按ID合并,框架瞬态字段先剥离;viewport与选择最终取本地,绝非远端整快照覆盖。 */ 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; @@ -80,15 +102,18 @@ export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument } throw new Error("version_conflict"); }; + /** ID集合以remote序后补local新项,缺项参与三路删除判断,删除与另一侧修改可能冲突。 */ function collection(base: T[], local: T[], remote: T[]): T[] { const b = new Map(base.map((v) => [v.id, v])), l = new Map(local.map((v) => [v.id, v])), r = new Map(remote.map((v) => [v.id, v])); return [...new Set([...r.keys(), ...l.keys()])].map((id) => merge(b.get(id), l.get(id), r.get(id))).filter((v) => v !== undefined) as T[]; } + /** 排除选择/拖动/测量/style这些框架字段,它们不该引发业务字段合并冲突。 */ const strip = (value: T) => { const { selected, dragging, measured, style, ...clean } = value; return clean; }; 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[], edges: collection(base.edges.map(strip), local.edges.map(strip), remote.edges.map(strip)) as CanvasEdgeModel[], viewport: local.viewport, }; + // 合并后重新生成样式与frame层序,恢复本地选择;不继承远端React Flow临时测量。 return { ...result, nodes: result.nodes.map((node) => ({ ...node, selected: local.nodes.find((item) => item.id === node.id)?.selected, style: { width: node.width, height: node.height, ...(node.data.kind === "frame" ? { pointerEvents: "none" } : {}) }, ...(node.data.kind === "frame" ? { zIndex: -1 } : {}) })) }; } diff --git a/src/lib/canvas-v2.ts b/src/lib/canvas-v2.ts index 7e7bde0..84d199c 100644 --- a/src/lib/canvas-v2.ts +++ b/src/lib/canvas-v2.ts @@ -1,23 +1,40 @@ +/** + * 旧CanvasDocument schema v2的扩展字段校验与兼容类型入口。 + * 处理边样式/贝塞尔链、AI配置、素材ID、子图端口和分区展示;版本2并非新版canonical documentFormatVersion。 + */ import type { Edge, XYPosition } from "@xyflow/react"; import type {EdgeData,BezierKnot,AIConfig,FramePort,FramePresentation} from "./canvas-bindings/compat-types"; export type {MarkerShape,EdgeMarker,BezierKnot,EdgeData,AIConfig,FramePort,FramePresentation} from "./canvas-bindings/compat-types"; +/** 旧连线使用React Flow Edge外壳,持久data限制为兼容EdgeData。 */ export type CanvasEdgeModel = Edge; +/** 请求旧快照接口使用的媒体类型,避免服务端用默认旧表示丢掉v2扩展字段。 */ export const CANVAS_ACCEPT = "application/vnd.foundry.canvas+json;version=2"; +/** 旧节点/端口ID的短字符串语法,独立于服务端board/resource使用的UUID。 */ export const wireID = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); +/** 包含端点的有限数值范围检查,拒绝NaN与Infinity。 */ export const numberIn = (value: unknown, min: number, max: number): value is number => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max; +/** 位置/控制向量各轴限定在±100万,调用路径决定它表示绝对点还是向量。 */ export const pointValid = (value: unknown): value is XYPosition => !!value && typeof value === "object" && numberIn((value as XYPosition).x, -1e6, 1e6) && numberIn((value as XYPosition).y, -1e6, 1e6); +/** 只允许已知自有键的对象外壳;必填字段与每字段值需由调用处另外校验。 */ 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,不返回半份有效扩展。 */ function check(value: boolean) { if (!value) throw new Error("invalid_canvas"); } +/** 字段缺失可通过,字段存在时必须满足校验;存在但undefined不等同于缺失。 */ 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); +/** 颜色仅接六位十六进制,不能把任意CSS表达式带入旧边样式。 */ const color = (value: unknown) => typeof value === "string" && /^#[a-f\d]{6}$/i.test(value); +/** 可选开关仍需真实boolean,不把字符串false当作关闭。 */ const bool = (value: unknown) => typeof value === "boolean"; +/** 验证已知边扩展并structuredClone,与输入断开引用;undefined保留为无扩展。 */ export function decodeEdgeData(value: unknown): EdgeData | undefined { if (value === undefined) return undefined; check(fields(value, ["appearance", "animation", "path", "execution"])); const data = value as Record; + // 限制线宽/透明度/箭头形状尺寸;样式不是直接透传任意SVG属性。 if (data.appearance !== undefined) { check(fields(data.appearance, ["color", "width", "opacity", "line", "head", "tail"])); const a = data.appearance as Record; optional(a, "color", color); optional(a, "width", (v) => numberIn(v, .5, 20)); optional(a, "opacity", (v) => numberIn(v, .05, 1)); optional(a, "line", choice("solid", "dashed", "dotted")); @@ -31,6 +48,7 @@ export function decodeEdgeData(value: unknown): EdgeData | undefined { optional(a, "mode", choice("off", "always", "executing")); optional(a, "effect", choice("flow", "dash", "pulse")); optional(a, "direction", choice("forward", "reverse")); optional(a, "durationMs", (v) => numberIn(v, 250, 30000)); optional(a, "color", color); } if (data.execution !== undefined) { check(fields(data.execution, ["enabled"])); optional(data.execution as Record, "enabled", bool); } + // 贝塞尔链最多64个结点,身份唯一、各点有界;不只检查path.type就信任控制数据。 if (data.path !== undefined) { check(fields(data.path, ["type", "startControl", "endControl", "knots"])); const path = data.path as Record; check(path.type === "bezier-chain" && Array.isArray(path.knots) && path.knots.length <= 64); @@ -38,6 +56,7 @@ export function decodeEdgeData(value: unknown): EdgeData | undefined { const ids = new Set(); for (const knot of path.knots as BezierKnot[]) { check(fields(knot, ["id", "position", "in", "out", "mode"]) && wireID(knot.id) && !ids.has(knot.id) && pointValid(knot.position) && pointValid(knot.in) && pointValid(knot.out) && choice("corner", "smooth", "symmetric")(knot.mode)); ids.add(knot.id); + // smooth要求切线反向共线,symmetric还要求两侧等长,允许零长度向量的退化情形。 if (knot.mode !== "corner") { const a = Math.hypot(knot.in.x, knot.in.y), b = Math.hypot(knot.out.x, knot.out.y); if (a && b) check(Math.abs(knot.in.x / a * knot.out.y / b - knot.in.y / a * knot.out.x / b) <= 1e-6 && knot.in.x / a * knot.out.x / b + knot.in.y / a * knot.out.y / b <= 0); @@ -47,10 +66,12 @@ export function decodeEdgeData(value: unknown): EdgeData | undefined { } return structuredClone(value) as EdgeData; } +/** 仅schema2允许这些扩展,且能力受kind限制:图片资源ID、可生成节点AI、frame子图/端口/展示。 */ export function decodeNodeExtensions(data: Record, schema: number) { const extensions: { ai?: AIConfig; assetId?: string; subgraph?: { boardId: string }; ports?: FramePort[]; presentation?: FramePresentation } = {}; for (const key of ["ai", "assetId", "subgraph", "ports", "presentation"]) if (key in data) check(schema === 2); if (data.assetId !== undefined) { check(["image", "plain-image"].includes(String(data.kind)) && typeof data.assetId === "string" && /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(data.assetId)); extensions.assetId = data.assetId as string; } + // AI提示按Unicode码点计长,parameters限制JSON序列化字节;这是配置,不是生成任务受理记录。 if (data.ai !== undefined) { 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); @@ -59,11 +80,13 @@ export function decodeNodeExtensions(data: Record, schema: numb } if (["subgraph", "ports", "presentation"].some((key) => key in data)) check(data.kind === "frame"); if (data.subgraph !== undefined) { check(fields(data.subgraph, ["boardId"]) && typeof data.subgraph.boardId === "string" && /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(data.subgraph.boardId)); extensions.subgraph = { boardId: (data.subgraph as { boardId: string }).boardId }; } + // 分区端口限定方向、唯一短ID和内部node/handle引用形状;跨board引用是否存在由树/事务流程校验。 if (data.ports !== undefined) { check(Array.isArray(data.ports) && data.ports.length <= 4000); const ids = new Set(); for (const port of data.ports as FramePort[]) { check(fields(port, ["id", "direction", "nodeId", "handle"]) && wireID(port.id) && !ids.has(port.id) && wireID(port.nodeId) && choice("input", "output")(port.direction) && typeof port.handle === "string" && (/^(top|right|bottom|left)$/.test(port.handle) || /^port:[A-Za-z0-9_-]{1,128}$/.test(port.handle))); ids.add(port.id); } extensions.ports = structuredClone(data.ports) as FramePort[]; } + // 折叠状态、内容比例和展开/折叠尺寸分别验证;不据此自动创建或迁移子图。 if (data.presentation !== undefined) { check(fields(data.presentation, ["collapsed", "contentScale", "expandedSize", "collapsedSize"])); const p = data.presentation as Record; optional(p, "collapsed", bool); optional(p, "contentScale", (v) => numberIn(v, .05, 4)); @@ -72,6 +95,8 @@ export function decodeNodeExtensions(data: Record, schema: numb } return extensions; } +/** 只把固定Foundry项目的规范素材路径映射为同源代理,可选preview; + * 内联、demo或其它地址原样返回,不把任意HTTPS资源都伪装成受管资源。 */ export function managedImageSource(src?: string, preview = false) { if (!src) return src; try { const url = new URL(src); const match = url.pathname.match(/^\/projects\/endoleap\/(workspaces\/[A-Za-z0-9_-]+\/assets\/[A-Za-z0-9_-]+)$/); diff --git a/src/lib/canvas.ts b/src/lib/canvas.ts index d026e6d..5b9cb1e 100644 --- a/src/lib/canvas.ts +++ b/src/lib/canvas.ts @@ -1,3 +1,8 @@ +/** + * 旧snapshot编辑器的文档、快照history reducer与解码入口。history保存整份文档,最多40个past项; + * 它与新版按changes/目标代次记录的history分开保留。交互过程可先checkpoint,再连续更新present。 + * 解码重建允许的旧节点/边字段,去掉框架瞬态信息;不接受任意未知组件或新版canonical模型。 + */ import { decodeEdgeData, decodeNodeExtensions, type CanvasEdgeModel } from "./canvas-v2.ts"; import { applyEdgeChanges } from "@xyflow/react"; import type { Edge, EdgeChange, NodeChange, Viewport, XYPosition } from "@xyflow/react"; @@ -6,10 +11,15 @@ import { applyFrameNodeChanges, assignFrameMembership } from "./canvas-frames.ts import { createCanvasElement, elementCapabilities, type CanvasNode } from "./canvas-elements.ts"; import { nodeSize, rotateVector, nodeCenter, type NodeTransform } from "./canvas-geometry.ts"; export type { CanvasNode } from "./canvas-elements.ts"; +/** 旧节点的四向连线/分支端口名。 */ export type NodeDirection = "top" | "right" | "bottom" | "left"; +/** 方向白名单,导入连线handle也通过此表校验。 */ export const nodeDirections: NodeDirection[] = ["top", "right", "bottom", "left"]; +/** 旧持久文档由节点、边数组与viewport构成,schema_version与网络保存version不同。 */ export type CanvasDocument = { schema_version: 1 | 2; nodes: CanvasNode[]; edges: CanvasEdgeModel[]; viewport: Viewport }; +/** past末项用于undo,future首项用于redo,present为当前文档;不是网络待发送操作队列。 */ export type CanvasHistory = { past: CanvasDocument[]; present: CanvasDocument; future: CanvasDocument[] }; +/** 旧UI动作联合:结构编辑/检查点记录历史,部分连续输入与视口动作只更新present。 */ export type CanvasAction = | { type: "checkpoint" } | { type: "node-data"; id: string; patch: Partial } @@ -26,27 +36,35 @@ export type CanvasAction = | { type: "viewport"; viewport: Viewport } | { type: "undo" | "redo" }; +/** 初始空文档仍用schema1,真正使用v2扩展的动作会升级相应文档。 */ export function emptyCanvas(): CanvasDocument { return { schema_version: 1, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }; } +/** 旧本地草稿键按workspace/board定位,不包含新版account/authEpoch作用域。 */ 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) { 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": { const present: CanvasDocument = { ...state.present, schema_version: 2, edges: state.present.edges.map((edge) => edge.id === action.id ? { ...edge, data: action.data } : edge) }; return action.transient ? { ...state, present, future: [] } : recorded(state, present); } + // 放下内容才重新算旧frameId归属;拖动/缩放分区本身不在此改变成员关系。 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不逐帧增加历史项。 case "checkpoint": return recorded(state, state.present); case "replace": return recorded(state, action.document); + // 节点删除同步去掉悬空边;add/remove入栈,选择/位置等框架变化仅更新present。 case "nodes": { const nodes = applyFrameNodeChanges(action.changes, state.present.nodes); const ids = new Set(nodes.map((node) => node.id)); @@ -54,6 +72,7 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva return action.changes.some((change) => change.type === "remove" || change.type === "add") ? recorded(state, present) : { ...state, present }; } + // 节点删除后框架可能再次通知删同一边;过滤已不存在边,避免一次删除消耗两次undo。 case "edges": { // React Flow may report the same edge removal after its node was removed. // Keep that notification from consuming a second undo step. @@ -63,8 +82,10 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva return changes.some((change) => change.type === "remove" || change.type === "add") ? recorded(state, present) : { ...state, present }; } + // 正文连续编辑清redo但不压past,历史边界由调用方的checkpoint/提交动作管理。 case "edit": return { ...state, future: [], present: { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, text: action.text } } : node) } }; 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": { const patch = action.transform; return { ...state, future: [], present: { ...state.present, nodes: state.present.nodes.map((node) => node.id !== action.id ? node : { @@ -77,15 +98,18 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva data: { ...node.data, ...(node.data.kind === "frame" && node.data.subgraph && patch.width !== undefined && patch.height !== undefined ? { presentation: { ...node.data.presentation, [node.data.presentation?.collapsed ? "collapsedSize" : "expandedSize"]: { width: patch.width, height: patch.height } } } : {}), ...(patch.rotation !== undefined ? { rotation: patch.rotation } : {}), ...(patch.fontSize !== undefined ? { fontSize: patch.fontSize } : {}) }, }) } }; } + // 纯镜头变化不入历史,微小数值差忽略,避免无意义根对象更新。 case "viewport": { const before = state.present.viewport; if (Math.abs(before.x - action.viewport.x) < 1e-6 && Math.abs(before.y - action.viewport.y) < 1e-6 && Math.abs(before.zoom - action.viewport.zoom) < 1e-9) return state; return { ...state, present: { ...state.present, viewport: action.viewport } }; } + // 撤销只还原内容,保持用户当前镜头;旧present插到future前端。 case "undo": { const last = state.past.at(-1); return last ? { past: state.past.slice(0, -1), present: { ...last, viewport: state.present.viewport }, future: [state.present, ...state.future] } : state; } + // 从future头取回内容,当前present压past并保持40项限制,镜头同样不跳回。 case "redo": { const next = state.future[0]; return next ? { past: [...state.past, state.present].slice(-40), present: { ...next, viewport: state.present.viewport }, future: state.future.slice(1) } : state; @@ -93,10 +117,13 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva } } +/** 旧内置元素工厂的转接入口,不直接发布历史或保存网络。 */ export function makeCanvasNode(kind: CanvasNode["data"]["kind"], position: XYPosition, src?: string): CanvasNode { return createCanvasElement(kind, position, src); } +/** 给可分支节点创建一张卡片与连接边;按旋转后方向在原节点外围布局,或使用调用方指定位置。 + * 最多500个节点;布局避让最多尝试500次,并非求解保证无重叠的通用排版器。 */ export function branchCanvasNode(document: CanvasDocument, id: string, direction: NodeDirection, kind: "text" | "script" | "image" = "text", src?: string, at?: XYPosition): CanvasDocument { const source = document.nodes.find((node) => node.id === id); if (!source) throw new Error("节点不存在。"); @@ -105,6 +132,7 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction const { width, height } = nodeSize(source); const gap = 80; const vectors = { top: { x: 0, y: -1 }, right: { x: 1, y: 0 }, bottom: { x: 0, y: 1 }, left: { x: -1, y: 0 } }; + // 分支方向先按源节点旋转,位置围绕其中心计算;新卡片按260×200默认尺寸留间距。 const vector = rotateVector(vectors[direction], source.data.rotation || 0); const center = nodeCenter(source); const distance = (direction === "left" || direction === "right" ? width / 2 : height / 2) + gap + Math.abs(vector.x) * 130 + Math.abs(vector.y) * 100; @@ -117,6 +145,7 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction if (direction === "left" || direction === "right") position.y += 240; else position.x += 300; } + // 仅选中新卡片并分配独立边ID;目标handle取旋转后主方向的反侧,源handle仍保留指定方向名。 const child = { ...makeCanvasNode(kind, at || position, src), selected: true }; const opposite: Record = { top: "bottom", bottom: "top", left: "right", right: "left" }; return { @@ -126,12 +155,14 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction }; } +/** 接受有限长度的允许格式base64内联图或无用户名/密码的HTTPS源,拒绝其它scheme。 */ function validImage(src: unknown): src is string { if (typeof src !== "string") return false; 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; } } +/** 旧正文样式的封闭键/值校验,返回复制对象;undefined表示未设置,非法字段不静默放行。 */ export function decodeTextStyle(value: unknown): import("./canvas-elements.ts").TextStyle | undefined { if (value === undefined) return undefined; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("文本样式无效。"); @@ -147,18 +178,22 @@ export function decodeTextStyle(value: unknown): import("./canvas-elements.ts"). if (!valid) throw new Error("文本样式无效。"); return { ...style }; } +/** 先限制UTF-8文件5MiB,再解析旧schema1/2、最多500节点/2000边,重建规范文档。 + * 校验单board内容和其端口外壳,跨board子树/目标存在性需要上层联合加载与事务校验。 */ export function decodeCanvas(raw: string): CanvasDocument { if (new TextEncoder().encode(raw).length > 5 * 1024 * 1024) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。"); 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("不是有效的啡跃图谱文件。"); const ids = new Set(); + // 先校验节点ID、文本/图片、坐标和基础类型;不保留输入的任意type/style/测量字段。 const nodes: CanvasNode[] = value.nodes.map((node: CanvasNode) => { if (!node || typeof node.id !== "string" || !/^[\w-]{1,128}$/.test(node.id) || ids.has(node.id) || !node.position || ![node.position.x, node.position.y].every((n) => Number.isFinite(n) && Math.abs(n) <= 1_000_000) || !node.data || !["text", "image", "script", "plain-text", "icon", "frame", "plain-image"].includes(node.data.kind) || typeof node.data.title !== "string" || [...node.data.title].length > 300 || typeof node.data.text !== "string" || [...node.data.text].length > 100_000 || (["image", "plain-image"].includes(node.data.kind) && !validImage(node.data.src))) throw new Error("图谱节点格式无效。"); + // 兼容旧style尺寸,但宽高必须成对;v2分区可比普通卡片更大,旋转/字体仍有独立范围。 const width = node.width ?? node.style?.width; const height = node.height ?? node.style?.height; if ((width === undefined) !== (height === undefined) || (width !== undefined && (typeof width !== "number" || !Number.isFinite(width) || width < (value.schema_version === 2 && node.data.kind === "frame" ? 32 : 20) || width > (value.schema_version === 2 && node.data.kind === "frame" ? 100000 : 3000))) || @@ -169,6 +204,7 @@ export function decodeCanvas(raw: string): CanvasDocument { if (node.data.kind === "icon" && (typeof node.data.icon !== "string" || !/^[A-Z][A-Za-z0-9]{0,63}$/.test(node.data.icon))) throw new Error("图标标识无效。"); if (node.data.frameId !== undefined && (node.data.kind === "frame" || typeof node.data.frameId !== "string" || !/^[\w-]{1,128}$/.test(node.data.frameId))) throw new Error("分区归属无效。"); if (node.data.kind === "frame" && ((node.data.labelMode !== undefined && !["hidden", "fixed", "scaled"].includes(node.data.labelMode)) || (node.data.rotation || 0) !== 0)) throw new Error("分区设置无效。"); + // 已知样式和v2扩展分别解释,重建时只带认可字段;schema升级不能靠类型断言蒙混。 const textStyle = decodeTextStyle(node.data.textStyle); const extensions = decodeNodeExtensions(node.data, value.schema_version); ids.add(node.id); @@ -182,10 +218,12 @@ export function decodeCanvas(raw: string): CanvasDocument { rotation: node.data.rotation || 0, fontSize: node.data.fontSize || (node.data.kind === "plain-text" ? 24 : 13), ...(["image", "plain-image"].includes(node.data.kind) ? { src: node.data.src } : {}), } }; }); + // 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("分区归属不存在。"); const nodeKinds = new Map(nodes.map((node) => [node.id, node.data.kind])); const edgeIds = new Set(); + // 边身份唯一且两端存在;v2分区必须通过声明的input/output port连接,其它允许节点使用四向handle。 const edges: CanvasEdgeModel[] = value.edges.map((edge: CanvasEdgeModel) => { if (!edge || typeof edge.id !== "string" || !/^[\w-]{1,128}$/.test(edge.id) || edgeIds.has(edge.id) || !ids.has(edge.source) || !ids.has(edge.target)) throw new Error("图谱连线格式无效。"); @@ -200,6 +238,7 @@ export function decodeCanvas(raw: string): CanvasDocument { if (handle != null && !nodeDirections.includes(handle as NodeDirection)) throw new Error("图谱连接方向无效。"); } } + // schema1不能夹带v2边扩展,避免保存后服务端旧表示悄悄丢字段。 const edgeData = decodeEdgeData(edge.data); if (value.schema_version === 1 && edgeData) throw new Error("invalid_canvas"); edgeIds.add(edge.id); @@ -208,6 +247,7 @@ export function decodeCanvas(raw: string): CanvasDocument { ...(edge.targetHandle ? { targetHandle: edge.targetHandle } : {}), }; }); + // 镜头坐标必须有限且缩放0.1到4;返回独立值对象,不复用外部viewport引用。 const viewport = value.viewport; if (!viewport || ![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom < 0.1 || viewport.zoom > 4) throw new Error("图谱视图格式无效。");