/** * 旧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"; 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 } | { type: "edge-data"; id: string; data: import("./canvas-v2").EdgeData; transient?: boolean } | { type: "drop"; ids: string[] } | { type: "rename-node"; id: string; title: string } | { type: "frame"; id: string; title?: string; labelMode?: import("./canvas-elements.ts").FrameLabelMode } | { type: "replace"; document: CanvasDocument } | { type: "nodes"; changes: NodeChange[] } | { type: "edges"; changes: EdgeChange[] } | { type: "edit"; id: string; text: string } | { type: "transform"; id: string; transform: NodeTransform } | { type: "style"; id: string; style: import("./canvas-elements.ts").TextStyle; fontSize?: number } | { 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) { // 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": { 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归属;拖动/缩放分区本身不在此改变成员关系。 // 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不逐帧增加历史项。 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)); const present = { ...state.present, nodes, edges: state.present.edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target)) }; 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. const changes = action.changes.filter((change) => change.type !== "remove" || state.present.edges.some((edge) => edge.id === change.id)); if (!changes.length) return state; const present = { ...state.present, edges: applyEdgeChanges(changes, state.present.edges) }; 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": { // 宽高必须同时给出才更新尺寸;只给单轴时该轴也不生效,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, ...(patch.position ? { position: patch.position } : {}), ...(patch.width !== undefined && patch.height !== undefined ? { width: patch.width, height: patch.height, measured: { width: patch.width, height: patch.height }, style: { ...node.style, width: patch.width, height: patch.height }, } : {}), 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; } } } /** 旧内置元素工厂的转接入口,不直接发布历史或保存网络。 */ 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("节点不存在。"); if (document.nodes.length >= 500) throw new Error("当前图谱已达到 500 个节点。"); if (!elementCapabilities(source.data.kind).branchable) throw new Error("纯文本和图标不支持分支节点。"); 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; const position = { x: center.x + vector.x * distance - 130, y: center.y + vector.y * distance - 100 }; // Keep repeated additions on the same side from covering one another. for (let attempts = 0; attempts < 500; attempts++) { const overlap = document.nodes.some((node) => position.x < node.position.x + nodeSize(node).width + 12 && position.x + 260 + 12 > node.position.x && position.y < node.position.y + nodeSize(node).height + 12 && position.y + 200 + 12 > node.position.y); if (!overlap) break; if (direction === "left" || direction === "right") position.y += 240; 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 { ...document, nodes: [...document.nodes.map((node) => ({ ...node, selected: false })), child], edges: [...document.edges, { id: crypto.randomUUID(), source: id, target: child.id, sourceHandle: direction, targetHandle: opposite[Math.abs(vector.x) > Math.abs(vector.y) ? vector.x > 0 ? "right" : "left" : vector.y > 0 ? "bottom" : "top"] }], }; } /** 接受有限长度的允许格式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; } } /** 旧正文样式的封闭键/值校验,返回复制对象;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("文本样式无效。"); const style = value as Record; const valid = Object.entries(style).every(([key, entry]) => { if (["color", "background"].includes(key)) return typeof entry === "string" && (/^#[0-9a-f]{6}$/i.test(entry) || (key === "background" && entry === "transparent")); if (["bold", "italic", "underline"].includes(key)) return typeof entry === "boolean"; if (key === "align") return typeof entry === "string" && ["left", "center", "right"].includes(entry); if (key === "fontFamily") return typeof entry === "string" && ["sans", "serif", "mono"].includes(entry); if (key === "lineHeight") return typeof entry === "number" && Number.isFinite(entry) && entry >= 1 && entry <= 3; return false; }); 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 的文件。"); // 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("不是有效的啡跃图谱文件。"); 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))) || (height !== undefined && (typeof height !== "number" || !Number.isFinite(height) || height < (value.schema_version === 2 && node.data.kind === "frame" ? 32 : 20) || height > (value.schema_version === 2 && node.data.kind === "frame" ? 100000 : 3000))) || (node.data.rotation !== undefined && (!Number.isFinite(node.data.rotation) || node.data.rotation < 0 || node.data.rotation >= 360)) || (node.data.fontSize !== undefined && (!Number.isFinite(node.data.fontSize) || node.data.fontSize < 8 || node.data.fontSize > 160))) throw new Error("节点大小或旋转角度无效。"); 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); return { id: node.id, type: node.data.kind === "plain-text" ? "plainText" : node.data.kind === "icon" ? "icon" : node.data.kind === "frame" ? "frame" : node.data.kind === "plain-image" ? "plainImage" : "material", ...(width !== undefined && height !== undefined ? { width, height, style: { width, height } } : {}), ...(node.data.kind === "frame" ? { zIndex: -1, style: { ...(width !== undefined ? { width, height } : {}), pointerEvents: "none" as const } } : {}), position: { x: node.position.x, y: node.position.y }, data: { ...extensions, ...(node.data.frameId ? { frameId: node.data.frameId } : {}), ...(node.data.kind === "frame" ? { labelMode: node.data.labelMode || "fixed" } : {}), ...(textStyle ? { textStyle } : {}), ...(node.data.kind === "icon" ? { icon: node.data.icon } : {}), kind: node.data.kind, title: node.data.title, text: node.data.text, 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("分区归属不存在。"); // 以下校验本 board 的端点/端口语法,不检测生成执行图环或加载子 board 验证端口内部目标。 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("图谱连线格式无效。"); for (const [nodeId, handle, direction] of [[edge.source, edge.sourceHandle, "output"], [edge.target, edge.targetHandle, "input"]] as const) { const kind = nodeKinds.get(nodeId)!; if (kind === "frame" && value.schema_version === 2) { const node = nodes.find((item) => item.id === nodeId)!; if (!node.data.subgraph || !node.data.ports?.some((port) => `port:${port.id}` === handle && port.direction === direction)) throw new Error("分区端口无效。"); } else { const allowed = value.schema_version === 2 ? ["text", "script", "image", "plain-text", "plain-image"].includes(kind) : elementCapabilities(kind).connectable; if (!allowed) throw new Error("该元素不支持节点连线。"); 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); return { id: edge.id, source: edge.source, target: edge.target, ...(edgeData ? { data: edgeData } : {}), ...(edge.sourceHandle ? { sourceHandle: edge.sourceHandle } : {}), ...(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("图谱视图格式无效。"); return { schema_version: value.schema_version, nodes, edges, viewport: { x: viewport.x, y: viewport.y, zoom: viewport.zoom } }; }