"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" })} />
changeView(() => flow.zoomOut({ duration }))} />
changeView(() => flow.zoomIn({ duration }))} />
changeView(() => nodes.length ? flow.fitView({ padding: 0.2, duration }) : flow.setViewport({ x: 0, y: 0, zoom: 1 }, { duration }))} />
setTool("select")} />
setTool("hand")} />
addNode("plain-text")} />
addNode("text")} />
{ pendingImage.current = null; imageKind.current = "image"; imageInput.current?.click(); }} />
{ pendingImage.current = null; imageKind.current = "plain-image"; imageInput.current?.click(); }} />
addNode("script")} />
addNode("frame")} />
setIconPicker(!iconPicker)} />
ai.setEnabled(!ai.enabled)} />
projectInput.current?.click()} />
自由创作,从移动画布开始
拖动空白处平移,拖动节点移动位置。
触控板滚动平移;按 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 ;
}