diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx index 716a902..14c9bf4 100644 --- a/src/components/canvas/graph-canvas.tsx +++ b/src/components/canvas/graph-canvas.tsx @@ -1,3 +1,9 @@ +/** + * 旧版主画布的界面装配与交互控制。useCanvasTree拥有旧快照、writer、历史和子图事务,组件把其投影交给ReactFlow。 + * 本地state管理工具、模式、菜单与提示,refs保存拖动/连线起点及文件选择上下文;真实上传与演示Data URL分别处理。 + * 节点/连线组件通过Context调用适配动作,不直接修改投影;子图归属、端口连线和解散优先使用服务端能力与事务。 + * 迁移门闩暂停旧编辑和创作入口,给迁移协调组件提供检查、保存排空与备份;本文件不自行切换到新版engine。 + */ "use client"; import { useImageUploads } from "./use-image-uploads"; import {LegacyCanvasMigration} from "./legacy-canvas-migration"; @@ -41,15 +47,21 @@ 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: () =>
正在加载图标面板…
}); +/** 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(); const { zoom } = useViewport(); const reduceMotion = useReducedMotion(); const storageKey = canvasStorageKey(board.workspace_id, board.id); + // 服务端快照优先,另有不同本地内容则等待恢复选择;无快照可直接用草稿。 + // 视口独立读本地键并限制范围,损坏视口不阻断文档;草稿解码失败回退快照/空文档并提示。 const [initial] = useState(() => { try { const local = readLocalRecovery(localStorage, storageKey, snapshot); @@ -62,6 +74,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: } 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); @@ -72,26 +85,35 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: const [rotating, setRotating] = useState(null); const [draggedIds, setDraggedIds] = useState([]); const [membershipNotice, setMembershipNotice] = useState(""); + // 防节点拖动与多选拖动回调重复进入;起点按显示ID保存,连线屏幕起点用于区分点击与拉出新分支。 const dragSession = useRef<"idle" | "active" | "finishing">("idle"); const dragOrigins = useRef(new Map()); const draggingFrame = useRef(false); const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null); + // 图片分支先保留源节点/方向/位置,等文件选择返回;cancel事件要清它,不能沿用上次创建意图。 const pendingImage = useRef(null); + /** 稳定关闭回调供Portal菜单与画布事件共用,避免菜单因每次渲染重新安装监听。 */ const closeBranch = useCallback(() => setBranchRequest(null), []); const imageKind = useRef<"image" | "plain-image">("image"); const imageInput = useRef(null); const projectInput = useRef(null); const [recovery, setRecovery] = useState(initial.recovery); + // 根恢复待决时暂停保存;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) { @@ -102,38 +124,48 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: 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。 */ 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(); 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`; @@ -141,29 +173,40 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: 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) => { 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。 */ 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)!; @@ -174,7 +217,9 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: 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); @@ -184,11 +229,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: 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; @@ -201,11 +248,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: 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); @@ -218,6 +267,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: 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(); @@ -233,6 +283,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: ] } }); 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); @@ -242,6 +293,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: setDraggedIds(draggingFrame.current ? [] : [...dragOrigins.current.keys()]); setMembershipNotice(""); } + /** 只结束active拖动,先清拖动/保存暂停标记,再按真实位移计算归属;真实子图与旧frameId归属分路处理。 + * 异步移动失败保留错误提示,不宣称所有跨分区步骤一起回滚。 */ async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) { if (dragSession.current !== "active") return; const origins = new Map(dragOrigins.current); @@ -252,19 +305,23 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: 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(); 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(); 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; } @@ -272,6 +329,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: const key = ref.boardId + "/" + target, group = byOwner.get(key) || { ids: [], target }; group.ids.push(ref.id); byOwner.set(key, group); } } + // 不同owner组并行提出任务,每组沿父子路径逐跳等待;tree内部队列仍串行发送成员请求。 await Promise.all([...byOwner].map(async ([key, group]) => { let source = key.split("/")[0]; for (const hop of subgraphMovePath(tree.current.current, source, group.target)) { @@ -287,10 +345,12 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: } } } + /** 先把入口端口解析成实际内部节点;同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++) { @@ -303,6 +363,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: 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); @@ -316,11 +377,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: 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) { const file = event.target.files?.[0]; event.target.value = ""; @@ -331,6 +394,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: } 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; @@ -343,12 +407,14 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: 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 { const src = await new Promise((resolve, reject) => { @@ -370,6 +436,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: } catch (error) { pendingImage.current = null; setError(errorText(error)); } finally { setImageUploading(false); } } + /** 旧版导入最多5MB并全量读取解码,替换当前根快照;任一方含真实子图时拒绝,避免破坏跨board关系。 + * ioActive用于阻止迁移交接;这条路径不是新版渐进导入,也不会自动重建bundle子图。 */ async function importProject(event: ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; @@ -387,6 +455,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: } 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; 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; } @@ -399,7 +469,9 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: setTimeout(() => URL.revokeObjectURL(url), 1000); } + // 旧版全局快捷键:跳过输入框、文本编辑、对话框和打开的popover,支持撤销/重做与V/H;卸载移除。 useEffect(() => { + /** 按DOM目标过滤后处理键;此旧路径没有新版routeCanvasKey的组合输入/认领优先级模型。 */ const keydown = (event: KeyboardEvent) => { const target = event.target; if (!(target instanceof HTMLElement)) return; @@ -417,6 +489,11 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: return () => window.removeEventListener("keydown", keydown); }, []); + // Provider把图片预览、节点动作、分区反馈及活动连线接到renderer;迁移时surface与底部创作栏设inert。 + // ReactFlow删除由onBeforeDelete拦截真实子图/暴露端口节点,避免默认删除破坏结构;普通元素沿tree变化入口。 + // 选择工具允许拖动/连接,抓手只平移;Space临时平移、Shift部分框选,滚轮默认平移、修饰键缩放。 + // 工具栏创建/模板/导入走上述入口,顶部视口操作保存本地viewport;AI只给选中且支持的节点显示。 + // 恢复弹窗先根再子,逐项核查canRestore;迁移组件检查草稿、文件IO和未完成上传再排空writer。 return (
@@ -427,6 +504,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} onBeforeDelete={async ({ nodes: removing }) => { if (tree.busy || tree.transactionBusy) return false; + // 选中分区入口要先dissolve保留内容;内部节点若被父port引用,先移回父board再删,清理边界引用。 const linked = removing.filter((node) => node.data.subgraph); const parents = subgraphParents(tree.current.current); const referenced = removing.filter((node) => { @@ -436,6 +514,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: }); 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); @@ -445,6 +524,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: 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; @@ -461,6 +541,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: } if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留"); } catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); } + // 结构分支已自行执行删除/解散,阻止ReactFlow再按旧投影默认删一遍。 return false; }} onNodesChange={tree.nodesChanged} @@ -486,6 +567,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}> {ai.enabled && nodes.filter((node) => node.selected && ["text", "script", "plain-text", "image", "plain-image"].includes(node.data.kind)).map((node) => { + // 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 && tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />; @@ -558,6 +640,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
} {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 &&
@@ -577,6 +660,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: ); } +/** 为单个旧编辑器建立独立ReactFlow store;board/snapshot身份切换的重挂载由路由调用方负责。 */ export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) { return ; } diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index e116c89..8e0f320 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -1,3 +1,9 @@ +/** + * 新版主画布的React/ReactFlow装配。engine拥有文档、历史和运行事实,sessions拥有每视图选择/工具/手势;组件只保存显示缓存与面板状态。 + * 文档提交先推进场景索引,再按事件类型立即或逐帧发布;几何预览通过session进入投影,提交才产生文档修改。 + * 画布适配层负责DOM测量、命中、裁剪、焦点和资源显示,core不读取DOM;渲染组件通过动作上下文调用editor/runtime。 + * 外层只借用项目engine:挂载attach视图,卸载detach,不关闭工作区持有的项目或持久任务。 + */ "use client"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react"; @@ -40,9 +46,13 @@ import { UnsupportedNode, UnsupportedEdge } from "./unsupported-elements"; import { ImagePreviews } from "./canvas-image"; import styles from "./canvas.module.css"; +/** 把适配层样式等可序列化值规范化成JSON Data,去掉undefined等JSON不保留的属性;不处理循环或任意非JSON对象。 */ const json = (value: unknown): Data => JSON.parse(JSON.stringify(value)); +/** 已attach视图的内容组件;需要外层ReactFlowProvider与CanvasProvider。root/session是发布快照,current供回调读取最新投影。 */ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) { + // 场景索引按engine+view复用,提交订阅先advance变更,再通知显示,避免每次渲染重建整图索引。 const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]); + /** 用户字段修改/替换删除及undo/redo立即发布,保证受控输入及时回显;批量创建和后台修改允许合并到下一帧。 */ const subscribe = useCallback((notify: (immediate?:boolean) => void) => { let before=engine.getSnapshot().document; return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.document; @@ -52,16 +62,20 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi notify(immediate); }); }, [engine,sceneCache]); + // rootFrames只调度呈现通知,不延迟editor真实提交;sessionFrames独立合并手势/选择等视图变化。 const rootFrames=useMemo(()=>createFrameSource({subscribe,getSnapshot:engine.getSnapshot},{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine,subscribe]); const root = useSyncExternalStore(rootFrames.subscribe, rootFrames.getSnapshot, rootFrames.getSnapshot); const sessionFrames=useMemo(()=>createSessionFrameSource(engine.sessions,{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine]); const sessions = useSyncExternalStore(sessionFrames.subscribe, sessionFrames.getSnapshot, sessionFrames.getSnapshot); + // 视图被detach后保留最近view供本轮Hook计算,最终返回“画布已关闭”;不以lastView恢复已关闭会话。 const liveView = sessions.views.get(viewId), lastView = useRef(liveView); const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading); if (liveView) lastView.current = liveView; const view = liveView ?? lastView.current!, flow = useReactFlow(); + // 完整加载后允许普通创建;加载中要求loading版本支持且当前canvas覆盖完整,最终命令层仍再校验准入。 const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId); + // 加载预览首次适配只取当前canvas直接节点,避免展开后代把视口拉得过远;有交接视口时不再自动fit。 useEffect(()=>{ if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.canvases.has(view.canvasId))return; // Expanded descendants can extend far beyond their partition entry. Initial @@ -70,7 +84,9 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi if(!nodes.length)return; needsLoadingFit.current=false;void flow.fitView({nodes,padding:.2,maxZoom:1}); },[engine,flow,root.document,view.canvasId]); + // 复制准备任务来自runtime状态;copying防本地重复点击,两者共同禁用复制入口。 const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId); + // 焦点与epoch用于保留活动输入和排除迟到blur;切engine/view复位,移除renderer未触发blur时另检查isConnected。 const [focusedEditor, setFocusedEditor] = useState(null), focusEpoch = useRef(0); useEffect(() => { focusEpoch.current++; setFocusedEditor(null); return () => { focusEpoch.current++; }; }, [engine,viewId]); // Removing an active renderer need not dispatch a native blur. Retire only @@ -78,19 +94,26 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi useEffect(()=>{if(focusedEditor&&!focusedEditor.isConnected)setFocusedEditor(current=>current===focusedEditor?null:current);}); const [copying, setCopying] = useState(false); const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null); + // 选择转成id/incarnation后查询深复制可用性;渲染时的可用性仅用于UI,点击时再次捕获实际选择交runtime。 const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]); const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false); const [error, setError] = useState(""); + // resource显示状态带engine/view身份,旧显示器迟到回调不能为当前画布提供错误图片;sources不是文档资源表。 const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0}); const resourceView=useRef|null>(null); + // 拖动保存起始场景坐标、指针ID与会话token,positions只含顶层选中节点,防分区与成员重复位移。 const pointerStart = useRef<{ x: number; y: number;pointerId?:number } | null>(null), drag = useRef<{ token: number;pointerId?:number; start: { x: number; y: number }; positions: Map } | null>(null); + // 复用投影对象与测量结果,避免ReactFlow把纯元数据变化误当节点卸载,影响输入及连接点。 const projectionCache = useRef | null>(null); projectionCache.current ??= createProjectionCache(); const measurements = useRef(new Map()); + // groups按元素生命周期保存一次文本编辑的history分组ID,元素重建后清旧分组,不能跨incarnation合并编辑。 const imageInput = useRef(null), groups = useRef(new Map()); useEffect(()=>{for(const [id,group]of groups.current)if(root.document.elements.get(id)?.incarnation!==group.incarnation)groups.current.delete(id);},[root.document]); + // 加载期使用受限renderer catalog,完整后回到正常定义;sceneCache把文档、会话预览与查询组成显示场景。 const catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]); const scene=sceneCache.project(engine,root.document,view,sessions,engine.editor.registry,catalog,engine.editor.getQueries(root.document)); + // 仅在尺寸一致且投影缓存确认身份/变换允许时复用measured;离开场景的节点清测量条目。 const projection = useMemo(() => { const result = scene; // Preserve renderer measurements across metadata/preview updates. Dropping them @@ -100,36 +123,50 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; } return projectionCache.current!.reuse(engine,root.document,view.canvasId,result); }, [engine, root.document, scene,view.canvasId]); + // 每个view独立OverlayIndex,Portal控件通过索引贡献边界与焦点;不会把浮层几何存入项目文档。 const surface=useRef(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]); const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]); const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot); const surfaceBounds=surface.current?.getBoundingClientRect(); + // 绘制范围索引用于可见性筛选;sceneViewport把ReactFlow平移/缩放反算为场景矩形。 const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]); const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]); const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height); const sceneViewport={x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom}; + // 过滤元素生命周期已变的浮层;缺surface矩形时只按身份保守保留,不凭缺失测量断言不可见。 const visibleOverlays=overlaySnapshot.length?(surfaceBounds?overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation):overlaySnapshot.filter(entry=>root.document.elements.get(entry.target.id)?.incarnation===entry.target.incarnation)):[]; + // 未知绘制范围必须保留,加上矩形查询命中;不能把unknown当成屏幕外。 const paintedNodes=[...paint.unknown,...paint.query(sceneViewport)]; const nativeNodes=useStore(state=>state.nodes); const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]); + // 连线范围依赖真实Handle测量:新版/未知连线使用measuredConnector,兼容节点使用旧几何适配。 + // 订阅nativeNodes让测量更新后重算连线路径范围,不把DOM测量要求下放core。 const connectionPaint=useMemo(()=>connectionPaintCache.sync(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),[connectionPaintCache,projection,engine,flow,nativeNodes]); // Exhausted geometry work cannot prove any edge invisible. Retain the edge // layer and its endpoints, not unrelated nodes or a synthetic sentinel owner. const paintedEdges=connectionPaint.unknown.includes('paint_budget')?projection.edges.map(edge=>edge.id):[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)]; + // 保留调用方renderer表,并显式安装unsupported节点/边占位,未知内容仍可在场景中呈现。 const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]); + // 稳定动作闭包从current/engine读取最新状态,runtime-only发布不必重建全部卡片编辑上下文。 const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions }; + /** 将命令拒绝或能力不可用显示为错误;成功结果无需额外复制进本地state。 */ const report = useCallback((result: Result | { enabled: boolean; code?: string }) => { if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code))); }, []); + /** 发命令前读取当前incarnation,元素缺失即抛错;渲染侧更早的身份检查由useScopedNodeEditing等负责。 */ const target = (id: Id) => { const element = engine.getSnapshot().document.elements.get(id); if (!element) throw new Error('element_missing'); return { id, incarnation: element.incarnation }; }; + /** 统一元素data补丁入口,可携history groupId;schema、能力和事务合法性留editor验证。 */ const data = (id: Id, patch: Data, groupId?: Id) => report(engine.editor.dispatch({ type: 'element.data', target: target(id), patch }, { groupId })); + /** 把ReactFlow选择变化合到session当前Set,成员完全相同则不重复发布;不把selected写入文档。 */ function selectChanges(changes: readonly { id: Id; selected: boolean }[]) { const current = engine.sessions.getSnapshot().views.get(viewId)!; const ids = new Set(current.selection); for (const change of changes) if (change.selected) ids.add(change.id); else ids.delete(change.id); if (ids.size !== current.selection.size || [...ids].some(id => !current.selection.has(id))) engine.sessions.select(viewId, [...ids]); } + /** 空白点击补充core投影命中:12px容差、预览目标及实际Handle/layer适配;只选择confirmed命中。 + * 命中计算后再次比较document/session快照身份,过期帧不能选中新incarnation;抓手模式点击清选择。 */ function selectAtPane(event: {clientX:number;clientY:number}) { const captured=current.current; if(captured.view.tool!=='select'){engine.sessions.select(viewId,[]);return;} @@ -149,12 +186,16 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const hit=result.hits[0]; engine.sessions.select(viewId,hit?.certainty==='confirmed'?[hit.target.id]:[]); } + /** session.begin成功后捕获gesture token,以guardEditingLease约束后续preview/commit/cancel只作用于该交互。 */ function beginTransform(id: Id) { const result = engine.sessions.begin(viewId, [target(id)]); report(result); if (!result.enabled) return false; const token = engine.sessions.getSnapshot().gesture!.token; + /** 每次租约动作实时核对token,旧pointerup不能结束后来新建的手势。 */ const current = () => engine.sessions.getSnapshot().gesture?.token === token; return guardEditingLease(current, { preview: (change: Parameters[1]) => transform(id, change), commit: () => report(engine.sessions.commit(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') }); } + /** 将显示空间位置/尺寸转成owner局部Geometry:尺寸除scale,以中心toLocal后再求左上角,旋转减去父旋转。 + * 这里只更新session预览,不直接dispatch文档;后续commit统一形成history与同步修改。 */ function transform(id: Id, change: { position?: { x: number; y: number }; width?: number; height?: number; rotation?: number; fontSize?: number }) { const { root, projection } = current.current, element = root.document.elements.get(id); if (element?.structure !== 'node') return; @@ -165,16 +206,21 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const patch: Partial = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) }; report(engine.sessions.update(viewId, target(id), patch)); } + /** 创建内置节点,可加入调用方已有事务;frame同时创建以节点为入口的子canvas,保证结构一起提交。 + * 默认位置从首个data-headless-canvas的DOM矩形取屏幕中心再转场景坐标,at可显式覆盖。 */ function add(kind: 'text' | 'plain-text' | 'image' | 'frame', ref?: { id: Id; src: string }, tx?: Parameters[0]>[0], at?: { x: number; y: number }) { const id = crypto.randomUUID(), canvasId = current.current.view.canvasId; const rect = window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect(); const position = at ?? flow.screenToFlowPosition({ x: (rect?.left ?? 0) + (rect?.width ?? window.innerWidth) / 2, y: (rect?.top ?? 0) + (rect?.height ?? window.innerHeight) / 2 }); + // 各类型使用固定默认尺寸及初始incarnation/dataVersion;资源引用由已stage/adopt的ref注入,不在这里上传。 const size = kind === 'frame' ? { width: 640, height: 400 } : kind === 'plain-text' ? { width: 240, height: 72 } : { width: 260, height: 200 }; const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } }; + /** tx内创建元素及可选canvas关系;无外部tx时包装为一个Add element事务。 */ const create = (transaction: NonNullable) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); }; if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' })); return id; } + /** 选择工具才可建边;拒绝unsupported端点,将owner设为两端所属canvas的共同上级,端口ID来自连接事件。 */ function connect(connection: Connection) { if(engine.sessions.getSnapshot().views.get(viewId)?.tool!=='select')return; const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target); @@ -182,21 +228,30 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;} report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } })); } + // 用节点ID+assetId序列作为显示订阅键,资源分辨率/URL交资源视图决定;组件不把Blob URL写入元素data。 const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId])); + // 每个engine/view建立资源显示器,回调只接受当前实例;清理dispose解除订阅及显示资源占用。 useEffect(() => { if (!liveView) return; const view=createResourceView({resolve:(id,signal)=>engine.displayResource(id,{signal}),onChange:(sources,failures)=>{if(resourceView.current===view)setDisplayState({engine,viewId,sources,failures});},onError:error=>{if(resourceView.current===view)setError(errorText(error));}}); resourceView.current=view; return ()=>{if(resourceView.current===view)resourceView.current=null;view.dispose();}; }, [engine,viewId,Boolean(liveView)]); + // 资源目标集合变化才sync,映射失败在本面板报错,不擅自修改文档中的assetId。 useEffect(()=>{try{resourceView.current?.sync(JSON.parse(resourceKey) as [Id,Id][]);}catch(error){setError(errorText(error));}},[engine,viewId,resourceKey,Boolean(liveView)]); + // 再按engine/view及当前assetId过滤结果,节点切换资源时不显示上一个资源的迟到URL。 const sources=useMemo(()=>{if(displayState.engine!==engine||displayState.viewId!==viewId)return new Map();const wanted=new Map(JSON.parse(resourceKey) as [Id,Id][]);return new Map([...displayState.sources].filter(([id,source])=>wanted.get(id)===source.resourceId).map(([id,source])=>[id,source.url]));},[displayState,engine,viewId,resourceKey]); + /** 一次事务删除当前选择中仍存在的元素;关联结构处理由editor命令实现,不循环修改投影数组。 */ function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); } + // 同步显示来自ledger:未resolved算待处理;waiting_digest单独等待不算阻断,无定时重试的错误和conflicts要提示处理。 const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved'); const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0; + // 全局失焦或Escape仅取消本视图拥有的节点/连接手势;不干扰同engine其它视图的交互。 useEffect(() => { + /** 核对viewId后取消session并清本地拖动记录;window blur不要求指针事件。 */ const cancel = () => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('view_interrupted'); drag.current = null; }; // Bubble after the target/React handlers: an input owner can claim Escape first. + /** 冒泡阶段让输入控件优先认领Escape,再按统一路由决定是否取消手势。 */ const escape = (event: KeyboardEvent) => { if(event.key!=='Escape')return; const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{gesture:()=>{const state=engine.sessions.getSnapshot();if(state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;cancel();return true;}}); @@ -208,10 +263,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const modes = view.modes; // Retain explicit owners in the native renderer, leaving the full graph in its // lookup for edges/queries. No synthetic dragging or altered geometry is used. + // 保留模式目标、可见绘制对象、浮层目标和本视图手势;完整图仍留ReactFlow查询表,不以伪造拖动避裁剪。 const retained=new Set([...modes.keys(),...paintedNodes,...paintedEdges]); for(const entry of visibleOverlays)retained.add(entry.target.id); if(sessions.gesture?.viewId===viewId)for(const target of sessions.gesture.targets)retained.add(target.id); if(sessions.connectionGesture?.viewId===viewId)retained.add(sessions.connectionGesture.target.id); + // 活动输入可来自节点、连线、Portal浮层或独立UI;无法证明其归属时保守关闭可见性裁剪。 const activeEditor=focusedEditor?.isConnected?focusedEditor:null; const focusedOverlay=activeEditor?.closest('[data-canvas-overlay-target]'); const focusedUI=activeEditor?.closest('[data-canvas-ui-view]'); @@ -221,10 +278,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi if(focusedId)retained.add(focusedId); // Retained connections also keep their measured endpoint owners alive. for(const id of [...retained]){const element=root.document.elements.get(id);if(element?.structure==='connection'){retained.add(element.source.elementId);retained.add(element.target.elementId);}} + // 所有保留对象仍须存在于文档,且视口尺寸已知,才启用native onlyRenderVisibleElements。 const scoped=!(activeEditor&&!focusedId&&!independentFocus)&&[...retained].every(id=>root.document.elements.has(id)); const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped; const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]); const rendered=retainedProjection(projection.nodes,projection.edges,retained); + // 单选时才启用图片适配/AI工具;图片要求已知组件、resize能力和声明的assetId资源字段,不能猜同名业务字段。 const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined; const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined; const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture; @@ -233,15 +292,19 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi // Actions use current.current / engine snapshots instead of captured document projections. const rotating = [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null; const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null; + // 新版资源URL寿命由resourceView管理,CanvasImage的loaded回调这里无需删除旧版上传任务。 const imagePreviews = useMemo>(() => ({sources,loaded:()=>{}}),[sources]); + // 给旧节点renderer注入新版动作:begin/end维护文本history分组与editing mode,正文按同incarnation使用groupId。 const nodeEditing = useMemo>(() => ({ workspace: board.workspace_id, schema: 2, begin: id => { if (id) { groups.current.set(id,{incarnation:target(id).incarnation,groupId:crypto.randomUUID()}); engine.sessions.mode(viewId, id, 'editing'); } }, endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); }, select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }), beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); }, + // 样式和分区补丁走data,模式rotate/resize只在sessions中切换;开启一个前先清同视图该类旧模式。 style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)), rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); }, resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); }, + // 分支直接在当前canvas按方向偏移创建文本,再同一事务创建连线;与旧版弹类型菜单的行为不同。 branch: (id, direction) => { const node = projectionCache.current!.getNode(id); if (!node) return; const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0; @@ -249,19 +312,23 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi }, }),[engine,viewId,board.workspace_id,flow,report,rotating,resizing]); + // 曲线编辑同样捕获connectionGesture token;active暂为空集合,连线renderer在此不从run状态获取执行动画。 const edgeEditing = useMemo>(() => ({ active: new Set(), begin: id => { if (!id) return; const result = engine.sessions.beginConnection(viewId, target(id)); report(result); if (!result.enabled) return false; const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!; const transform = current.current.projection.transforms.get(element.ownerCanvasId)!; + /** token护栏绑定该条连接手势,预览路径先从场景空间变回owner局部空间。 */ const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token; return guardEditingLease(ownsGesture, { preview: (path: NonNullable) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') }); }, finish: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) report(engine.sessions.commitConnection(viewId)); }, cancel: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); }, + // 临时path只更新session;正式修改把data和path分开命令,在同一个事务提交,避免把路径写进业务data。 patch: (id, value, transient) => { const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return; const t = current.current.projection.transforms.get(element.ownerCanvasId)!; const path = transformPath((value.path ?? null) as Json, t, true); const displayedPath = current.current.projection.edges.find(edge => edge.id === id)?.data?.path; + // 工具栏可能传回整个显示data;只有显式含path且与显示路径不同才提交connection.path,避免无意重写。 const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath); if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; } const { path: ignored, ...rest } = value; void ignored; @@ -269,8 +336,15 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi }, }),[engine,viewId,report]); + // 自定义renderer的dispatch/transaction也经过editor,并统一report拒绝;选项只传label/groupId。 const rendererCommands=useMemo(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]); + // 本组件Hook全部调用后才判断视图关闭,保留React Hook调用顺序;已detach时不挂载交互surface。 if (!liveView) return

