679 lines
59 KiB
TypeScript
679 lines
59 KiB
TypeScript
/**
|
||
* 旧版主画布的界面装配与交互控制。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>;
|
||
}
|