"use client"; import { inlineImageCount, uploadCanvasImage } from "@/lib/canvas-assets"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { Background, BackgroundVariant, ConnectionLineType, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode, addEdge, useReactFlow, useViewport, type Connection, type OnConnectEnd, type XYPosition, } from "@xyflow/react"; import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Shapes, Frame, Image as ImageIcon, Bot, Type, Undo2, Upload } from "lucide-react"; import { useReducedMotion } from "motion/react"; import dynamic from "next/dynamic"; import toolbar from "@/components/ui/toolbar.module.css"; import { IconButton } from "@/components/ui/icon-button"; import { branchCanvasNode, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasNode, type NodeDirection } from "@/lib/canvas"; import { api, errorText } from "@/lib/api"; import { readLocalRecovery, canRestoreCanvasDraft, type CanvasSnapshot } from "@/lib/canvas-sync"; import { useCanvasTree } from "./use-canvas-tree"; import type { CanvasAction } from "@/lib/canvas"; import type { Board } from "@/lib/workspace"; import { useCanvasAI } from "./use-canvas-ai"; import { AIPanel } from "./ai-panel"; import { EdgeEditing } from "./edge-editing"; import { CanvasEdge, CanvasConnectionLine } from "./canvas-edge"; import { BranchMenu, type BranchRequest } from "./branch-menu"; import { IconNode } from "./icon-node"; import { FrameFeedback } from "./frame-feedback"; import { subgraphMovePath, subgraphMoveParticipants, subgraphParents, exposedConnection, connectionTransactionPlan } from "@/lib/canvas-subgraphs"; import { frameDropFeedback } from "@/lib/canvas-frames"; import { DraftRecovery } from "./draft-recovery"; import { FrameNode } from "./frame-node"; import type { FrameLabelMode, TextStyle } from "@/lib/canvas-elements"; import { PlainImageNode } from "./plain-image-node"; import { PlainTextNode } from "./plain-text-node"; import type { NodeTransform } from "@/lib/canvas-geometry"; import { MaterialNode, NodeEditing } from "./material-node"; import styles from "./canvas.module.css"; const edgeTypes = { default: CanvasEdge }; const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () =>
正在加载图标面板…
}); const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode }; function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) { const flow = useReactFlow(); const { zoom } = useViewport(); const reduceMotion = useReducedMotion(); const storageKey = canvasStorageKey(board.workspace_id, board.id); const [initial] = useState(() => { try { const local = readLocalRecovery(localStorage, storageKey, snapshot); let document = snapshot?.document || local || emptyCanvas(); try { const view = JSON.parse(localStorage.getItem(storageKey + ":viewport") || "null"); if (view && [view.x, view.y, view.zoom].every(Number.isFinite) && Math.abs(view.x) <= 1e6 && Math.abs(view.y) <= 1e6 && view.zoom >= .1 && view.zoom <= 4) document = { ...document, viewport: view }; } catch { /* A broken local view never blocks the server document. */ } return { document, recovery: snapshot ? local : null, error: "" }; } catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; } }); const [tool, setTool] = useState<"select" | "hand">("select"); const [error, setError] = useState(initial.error); const [branchRequest, setBranchRequest] = useState(null); const [imageUploading, setImageUploading] = useState(false); const [iconPicker, setIconPicker] = useState(false); const [resizing, setResizing] = useState(null); const [rotating, setRotating] = useState(null); const [draggedIds, setDraggedIds] = useState([]); const [membershipNotice, setMembershipNotice] = useState(""); const dragSession = useRef<"idle" | "active" | "finishing">("idle"); const dragOrigins = useRef(new Map()); const draggingFrame = useRef(false); const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null); const pendingImage = useRef(null); const closeBranch = useCallback(() => setBranchRequest(null), []); const imageKind = useRef<"image" | "plain-image">("image"); const imageInput = useRef(null); const projectInput = useRef(null); const [recovery, setRecovery] = useState(initial.recovery); const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id); const childRecovery = [...tree.recoveries][0]; const downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; const sync = tree; const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree); const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } }; const treeRef = useRef(tree); treeRef.current = tree; function dispatch(action: CanvasAction) { if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; } treeRef.current.dispatch(action); } useEffect(() => { const input = imageInput.current; const cancel = () => { pendingImage.current = null; }; input?.addEventListener("cancel", cancel); return () => input?.removeEventListener("cancel", cancel); }, []); const { nodes, edges } = state.present; const frameFeedback = useMemo(() => { const names = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node.data.title])); const counts = new Map(); for (const node of nodes) if (node.data.frameId) counts.set(node.data.frameId, (counts.get(node.data.frameId) || 0) + 1); return { names, counts, drops: frameDropFeedback(nodes, draggedIds) }; }, [nodes, draggedIds]); useEffect(() => { if (!membershipNotice) return; const timer = setTimeout(() => setMembershipNotice(""), 4000); return () => clearTimeout(timer); }, [membershipNotice]); const duration = reduceMotion ? 0 : 180; const rememberViewport = (viewport: import("@xyflow/react").Viewport) => { treeRef.current.dispatch({ type: "viewport", viewport }); try { localStorage.setItem(storageKey + ":viewport", JSON.stringify(viewport)); } catch { /* View changes do not require server writes. */ } }; const changeView = (operation: () => Promise) => { void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message)); }; const latest = useRef(state.present); latest.current = state.present; const select = useCallback((id: string) => treeRef.current.select(id), []); const beginEdit = useCallback((id?: string) => { const current = treeRef.current; const ref = id ? current.views.nodeRefs.get(id) || current.views.edgeRefs.get(id) : undefined; current.dispatch({ type: "checkpoint" }, ref?.boardId || board.id); }, [board.id]); const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []); const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => { setBranchRequest({ id, direction, point }); setRotating(null); setResizing(null); }, []); const transform = useCallback((id: string, transform: NodeTransform) => { const ref = treeRef.current.views.nodeRefs.get(id); if (!ref) return; treeRef.current.nodeAction(id, (id) => ({ type: "transform", id, transform: { ...transform, ...(transform.position ? { position: { x: (transform.position.x - ref.origin.x) / ref.scale, y: (transform.position.y - ref.origin.y) / ref.scale } } : {}), ...(transform.width !== undefined ? { width: transform.width / ref.scale, height: transform.height! / ref.scale } : {}) } })); }, []); const style = useCallback((id: string, style: TextStyle, fontSize?: number) => treeRef.current.nodeAction(id, (id) => ({ type: "style", id, style, fontSize })), []); const rename = useCallback((id: string, title: string) => treeRef.current.nodeAction(id, (id) => ({ type: "rename-node", id, title })), []); const frame = useCallback((id: string, patch: { title?: string; labelMode?: FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => { const current = treeRef.current, ref = current.views.nodeRefs.get(id); if (!ref) return; const document = current.documents.get(ref.boardId)!; current.dispatch({ type: "replace", document: { ...document, schema_version: 2, nodes: document.nodes.map((node) => { if (node.id !== ref.id) return node; const toggle = patch.presentation && !!patch.presentation.collapsed !== !!node.data.presentation?.collapsed; const size = toggle ? patch.presentation!.collapsed ? node.data.presentation?.collapsedSize || { width: 240, height: 140 } : node.data.presentation?.expandedSize || { width: 640, height: 400 } : undefined; return { ...node, ...(size ? { ...size, style: { ...node.style, ...size } } : {}), data: { ...node.data, ...patch } }; }) } }, ref.boardId); }, []); const editing = useMemo(() => ({ begin: beginEdit, select, edit, branch, transform, style, rename, frame, workspace: board.workspace_id, schema: 2 as const, rotating, resizing, resize: (id: string | null) => { setResizing(id); setRotating(null); }, rotate: (id: string | null) => { setRotating(id); setResizing(null); } }), [beginEdit, select, edit, branch, transform, style, rename, frame, rotating, resizing, board.workspace_id, state.present.schema_version]); function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) { try { const document = branchCanvasNode(tree.documents.get(tree.views.nodeRefs.get(request.id)!.boardId)!, tree.views.nodeRefs.get(request.id)!.id, request.direction, kind, src, request.at ? { x: (request.at.x - tree.views.nodeRefs.get(request.id)!.origin.x) / tree.views.nodeRefs.get(request.id)!.scale, y: (request.at.y - tree.views.nodeRefs.get(request.id)!.origin.y) / tree.views.nodeRefs.get(request.id)!.scale } : undefined); tree.dispatch({ type: "replace", document }, tree.views.nodeRefs.get(request.id)!.boardId); const child = document.nodes.at(-1)!; void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration }); setBranchRequest(null); } catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); } } function chooseBranch(kind: "text" | "image" | "script") { if (!branchRequest) return; if (kind === "image") { pendingImage.current = branchRequest; imageKind.current = "image"; setBranchRequest(null); imageInput.current?.click(); } else createBranch(branchRequest, kind); } const connectionEnd: OnConnectEnd = (event, connection) => { const start = connectionStart.current; connectionStart.current = null; if (!start || connection.isValid) return; const point = "changedTouches" in event ? event.changedTouches[0] : event; if (!point || Math.hypot(point.clientX - start.point.x, point.clientY - start.point.y) < 5) return; if (!document.elementFromPoint(point.clientX, point.clientY)?.closest(".react-flow__pane")) return; if (!flow.getNode(start.id)?.selected) return; const at = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }); setBranchRequest({ id: start.id, direction: start.direction, point: { x: point.clientX, y: point.clientY }, at }); }; const center = () => { const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); const offset = (nodes.length % 6) * 18; return { x: point.x - 130 + offset, y: point.y - 80 + offset }; }; function addNode(kind: CanvasNode["data"]["kind"], src?: string, size?: { width: number; height: number }, title?: string) { if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; } const node = makeCanvasNode(kind, center(), src); if (kind === "frame" && snapshot) { void tree.command(board.id, "subgraphs", { frame: { id: node.id, title: node.data.title, position: node.position, width: node.width, height: node.height, labelMode: "fixed" } }).catch((error) => setError(error.message)); setTool("select"); return; } if (size) { node.width = size.width; node.height = size.height; node.style = { ...node.style, ...size }; } if (title) node.data.title = title.slice(0, 300); dispatch({ type: "nodes", changes: [{ type: "add", item: node }] }); setTool("select"); } function addTemplate() { if (tree.rootDocument.nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; } const position = center(); const brief = makeCanvasNode("text", { x: position.x - 300, y: position.y }); brief.data = { ...brief.data, title: "创意简报", text: "你想表达什么?\n\n记录主题、受众和一个关键想法。" }; const idea = makeCanvasNode("text", { x: position.x + 20, y: position.y - 100 }); idea.data = { ...idea.data, title: "视觉参考", text: "收集颜色、光线和画面氛围。" }; const script = makeCanvasNode("script", { x: position.x + 340, y: position.y }); script.data.text = "从第一个镜头开始,写下你的故事。"; dispatch({ type: "replace", document: { ...tree.rootDocument, nodes: [...tree.rootDocument.nodes, brief, idea, script], edges: [...tree.rootDocument.edges, { id: crypto.randomUUID(), source: brief.id, target: idea.id, type: "default" }, { id: crypto.randomUUID(), source: idea.id, target: script.id }, ] } }); setTool("select"); } function beginDrag(node: CanvasNode, dragged: CanvasNode[]) { if (dragSession.current !== "idle") return; dragSession.current = "active"; beginEdit(node.id); closeBranch(); setRotating(null); setResizing(null); draggingFrame.current = node.data.kind === "frame" || dragged.some((item) => item.data.kind === "frame"); const before = new Map(latest.current.nodes.map((item) => [item.id, item.position])); dragOrigins.current = new Map((dragged.length ? dragged : [node]).map((item) => [item.id, before.get(item.id) || item.position])); setDraggedIds(draggingFrame.current ? [] : [...dragOrigins.current.keys()]); setMembershipNotice(""); } async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) { if (dragSession.current !== "active") return; dragSession.current = "finishing"; if (!draggingFrame.current || snapshot && tree.rootDocument.schema_version === 2) { const items = dragged.length ? dragged : [node]; const ids = items.filter((item) => { const start = dragOrigins.current.get(item.id); return (item.data.kind !== "frame" || snapshot && tree.rootDocument.schema_version === 2) && start && (start.x !== item.position.x || start.y !== item.position.y); }).map((item) => item.id); if (ids.length) { const positions = new Map(items.map((item) => [item.id, item.position])); const dropped = latest.current.nodes.map((item) => positions.has(item.id) ? { ...item, position: positions.get(item.id)! } : item); const changed = frameDropFeedback(dropped, ids).filter((item) => item.from !== item.to); const messages = changed.map((item) => item.to ? `已加入「${frameFeedback.names.get(item.to) || "分区"}」` : `已移出「${frameFeedback.names.get(item.from!) || "分区"}」`); const notice = messages.length ? `${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}` : ""; if (snapshot && tree.rootDocument.schema_version === 2) { try { const byOwner = new Map(); const parents = subgraphParents(tree.current.current); for (const item of items) { const ref = tree.views.nodeRefs.get(item.id); if (!ref) continue; const center = { x: item.position.x + (item.width || 260) / 2, y: item.position.y + (item.height || 200) / 2 }; const inMovingSubtree = (child: string) => { const seen = new Set(); let cursor: string | undefined = child; while (cursor && !seen.has(cursor)) { if (cursor === item.data.subgraph?.boardId) return true; seen.add(cursor); cursor = parents.get(cursor)?.boardId; } return false; }; const candidates = dropped.filter((candidate) => candidate.id !== item.id && candidate.data.subgraph && !inMovingSubtree(candidate.data.subgraph.boardId) && center.x >= candidate.position.x && center.y >= candidate.position.y && center.x <= candidate.position.x + (candidate.width || 640) && center.y <= candidate.position.y + (candidate.height || 400)).sort((a, b) => (a.width || 640) * (a.height || 400) - (b.width || 640) * (b.height || 400)); const target = candidates[0]?.data.subgraph?.boardId || (ref.boardId === board.id ? board.id : parents.get(ref.boardId)?.boardId) || board.id; if (target !== ref.boardId) { const key = ref.boardId + "/" + target, group = byOwner.get(key) || { ids: [], target }; group.ids.push(ref.id); byOwner.set(key, group); } } for (const [key, group] of byOwner) { let source = key.split("/")[0]; for (const hop of subgraphMovePath(tree.current.current, source, group.target)) { for (const id of group.ids) { const node = tree.current.current.get(hop.source)?.nodes.find((node) => node.id === id); if (node?.data.subgraph) await tree.ensure(node.data.subgraph.boardId, true); } const participants = subgraphMoveParticipants(tree.current.current, hop.parent, hop.source, hop.target, group.ids); await tree.command(hop.parent, `subgraphs/${hop.frame}/members`, { source_board_id: hop.source, target_board_id: hop.target, node_ids: group.ids }, participants); source = hop.target; } } if (byOwner.size) setMembershipNotice(notice || "已更新子画布归属"); } catch (error) { setError(error instanceof Error ? error.message : "移动内容未完成"); setMembershipNotice(""); } } else { dispatch({ type: "drop", ids }); if (notice) setMembershipNotice(notice); } } } setDraggedIds([]); dragOrigins.current.clear(); dragSession.current = "idle"; } async function connect(connection: Connection) { const source = tree.views.nodeRefs.get(connection.source), target = tree.views.nodeRefs.get(connection.target); if (!source || !target) return; try { const resolve = async (ref: typeof source, handle: string | null | undefined): Promise<{ boardId: string; id: string; handle: string }> => { let endpoint = { boardId: ref!.boardId, id: ref!.id, handle: handle || "right" }; for (let depth = 0; depth <= 8; depth++) { const node = tree.current.current.get(endpoint.boardId)?.nodes.find((node) => node.id === endpoint.id); if (!node?.data.subgraph) return endpoint; const port = node.data.ports?.find((port) => `port:${port.id}` === endpoint.handle); if (!port) throw new Error("ownership_conflict"); await tree.ensure(node.data.subgraph.boardId); endpoint = { boardId: node.data.subgraph.boardId, id: port.nodeId, handle: port.handle }; } throw new Error("subgraph_cycle"); }; const realSource = await resolve(source, connection.sourceHandle), realTarget = await resolve(target, connection.targetHandle || "left"); if (realSource.boardId === realTarget.boardId) { const document = tree.current.current.get(realSource.boardId)!; tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ id: crypto.randomUUID(), source: realSource.id, target: realTarget.id, sourceHandle: realSource.handle, targetHandle: realTarget.handle }, document.edges) } }, realSource.boardId); return; } if (realSource.boardId !== realTarget.boardId) { const plan = connectionTransactionPlan(tree.current.current, realSource, realTarget); const capability = await api<{ subgraphs?: { connections?: boolean } }>(`workspaces/${board.workspace_id}/materials/${plan.boardId}/canvas/capabilities`, { headers: { Accept: "application/vnd.foundry.canvas+json;version=2" } }); if (capability.subgraphs?.connections) { await tree.command(plan.boardId, "connections", { source: plan.source, target: plan.target, edge: { id: crypto.randomUUID() } }, plan.participants); return; } } const exposed = exposedConnection(tree.current.current, realSource, realTarget); const document = tree.current.current.get(exposed.boardId)!; tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ ...connection, source: exposed.source.id, target: exposed.target.id, sourceHandle: exposed.source.handle, targetHandle: exposed.target.handle, id: crypto.randomUUID() }, document.edges) } }, exposed.boardId); } catch (error) { setError(error instanceof Error && error.message === "boundary_port_required" ? "跨画布连接暂不可用,请稍后重试。" : errorText(error)); } } async function readImage(event: ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; if (!file) { pendingImage.current = null; return; } if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > (snapshot ? 20 * 1024 * 1024 : 2_000_000)) { pendingImage.current = null; setError(snapshot ? "请选择小于 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片。" : "演示模式请选择小于 2 MB 的图片。"); return; } const request = pendingImage.current; pendingImage.current = null; const kind = imageKind.current; setImageUploading(true); try { const uploaded = snapshot ? await uploadCanvasImage(api, board.workspace_id, file, file.name) : null; const src = uploaded?.url || await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error("无法读取图片。")); reader.readAsDataURL(file); }); let size: { width: number; height: number } | undefined; if (kind === "plain-image" && !request) { size = await new Promise((resolve, reject) => { const image = new window.Image(); const localSource = uploaded ? URL.createObjectURL(file) : src; const clean = () => { if (uploaded) URL.revokeObjectURL(localSource); }; image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); clean(); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); }; image.onerror = () => { clean(); reject(new Error("无法读取图片尺寸。")); }; image.src = localSource; }); } if (request) createBranch(request, "image", src); else addNode(kind, src, size, kind === "plain-image" ? file.name : undefined); } catch (error) { pendingImage.current = null; setError(errorText(error)); } finally { setImageUploading(false); } } async function importProject(event: ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; try { if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。"); if (tree.rootDocument.nodes.some((node) => node.data.subgraph)) throw new Error("当前画布包含真实子图,请先导出备份并通过子图事务恢复,避免覆盖子图关系。"); const document = decodeCanvas(await file.text()); if (document.nodes.some((node) => node.data.subgraph)) throw new Error("子图备份需要按原始画布关系恢复,不能直接导入到其他画布。"); dispatch({ type: "replace", document }); void flow.setViewport(document.viewport); setError(""); } catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); } } async function exportProject() { let documents: Map; try { documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; } const content = JSON.stringify(documents.size > 1 ? { format: "endoleap-canvas-bundle", root: board.id, documents: Object.fromEntries(documents) } : { ...tree.rootDocument, viewport: flow.getViewport() }, null, 2); const url = URL.createObjectURL(new Blob([content], { type: "application/json" })); const link = document.createElement("a"); link.href = url; link.download = `${board.title.replace(/[\\/:*?"<>|]/g, "_") || "未命名图谱"}.json`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } useEffect(() => { const keydown = (event: KeyboardEvent) => { const target = event.target; if (!(target instanceof HTMLElement)) return; if (target.closest("input, textarea, [contenteditable=true], [role=dialog], [popover]:popover-open")) return; if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") { event.preventDefault(); dispatch({ type: event.shiftKey ? "redo" : "undo" }); } else if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "y") { event.preventDefault(); dispatch({ type: "redo" }); } else if (!event.metaKey && !event.ctrlKey && !event.altKey) { if (event.key.toLowerCase() === "v") setTool("select"); if (event.key.toLowerCase() === "h") setTool("hand"); } }; window.addEventListener("keydown", keydown); return () => window.removeEventListener("keydown", keydown); }, []); return (
ai.activeEdges.has(ref.boardId + "/" + ref.id)).map(([id]) => id)) }}> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} onBeforeDelete={async ({ nodes: removing }) => { if (tree.busy) return false; const linked = removing.filter((node) => node.data.subgraph); const parents = subgraphParents(tree.current.current); const referenced = removing.filter((node) => { const ref = tree.views.nodeRefs.get(node.id); if (!ref) return false; const parent = parents.get(ref.boardId); return parent && tree.current.current.get(parent.boardId)?.nodes.find((frame) => frame.id === parent.frameId)?.data.ports?.some((port) => port.nodeId === ref.id); }); if (!linked.length && !referenced.length) return true; try { for (const node of linked) { const ref = tree.views.nodeRefs.get(node.id)!; await tree.ensure(node.data.subgraph!.boardId); const child = tree.current.current.get(node.data.subgraph!.boardId)!; const participants = new Set([ref.boardId, node.data.subgraph!.boardId, ...child.nodes.flatMap((item) => item.data.subgraph ? [item.data.subgraph.boardId] : [])]); const ancestor = subgraphParents(tree.current.current).get(ref.boardId)?.boardId; if (ancestor && subgraphMoveParticipants(tree.current.current, ref.boardId, node.data.subgraph!.boardId, ref.boardId, child.nodes.map((item) => item.id)).includes(ancestor)) participants.add(ancestor); await tree.command(ref.boardId, `subgraphs/${ref.id}/dissolve`, {}, [...participants]); } for (const node of removing.filter((node) => !node.data.subgraph)) { const ref = tree.views.nodeRefs.get(node.id); if (!ref) continue; let owner = ref.boardId; const parent = subgraphParents(tree.current.current).get(owner); if (parent && tree.current.current.get(parent.boardId)?.nodes.find((frame) => frame.id === parent.frameId)?.data.ports?.some((port) => port.nodeId === ref.id)) { const participants = subgraphMoveParticipants(tree.current.current, parent.boardId, owner, parent.boardId, [ref.id]); await tree.command(parent.boardId, `subgraphs/${parent.frameId}/members`, { source_board_id: owner, target_board_id: parent.boardId, node_ids: [ref.id] }, participants); owner = parent.boardId; } if (!tree.current.current.get(owner)?.nodes.some((item) => item.id === ref.id)) { const match = [...tree.current.current].filter(([, document]) => document.nodes.some((item) => item.id === ref.id)); if (match.length !== 1) continue; owner = match[0][0]; } tree.dispatch({ type: "nodes", changes: [{ type: "remove", id: ref.id }] }, owner); } if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留"); } catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); } return false; }} onNodesChange={tree.nodesChanged} onEdgesChange={tree.edgesChanged} connectionLineType={ConnectionLineType.Bezier} connectionLineComponent={CanvasConnectionLine} onNodeDragStart={(_, node, dragged) => beginDrag(node, dragged)} onNodeDragStop={(_, node, dragged) => { void finishDrag(node, dragged); }} onSelectionDragStart={(_, dragged) => { if (dragged[0]) beginDrag(dragged[0], dragged); }} onSelectionDragStop={(_, dragged) => { if (dragged[0]) void finishDrag(dragged[0], dragged); }} onConnect={(connection) => { void connect(connection); }} connectionMode={ConnectionMode.Loose} connectOnClick={false} connectionDragThreshold={5} onConnectStart={(event, params) => { closeBranch(); const point = "touches" in event ? event.touches[0] : event; if (params.nodeId && params.handleId && point) connectionStart.current = { id: params.nodeId, direction: params.handleId as NodeDirection, point: { x: point.clientX, y: point.clientY } }; }} onConnectEnd={connectionEnd} onPaneClick={() => { closeBranch(); setRotating(null); setResizing(null); }} onNodeClick={(_, node) => { if (node.id !== rotating) setRotating(null); if (node.id !== resizing) setResizing(null); }} onMoveStart={closeBranch} onMoveEnd={(event, viewport) => { if (event) rememberViewport(viewport); }} defaultViewport={initial.document.viewport} minZoom={0.1} maxZoom={4} panOnDrag panOnScroll zoomOnScroll={false} zoomOnPinch zoomActivationKeyCode={["Meta", "Control"]} nodesDraggable={tool === "select" && !tree.busy} nodesConnectable={tool === "select" && !tree.busy} elevateNodesOnSelect={false} elementsSelectable={tool === "select"} selectionMode={SelectionMode.Partial} panActivationKeyCode="Space" selectionKeyCode="Shift" deleteKeyCode={["Backspace", "Delete"]} preventScrolling defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}> {ai.enabled && nodes.filter((node) => node.selected && ["text", "script", "plain-text", "image", "plain-image"].includes(node.data.kind)).map((node) => { const ref = tree.views.nodeRefs.get(node.id)!; const owned = tree.documents.get(ref.boardId)?.nodes.find((item) => item.id === ref.id); return owned && tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />; })}
dispatch({ type: "undo" })} /> dispatch({ type: "redo" })} />
setTool("select")} /> setTool("hand")} />
自由创作,从移动画布开始

拖动空白处平移,拖动节点移动位置。

触控板滚动平移;按 Ctrl / ⌘ 滚动或双指捏合缩放。

V 选择 · H 抓手 · 按住空格临时平移 · Shift 框选

拖动右下角调整大小;在样式栏开启旋转,拖动元素旋转,点击外部退出。Shift 吸附 15°。

纯文本可独立调整宽高,文字随宽度换行,字号在样式栏设置;不显示卡片连接点或四向加号。

拖动四边中心点创建贝塞尔连线;选中后点击连接点选择新节点类型。Delete 删除所选内容。

添加框选区后拖入内容加入;拖出退出。移动分区只带动已有成员,不收纳被覆盖的内容。选中分区可改名并选择名称显示模式。

⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。

可通过右上角导出 JSON 备份。

{!!snapshot && inlineImageCount(tree.rootDocument) > 0 &&
此画布的旧图片可优化加载,原图和内容都会保留。
} {imageUploading &&

正在上传图片…

} {iconPicker && setIconPicker(false)} choose={(name, label) => { if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点。"); return; } const node = makeCanvasNode("icon", center()); node.data = { ...node.data, icon: name, title: label }; dispatch({ type: "replace", document: { ...tree.rootDocument, nodes: [...tree.rootDocument.nodes.map((item) => ({ ...item, selected: false })), { ...node, selected: true }] } }); setTool("select"); }} />}
{membershipNotice}
{recovery && downloadDraft(recovery)} restore={() => { dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => setRecovery(null)} />} {!recovery && childRecovery && downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />} {snapshot && sync.error &&
{sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))} {sync.error !== "version_conflict" && }
} {tree.busy &&
{tree.pendingCommand ?
同步结果尚未确认,为保护内容暂时停止编辑。
: "正在同步画布…"}
} {error && }
); } export default function GraphCanvas({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) { return ; }