265 lines
22 KiB
TypeScript
265 lines
22 KiB
TypeScript
/**
|
||
* 旧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<CanvasNode["data"]> }
|
||
| { 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<CanvasNode>[] }
|
||
| { 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<NodeDirection, NodeDirection> = { 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<string, unknown>;
|
||
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<string>();
|
||
// 先校验节点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<string>();
|
||
// 边身份唯一且两端存在;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 } };
|
||
}
|