Files
Endoleap/src/lib/canvas.ts
T

216 lines
17 KiB
TypeScript

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";
export const nodeDirections: NodeDirection[] = ["top", "right", "bottom", "left"];
export type CanvasDocument = { schema_version: 1 | 2; nodes: CanvasNode[]; edges: CanvasEdgeModel[]; viewport: Viewport };
export type CanvasHistory = { past: CanvasDocument[]; present: CanvasDocument; future: CanvasDocument[] };
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" };
export function emptyCanvas(): CanvasDocument {
return { schema_version: 1, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } };
}
export function canvasStorageKey(workspace: string, board: string) {
return `endoleap_canvas_v1:${workspace}:${board}`;
}
function recorded(state: CanvasHistory, present: CanvasDocument): CanvasHistory {
return { past: [...state.past, state.present].slice(-40), present, future: [] };
}
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) });
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);
}
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) });
case "checkpoint": return recorded(state, state.present);
case "replace": return recorded(state, action.document);
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 };
}
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 };
}
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) });
case "transform": {
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 } };
}
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;
}
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);
}
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 } };
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;
}
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"] }],
};
}
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; }
}
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 };
}
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<string>();
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("图谱节点格式无效。");
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("分区设置无效。");
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 } : {}),
} };
});
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<string>();
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("图谱连接方向无效。");
}
}
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 } : {}),
};
});
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 } };
}