画布已关闭

; + // surface集中处理焦点epoch、拖动起点和键盘路由:blur微任务只清旧epoch,多触点取消当前手势。 + // 快捷键按输入上下文优先级处理Escape、H/V、撤销重做及删除;只有认领成功才阻止默认行为。 + // ReactFlow位移变化只转session预览,测量单独缓存;拖动结束依据起始位置补最后位移后commit。 + // 工具栏撤销/深复制/导出走各自editor/runtime接口,视口缩放只改view;导出完整文档JSON,不含文件字节。 + // 文件选择调用stageFiles,引用与图片节点在回调事务里一起创建;导入/AI/资源/恢复面板按加载与选择条件挂载。 return
{ if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }} onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setFocusedEditor(null); }); }} @@ -296,6 +370,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)} onNodeDragStart={(event, node, nodes) => { const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id)); + // 同时选中分区入口与后代时只对最上层目标启动手势,后代位置由结构投影带动。 const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); }); const point = 'clientX' in event ? event : event.changedTouches[0]; drag.current = { token: 0,pointerId:pointerStart.current?.pointerId, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, projectionCache.current!.getNode(id)!.position])) }; @@ -305,6 +380,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi onEdgesChange={changes => selectChanges(changes.filter(change => change.type === 'select'))} onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = projectionCache.current!.getNode(change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }} onNodeDragStop={event => { + // 仅结束匹配本视图和token的拖动,迟到dragStop不提交当前其它手势。 const saved = drag.current; drag.current = null; pointerStart.current = null; if (engine.sessions.getSnapshot().gesture?.viewId !== viewId || !saved || engine.sessions.getSnapshot().gesture?.token !== saved.token) return; const point = 'clientX' in event ? event : event.changedTouches[0], end = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }); @@ -318,6 +394,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
report(engine.editor.history.undo())} /> report(engine.editor.history.redo())} /> { + // 深复制点击时捕获选择身份,异步完成只更新同engine/view的面板;runtime负责进度与已加入内容。 const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation})); setCopying(true); setError(""); void engine.imports.duplicate(selection, {viewCanvasId: captured.view.canvasId}).catch(error => { if (current.current.engine === engine && current.current.view.id === viewId) setError(errorText(error)); }).finally(() => { if (current.current.engine === engine && current.current.view.id === viewId) setCopying(false); }); @@ -339,9 +416,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi {error &&
{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&}
}
; } +/** 挂载期生成稳定viewId并attach,借用initialViewport只初始化一次;detach只释放视图,不关闭engine。 + * 交接视口存在时禁用加载初次fit,避免从只读预览切入后可视位置突然跳变。 */ export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) { const [handoffViewport]=useState(initialViewport); const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false); + // attach完成后才挂载CanvasView所需Provider;清理时detach视图,CanvasView卸载清理自身订阅。 useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]); return attached ? :

正在准备画布…

; }