Files
Endoleap/src/components/canvas/graph-canvas.tsx
T

679 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧版主画布的界面装配与交互控制。useCanvasTree拥有旧快照、writer、历史和子图事务,组件把其投影交给ReactFlow。
* 本地state管理工具、模式、菜单与提示,refs保存拖动/连线起点及文件选择上下文;真实上传与演示Data URL分别处理。
* 节点/连线组件通过Context调用适配动作,不直接修改投影;子图归属、端口连线和解散优先使用服务端能力与事务。
* 迁移门闩暂停旧编辑和创作入口,给迁移协调组件提供检查、保存排空与备份;本文件不自行切换到新版engine。
*/
"use client";
import { useImageUploads } from "./use-image-uploads";
import {LegacyCanvasMigration} from "./legacy-canvas-migration";
import type {LegacyMigrationContext} from "@/lib/browser-canvas-migration";
import { notify, dismissNotification } from "@/lib/notifications";
import { FeedbackContext } from "@/components/ui/app-feedback";
import { ImagePreviews } from "./canvas-image";
import { displayID } from "@/lib/canvas-tree";
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import {
Background, BackgroundVariant, ConnectionLineType, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode,
addEdge, NodeToolbar, Position, 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";
/** 旧边统一使用可编辑贝塞尔组件,节点按旧type名称分派;类型映射本身不持有元素实例数据。 */
const edgeTypes = { default: CanvasEdge };
/** 图标面板延迟在客户端加载,避免首屏加载目录面板及其交互代码;加载中提供可访问状态。 */
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> });
/** ReactFlow type到节点展示组件的固定表,须与makeCanvasNode/投影生成的type保持一致。 */
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
/** 每个挂载对应一个board身份,初始快照/草稿只在useState初始化时采用;切画布应由上层key重挂载。 */
function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
const flow = useReactFlow<CanvasNode>();
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: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
});
// 工具与变换模式是界面状态,不放入文档;draggedIds仅驱动成员预览,membershipNotice短暂展示结果。
const [tool, setTool] = useState<"select" | "hand">("select");
const [error, setError] = useState(initial.error);
const feedback = useContext(FeedbackContext);
const [branchRequest, setBranchRequest] = useState<BranchRequest | null>(null);
const [demoImageUploading, setImageUploading] = useState(false);
const [iconPicker, setIconPicker] = useState(false);
const [resizing, setResizing] = useState<string | null>(null);
const [rotating, setRotating] = useState<string | null>(null);
const [draggedIds, setDraggedIds] = useState<string[]>([]);
const [membershipNotice, setMembershipNotice] = useState("");
// 防节点拖动与多选拖动回调重复进入;起点按显示ID保存,连线屏幕起点用于区分点击与拉出新分支。
const dragSession = useRef<"idle" | "active" | "finishing">("idle");
const dragOrigins = useRef(new Map<string, XYPosition>());
const draggingFrame = useRef(false);
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
// 图片分支先保留源节点/方向/位置,等文件选择返回;cancel事件要清它,不能沿用上次创建意图。
const pendingImage = useRef<BranchRequest | null>(null);
/** 稳定关闭回调供Portal菜单与画布事件共用,避免菜单因每次渲染重新安装监听。 */
const closeBranch = useCallback(() => setBranchRequest(null), []);
const imageKind = useRef<"image" | "plain-image">("image");
const imageInput = useRef<HTMLInputElement>(null);
const projectInput = useRef<HTMLInputElement>(null);
const [recovery, setRecovery] = useState(initial.recovery);
// 根恢复待决时暂停保存;tree负责真实owner文档,UI显示节点来自其树投影。子图恢复按Map首项逐个处理。
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration);
const childRecovery = [...tree.recoveries][0];
/** 下载单份内存文档JSON备份,稍后释放Blob URL;不修改草稿选择,也不保证包含所有子图。 */
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;
// snapshot存在才执行真实AI;state.present组合根元数据与整树显示节点/边,不能把它当单board持久快照。
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;
// 上传完成回调通过treeRef定位当前归属,并更新当前文档与历史;job.owner可能已不再是最新owner。
const uploads = useImageUploads(board.workspace_id, (job, asset) => treeRef.current.resolveImageUpload(job.node.id, asset,{width:job.node.width!,height:job.node.height!}));
const imageUploading = demoImageUploading;
// 文件导入等待期间的迁移检查标记;这不是通用导入队列,也不提供并发文件事务锁。
const ioActive=useRef(false);
// 上传任务与文档都可能变化:按pendingUpload重新找owner,补资源或纯图片默认尺寸,避免移动/撤销后遗漏占位。
useEffect(() => {
const current = treeRef.current;
for (const job of uploads.jobs) {
const owner = [...current.current.current].find(([,doc])=>doc.nodes.some((node)=>node.pendingUpload===job.node.id));
if (!owner) continue;
if (job.asset) { current.resolveImageUpload(job.node.id,job.asset,{width:job.node.width!,height:job.node.height!}); continue; }
const node=owner[1].nodes.find((node)=>node.id===job.node.id)!;
if (node.data.kind==="plain-image"&&node.width===320&&node.height===240&&(job.node.width!==320||job.node.height!==240)) current.resolveImageUpload(job.node.id,undefined,{width:job.node.width!,height:job.node.height!});
}
}, [uploads.jobs,tree.documents]);
/** undo/redo走异步树历史接口并显示失败,其余动作走根document reducer;不把服务端事务撤销当普通reducer动作。 */
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);
}
// 浏览器文件选择取消不触发普通onChange,单独监听cancel清理待建图片分支;卸载拆监听。
useEffect(() => {
const input = imageInput.current;
/** 取消本次选择意图,不删除已经存在的图片节点或中止已启动上传。 */
const cancel = () => { pendingImage.current = null; };
input?.addEventListener("cancel", cancel);
return () => input?.removeEventListener("cancel", cancel);
}, []);
const { nodes: displayNodes, edges } = state.present;
// 对成员任务保护节点关闭拖动/删除/连接;tree.update内部仍保护相应数据,界面禁用不是唯一护栏。
const nodes = displayNodes.map((node) => { const ref = tree.views.nodeRefs.get(node.id); const blocked = ref && tree.protectedNodes(ref.boardId).has(ref.id); return blocked ? { ...node, draggable: false, deletable: false, connectable: false } : node; });
/** 上传任务的本地nodeID映射到当前场景ID;尚无投影时按根/原owner构造兼容显示ID。 */
// 这里优先按 local node.id 搜索,未同时限定 owner;旧协议跨 board 重名时取首个匹配,不能当作全局唯一身份。
const uploadDisplayID = (job: typeof uploads.jobs[number]) => [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || displayID(board.id,job.owner,job.node.id);
// 预览URL按显示ID提供;正式图片onLoad再映射回job,上传完成的任务才回收本地URL。
const imagePreviews = { sources: new Map(uploads.jobs.map((job) => [uploadDisplayID(job),job.localURL])), loaded: (id: string) => { const job = uploads.jobs.find((job) => uploadDisplayID(job)===id);if(job) uploads.loaded(job.node.id); } };
// 从场景节点派生分区名称、已有frameId计数与本次拖入/拖出候选,只作显示,不在此改变owner。
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<string, number>();
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]);
// 全局反馈可用时把本地错误转成稳定ID通知并清本地error;无Provider时保留底部内联提示。
useEffect(() => {
if (!feedback || !error) return;
const id = `canvas:${board.id}:error`;
notify({ id, level: "error", title: error });
setError("");
}, [feedback, error, board.id]);
// 成员通知使用board级稳定ID,内容变化替换旧通知,清理时显式dismiss。
useEffect(() => {
if (!feedback || !membershipNotice) return;
const id = `canvas:${board.id}:membership`;
notify({ id, level: "success", title: membershipNotice });
return () => dismissNotification(id);
}, [feedback, membershipNotice, board.id]);
const duration = reduceMotion ? 0 : 180;
/** 视口派发给根并独立写本地键;存储失败不打断浏览,内容writer自行判断是否需要服务端保存。 */
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<boolean>) => {
void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message));
};
// latest保留当前显示几何,拖动结束基于最新节点计算成员反馈,避免只使用开始时的闭包。
const latest = useRef(state.present);
latest.current = state.present;
/** 显示ID的选择交tree解析owner;稳定回调通过treeRef避免每次文档更新重建编辑上下文。 */
const select = useCallback((id: string) => treeRef.current.select(id), []);
/** 对目标owner建立checkpoint,把后续连续编辑归到同一撤销起点;缺目标时使用根board。 */
// checkpoint 只保存起点,没有对应 token 或原子结束动作;取消能力取决于旧节点组件的回退处理。
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]);
/** 正文动作按投影ID定位原节点,reducer处理文本值与历史。 */
const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []);
/** 打开分支类型菜单并退出变换模式,point用于屏幕定位。 */
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => { setBranchRequest({ id, direction, point }); setRotating(null); setResizing(null); }, []);
/** 将场景位置减owner原点再除scale,宽高也除scale;旋转等其余字段直接交旧transform动作。 */
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 } : {}) } }));
}, []);
/** 样式适配只构造旧style动作,具体字段合并由reducer处理。 */
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => treeRef.current.nodeAction(id, (id) => ({ type: "style", id, style, fontSize })), []);
/** 节点名称通过owner映射更新,避免将显示复合ID当持久ID。 */
const rename = useCallback((id: string, title: string) => treeRef.current.nodeAction(id, (id) => ({ type: "rename-node", id, title })), []);
/** 分区属性以replace提交;折叠切换从已保存expanded/collapsedSize或默认值恢复尺寸,并写node与style。 */
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);
}, []);
// 注入节点动作及本地旋转/拉伸模式,两模式互斥;旧版接口没有新版手势token租约。
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]);
/** 在源owner文档创建节点与连线,at从场景坐标转局部;发布后尝试把视口移向新节点,异常留作提示。 */
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)!;
// 使用 child 的 owner 局部坐标,未再投回根场景;Promise 的异步拒绝也不被外围同步 catch 捕获。
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 : "无法创建节点。"); }
}
/** 图片分支需要文件选择才能创建,先保存request并关菜单;文本/脚本立即创建。 */
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);
}
/** 无有效目标的连接拖动若移动至少5px并落到空白pane,且源仍选中,则在落点打开分支菜单。 */
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 };
};
/** 根节点上限500;真实分区走subgraphs命令创建子画布,其它节点本地加入,演示frame也走本地模型。 */
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");
}
/** 一次replace插入三张预填卡片和两条连线,预留三个节点容量并切回选择工具;不触发AI生成。 */
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");
}
/** idle才建立checkpoint与拖动起点;有frame参与则不显示普通拖入预览,防止移动分区误收纳覆盖内容。 */
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
if (dragSession.current !== "idle") return; dragSession.current = "active"; tree.setDragging(true);
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("");
}
/** 只结束active拖动,先清拖动/保存暂停标记,再按真实位移计算归属;真实子图与旧frameId归属分路处理。
* 异步移动失败保留错误提示,不宣称所有跨分区步骤一起回滚。 */
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
if (dragSession.current !== "active") return;
// 先恢复 idle 才 await 后续成员移动;第二次拖动的防护依赖 tree 的保护节点/队列,而非这个状态持续锁定。
const origins = new Map(dragOrigins.current);
dragSession.current = "idle"; dragOrigins.current.clear(); setDraggedIds([]); tree.setDragging(false);
if (!draggingFrame.current || snapshot && tree.rootDocument.schema_version === 2) {
const items = dragged.length ? dragged : [node];
const ids = items.filter((item) => { const start = origins.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);
// 先生成“加入/移出”展示文案;实际owner由下面的真实子图路径重新判定,成功后才显示结果通知。
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 {
// 按source/target分组,同组节点一起迁移;候选分区按面积升序,优先最小包围分区。
const byOwner = new Map<string, { ids: string[]; target: string }>();
const parents = subgraphParents(tree.current.current);
for (const item of items.filter((item) => ids.includes(item.id))) {
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<string>(); 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 && tree.current.current.get(ref.boardId)?.nodes.find((node)=>node.id===ref.id)?.pendingUpload) {
tree.movePendingImage(ref.boardId,target,ref.id);continue;
}
if (target !== ref.boardId) {
const key = ref.boardId + "/" + target, group = byOwner.get(key) || { ids: [], target }; group.ids.push(ref.id); byOwner.set(key, group);
}
}
// 不同owner组并行提出任务,每组沿父子路径逐跳等待;tree内部队列仍串行发送成员请求。
// Promise.all 任一拒绝不取消其它组,也不回滚已完成的 hop;失败队首还可能保持未决 Promise 等待重试。
await Promise.all([...byOwner].map(async ([key, group]) => {
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.moveMembers(hop.parent, hop.frame, hop.source, hop.target, 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); }
}
}
}
/** 先把入口端口解析成实际内部节点;同owner本地加边,跨owner优先服务端connections事务,缺能力时尝试暴露端口适配。 */
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 {
/** 按深度计数0—8解析子图入口,每层必须找到明确port映射;仍未到实际节点或缺端口则拒绝。 */
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");
// 同一canvas快照可直接加边;跨canvas需要共同上层/边界端口关系,不能把显示端点直接写进任一子快照。
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;
}
}
// 兼容路径由exposedConnection提供可寻址端点与owner,失败提示跨画布连接暂不可用。
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)); }
}
/** 校验MIME与大小并清文件input;迁移期间拒绝。真实画布通过上传占位,演示以Data URL直接加入。 */
async function readImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) { pendingImage.current = null; return; }
if(treeRef.current.isMigrationHeld()){pendingImage.current=null;setError("画布交接期间暂时不能添加图片。");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;
}
// 文件选择完成后才消费保存的分支意图;不带源节点代次,实际创建前只按当前显示 ID 查 owner。
const request = pendingImage.current; pendingImage.current = null;
const kind = imageKind.current;
// 真实图片上传先取得本地Blob预览并构造draft,随后把pendingUpload节点及可选分支边发布到owner。
if (snapshot) {
try { const job = uploads.start(file, (src) => {
const current = treeRef.current;
if (request) {
const ref = current.views.nodeRefs.get(request.id); if (!ref) throw new Error("源节点已移动或删除,请重新添加图片。");
const draft = branchCanvasNode(current.current.current.get(ref.boardId)!,ref.id,request.direction,"image",src,request.at ? {x:(request.at.x-ref.origin.x)/ref.scale,y:(request.at.y-ref.origin.y)/ref.scale}:undefined);
return {owner:ref.boardId,node:draft.nodes.at(-1)!,edge:draft.edges.at(-1)};
}
if(current.rootDocument.nodes.length + uploads.jobs.filter((job)=>job.owner===board.id&&job.status!=="uploaded").length>=500)throw new Error("当前画布已达到 500 个节点。");
const node=makeCanvasNode(kind,center(),src);if(kind==='plain-image')node.data.title=file.name.slice(0,300);
return {owner:board.id,node};
});
// pendingUpload以node.id标记,后续资源完成/跨区移动/历史恢复都按这个身份找到占位。
const current=treeRef.current,doc=current.current.current.get(job.owner)!;
const node={...job.node,pendingUpload:job.node.id};
current.dispatch({type:"replace",document:{...doc,nodes:[...doc.nodes,node],edges:[...doc.edges,...(job.edge?[job.edge]:[])]}},job.owner);
setTool("select"); } catch(error) {setError(errorText(error));}
return;
}
// 演示不上传服务端,FileReader读取全部字节为Data URL;纯图片另解码初始尺寸,finally释放读取中UI状态。
setImageUploading(true);
try {
// 演示 FileReader/Image 没有卸载取消信号或 await 后身份检查;回调仍使用本次调用捕获的创建函数。
const src = await new Promise<string>((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();
image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); };
image.onerror = () => reject(new Error("无法读取图片尺寸。"));
image.src = src;
});
}
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); }
}
/** 旧版导入最多5MB并全量读取解码,替换当前根快照;任一方含真实子图时拒绝,避免破坏跨board关系。
* ioActive用于阻止迁移交接;这条路径不是新版渐进导入,也不会自动重建bundle子图。 */
async function importProject(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if(treeRef.current.isMigrationHeld()){setError("画布交接期间暂时不能导入。");return;}
ioActive.current=true;
try {
if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。");
if (tree.rootDocument.nodes.some((node) => node.data.subgraph)) throw new Error("当前画布包含真实子图,请先导出备份并通过子图事务恢复,避免覆盖子图关系。");
// await 前检查的是本次 render 的根;读完不重新核对子图关系/画布身份,tree.dispatch 准入只能覆盖其自身护栏。
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 : "导入失败。"); }
finally{ioActive.current=false;}
}
/** 有上传占位则先等待用户完成;树导出完整可达文档,多board写bundle,单board写当前视口。
* 下载名清理文件名非法字符,Blob URL延迟释放;不额外打包File/Blob,已有演示Data URL仍随src导出。 */
async function exportProject() {
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
try { if([...tree.current.current.values()].some((doc)=>doc.nodes.some((node)=>node.pendingUpload))) throw new Error("图片仍在上传,完成后即可导出。"); documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; }
// 多 board bundle 仅导出文档 JSON;importProject 仍只接收普通单文档,并不是这个 bundle 的对称恢复器。
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);
}
// 旧版全局快捷键:跳过输入框、文本编辑、对话框和打开的popover,支持撤销/重做与V/H;卸载移除。
useEffect(() => {
/** 按DOM目标过滤后处理键;此旧路径没有新版routeCanvasKey的组合输入/认领优先级模型。 */
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);
}, []);
// 上传失败行的“取消”只尝试移除占位节点,未直接 uploads.remove;任务/文件/预览寿命仍由上传 Hook 和卸载处理。
// Provider把图片预览、节点动作、分区反馈及活动连线接到renderer;迁移时surface与底部创作栏设inert。
// ReactFlow删除由onBeforeDelete拦截真实子图/暴露端口节点,避免默认删除破坏结构;普通元素沿tree变化入口。
// 选择工具允许拖动/连接,抓手只平移;Space临时平移、Shift部分框选,滚轮默认平移、修饰键缩放。
// 工具栏创建/模板/导入走上述入口,顶部视口操作保存本地viewport;AI只给选中且支持的节点显示。
// 迁移准入检查 UI 文件读取与上传;真正排空 writer/本地恢复由 prepareMigration 再检查。
// 恢复弹窗先根再子,逐项核查canRestore;迁移组件检查草稿、文件IO和未完成上传再排空writer。
return (
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
<ImagePreviews.Provider value={imagePreviews}>
<NodeEditing.Provider value={editing}>
<FrameFeedback.Provider value={frameFeedback}>
<EdgeEditing.Provider value={{ begin: beginEdit, patch: tree.patchEdge, active: new Set([...tree.views.edgeRefs].filter(([id, ref]) => ai.activeEdges.has(ref.boardId + "/" + ref.id)).map(([id]) => id)) }}>
<div className={styles.migrationSurface} inert={tree.migrationHeld}>
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
onBeforeDelete={async ({ nodes: removing }) => {
if (tree.busy || tree.transactionBusy) return false;
// 选中分区入口要先dissolve保留内容;内部节点若被父port引用,先移回父board再删,清理边界引用。
// 删除流程使用本次回调捕获的 tree 投影;await 后通过内存表补查,但没有全程冻结选择和历史的统一事务。
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]);
}
// 解散后owner可能变化,按原ID重新查唯一归属;找不到唯一目标就跳过,不误删同ID的其它节点。
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 : "删除未完成"); }
// 结构分支已自行执行删除/解散,阻止ReactFlow再按旧投影默认删一遍。
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 } }}>
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#c9bfb5" />
{ai.enabled && nodes.filter((node) => node.selected && ["text", "script", "plain-text", "image", "plain-image"].includes(node.data.kind)).map((node) => {
// AIPanel收到owner原始节点,显示ID仅用于NodeToolbar定位;save仍经tree.nodeAction回写owner。
const ref = tree.views.nodeRefs.get(node.id)!;
const owned = tree.documents.get(ref.boardId)?.nodes.find((item) => item.id === ref.id);
return owned && <AIPanel key={node.id} id={node.id} node={owned} workspace={board.workspace_id} boardId={ref.boardId} ai={ai} save={(config) => tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
})}
</ReactFlow>
</div>
</EdgeEditing.Provider>
</FrameFeedback.Provider>
</NodeEditing.Provider>
</ImagePreviews.Provider>
<div className={styles.topRight}>
<div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
<IconButton icon={Undo2} label="撤销" disabled={!tree.canUndo || tree.busy} onClick={() => dispatch({ type: "undo" })} />
<IconButton icon={Redo2} label="重做" disabled={!tree.canRedo || tree.busy} onClick={() => dispatch({ type: "redo" })} />
<span className={toolbar.divider} aria-hidden="true" />
<IconButton icon={Minus} label="缩小" onClick={() => changeView(() => flow.zoomOut({ duration }))} />
<button className={styles.zoomValue} onClick={() => changeView(() => flow.zoomTo(1, { duration }))} title="恢复 100% 缩放">{Math.round(zoom * 100)}%</button>
<IconButton icon={Plus} label="放大" onClick={() => changeView(() => flow.zoomIn({ duration }))} />
<IconButton icon={Maximize} label="适应画布" onClick={() => changeView(() => nodes.length ? flow.fitView({ padding: 0.2, duration }) : flow.setViewport({ x: 0, y: 0, zoom: 1 }, { duration }))} />
<span className={toolbar.divider} aria-hidden="true" />
<IconButton icon={Download} label="导出图谱" onClick={exportProject} />
</div>
</div>
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具" inert={tree.migrationHeld}>
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={tool === "select"} onClick={() => setTool("select")} />
<IconButton icon={Hand} label="抓手工具(H)" aria-pressed={tool === "hand"} onClick={() => setTool("hand")} />
<span className={toolbar.divider} aria-hidden="true" />
<IconButton icon={Type} label="添加纯文本" onClick={() => addNode("plain-text")} />
<IconButton icon={StickyNote} label="添加文本卡片" onClick={() => addNode("text")} />
<IconButton disabled={imageUploading || tree.busy} icon={ImagePlus} label="添加图片" onClick={() => { pendingImage.current = null; imageKind.current = "image"; imageInput.current?.click(); }} />
<IconButton disabled={imageUploading || tree.busy} icon={ImageIcon} label="添加纯图片" onClick={() => { pendingImage.current = null; imageKind.current = "plain-image"; imageInput.current?.click(); }} />
<IconButton icon={FileText} label="添加脚本" onClick={() => addNode("script")} />
<IconButton icon={Frame} label="添加框选区" onClick={() => addNode("frame")} />
<IconButton icon={Shapes} label="添加图标" aria-expanded={iconPicker} onClick={() => setIconPicker(!iconPicker)} />
<IconButton icon={Bot} label="AI 模式" aria-pressed={ai.enabled} onClick={() => ai.setEnabled(!ai.enabled)} />
<IconButton icon={Sparkles} label="插入创作模板" onClick={addTemplate} />
<span className={toolbar.divider} aria-hidden="true" />
<IconButton icon={Upload} label="导入图谱" onClick={() => projectInput.current?.click()} />
</div>
<div className={`${toolbar.surface} ${styles.helpToolbar}`}>
<IconButton icon={HelpCircle} label="画布操作帮助" popoverTarget="canvas-help" />
</div>
<div id="canvas-help" popover="auto" className={styles.helpPopup}>
<strong>自由创作,从移动画布开始</strong>
<p>拖动空白处平移,拖动节点移动位置。</p>
<p>触控板滚动平移;按 Ctrl / ⌘ 滚动或双指捏合缩放。</p>
<p>V 选择 · H 抓手 · 按住空格临时平移 · Shift 框选</p>
<p>拖动右下角调整大小;在样式栏开启旋转,拖动元素旋转,点击外部退出。Shift 吸附 15°。</p>
<p>纯文本可独立调整宽高,文字随宽度换行,字号在样式栏设置;不显示卡片连接点或四向加号。</p>
<p>拖动四边中心点创建贝塞尔连线;选中后点击连接点选择新节点类型。Delete 删除所选内容。</p>
<p>添加框选区后拖入内容加入;拖出退出。移动分区只带动已有成员,不收纳被覆盖的内容。选中分区可改名并选择名称显示模式。</p>
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
<p>可通过右上角导出 JSON 备份。</p>
</div>
{demoImageUploading && <p className={styles.imageUploadStatus} role="status">正在读取图片…</p>}
{uploads.jobs.filter((job)=>job.status!=="uploaded").map((job)=><NodeToolbar key={job.node.id} nodeId={uploadDisplayID(job)} isVisible position={Position.Top}><div className={`${styles.memberSync} nodrag nopan`} role="status" aria-label="图片上传状态">{job.status==="failed"?<>上传未完成 <button title={job.error} onClick={()=>uploads.retry(job.node.id)}>重试</button><button onClick={()=>{const current=treeRef.current;const owner=[...current.current.current].find(([,doc])=>doc.nodes.some((node)=>node.pendingUpload===job.node.id));if(owner)current.dispatch({type:"nodes",changes:[{type:"remove",id:job.node.id}]},owner[0]);}}>取消</button></>:"上传中…"}</div></NodeToolbar>)}
<BranchMenu request={branchRequest} close={closeBranch} choose={chooseBranch} />
{iconPicker && <IconPicker close={() => 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");
}} />}
{!feedback && <div className={styles.membershipNotice} role="status" aria-live="polite">{membershipNotice}</div>}
{!tree.migrationHeld && recovery && <DraftRecovery canRestore={canRestoreCanvasDraft(recovery, tree.rootDocument)} exportDraft={() => downloadDraft(recovery)} restore={() => { if(treeRef.current.isMigrationHeld())return;dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => {if(!treeRef.current.isMigrationHeld())setRecovery(null);}} />}
{!tree.migrationHeld && !recovery && childRecovery && <DraftRecovery canRestore={canRestoreCanvasDraft(childRecovery[1], tree.documents.get(childRecovery[0])!)} exportDraft={() => downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />}
{snapshot && sync.error && <div className={styles.syncError} role="alert"><span>{sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))}</span>
{sync.error !== "version_conflict" && <button onClick={() => void sync.retry()}>重试保存</button>}
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
</div>}
{tree.memberJobs.map((job, index) => job.ids.map((id) => {
// 成员任务提示跟随当前source/target中的场景节点,队首同步、后续等待,失败通过retryMembers继续。
const node = [...tree.views.nodeRefs].find(([, ref]) => ref.id === id && [job.source, job.target].includes(ref.boardId));
return node && <NodeToolbar key={`${job.source}/${id}`} nodeId={node[0]} isVisible position={Position.Top}>
<div className={`${styles.memberSync} nodrag nopan`} role="status" aria-label="元素同步状态" data-node-id={node[0]}>
{job.error ? <>移动未完成 <button onClick={tree.retryMembers}>重试</button></> : index ? "等待同步…" : "同步中…"}
</div>
</NodeToolbar>;
}))}
{migration&&<LegacyCanvasMigration context={migration} workspaceId={board.workspace_id} canvasId={board.id} registry={migration.registry} held={tree.migrationHeld} hold={tree.setMigrationHeld} prepare={tree.prepareMigration}
check={()=>{if(recovery||treeRef.current.recoveries.size)throw new Error('draft_recovery_required');if(ioActive.current||uploads.hasPending()||demoImageUploading)throw new Error('migration_pending_uploads');}}
canLeave={()=>![...treeRef.current.current.current].some(([id,doc])=>treeRef.current.writers.current.get(id)?.needsSave(doc))}
exportDraft={()=>downloadDraft(recovery??treeRef.current.rootDocument)} />}
{tree.busy && <div className={styles.canvasBusy} role="status">{tree.pendingCommand ? <div>同步结果尚未确认,为保护内容暂时停止编辑。<button onClick={() => void tree.retryPending().catch((error) => setError(error.message))}>重试同步</button><button onClick={() => window.location.reload()}>重新读取画布</button></div> : tree.migrationHeld?"正在检查或交接画布,编辑暂时暂停。":"正在同步画布…"}</div>}
{error && !feedback && <button className={styles.error} role="alert" onClick={() => setError("")} title="点击关闭提示">{error}</button>}
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={readImage} hidden />
<input ref={projectInput} type="file" accept="application/json,.json" onChange={importProject} hidden />
</div>
);
}
/** 为单个旧编辑器建立独立ReactFlow store;board/snapshot身份切换的重挂载由路由调用方负责。 */
export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} migration={migration} /></ReactFlowProvider>;
}