Files
Endoleap/src/lib/canvas.ts
T

265 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧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 } };
}