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 } | { 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" }; 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 = { 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; 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(); 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(); 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 } }; }