/** * 新版主画布的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"; import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, useViewport, useStore, type Connection,type Viewport } from "@xyflow/react"; import { Copy, Download, Frame, Hand, ImagePlus, Images, Maximize, Minus, Ratio, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react"; import type { Board } from "@/lib/workspace"; import type { ReactRenderers } from "@/lib/canvas-bindings/react-flow"; import { CanvasProvider, CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/lib/canvas-bindings/react-flow"; import { defaultCanvasRenderers } from "./canvas-renderers"; import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types"; import { equal } from "@/lib/canvas-core/values"; import {createSceneProjection} from "@/lib/canvas-bindings/scene-projection"; import {createConnectionPaintIndex} from "@/lib/canvas-bindings/connection-paint"; import {createRetainedProjection} from "./retained-projection"; import {createOverlayIndex} from "@/lib/canvas-bindings/overlays"; import {CanvasOverlays} from "./canvas-overlays"; import {createProjectedPaintIndex} from "@/lib/canvas-bindings/projected-paint"; import {createFrameSource} from '@/lib/canvas-bindings/frame-queue'; import {createSessionFrameSource} from "@/lib/canvas-bindings/session-frames"; import {routeCanvasKey,canvasKeyContext} from "@/lib/canvas-bindings/input-router"; import {hitProjectedCanvas} from "@/lib/canvas-bindings/hit-query"; import {compatConnector,measuredConnector} from "@/lib/canvas-bindings/connectors"; import {createLoadingRendererCatalog} from '@/lib/canvas-bindings/renderers'; import { createProjectionCache } from "@/lib/canvas-bindings/projection-cache"; import { createResourceView, type ResourceViewSource } from "@/lib/canvas-bindings/resource-view"; import { ancestors, commonCanvas } from "@/lib/canvas-core/document"; import { toLocal, transformPath, type ProjectionNode } from "@/lib/canvas-bindings/projection"; import type { ProjectEngine } from "@/lib/canvas-runtime/workspace"; import { errorText } from "@/lib/api"; import { IconButton } from "@/components/ui/icon-button"; import toolbar from "@/components/ui/toolbar.module.css"; import { guardEditingLease } from "./editing-lease"; import { NodeEditing } from "./transformable-node"; import { EdgeEditing } from "./edge-editing"; import { HeadlessAIPanel } from "./headless-ai-panel"; import { HeadlessResourcePicker } from "./headless-resource-picker"; import { HeadlessImports } from "./headless-imports"; import { HeadlessRecoveryDialog } from "./headless-recovery-dialog"; 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; // Controlled inputs must publish their new value before the native event // finishes. Batch creation/background work may share a presentation frame. const immediate=commit.origin==='undo'||commit.origin==='redo'||commit.origin==='user'&&commit.changes.some(change=>change.kind==='field'||change.before!==null); 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 // framing belongs to the direct members of this canvas. const nodes=engine.editor.getQueries(root.document).members(view.canvasId).filter(id=>root.document.elements.get(id)?.structure==='node').map(id=>({id})); 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 // that detached input, without clearing a newer focus acquired in the meantime. 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 // makes React Flow discard handle bounds and unmount active edge controls. const visibleIds = new Set(result.nodes.map(node => node.id)); for (const id of measurements.current.keys()) if (!visibleIds.has(id)) measurements.current.delete(id); 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发布不必重建全部卡片编辑上下文。 // current 是最近一次 React render 的快照,frame 合并期间可落后于 engine;需要强身份保证的入口仍须实时查询。 const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions }; /** 将命令拒绝或能力不可用显示为错误;成功结果无需额外复制进本地state。 */ // 这里只呈现 rejected/disabled,不捕获同步异常,也不在成功时自动清除旧错误文案。 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;} // Native handles stay in the renderer adapter; the core never reads DOM or guesses custom ports. const result=hitProjectedCanvas(captured.projection,engine.editor.getQueries(captured.root.document),engine.editor.registry,viewId,{ space:'scene',point:flow.screenToFlowPosition({x:event.clientX,y:event.clientY}),zoom:flow.getViewport().zoom,tolerancePx:12, },{ previewIds:new Set(captured.sessions.gesture?.targets.map(target=>target.id)), anchor:(node,handle,end,edge)=>{ const measured=flow.getInternalNode(node.id);if(!measured)return undefined; return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector({...measured,position:measured.internals.positionAbsolute},handle,end==='source'?'right':'left'); }, layer:(id,kind)=>kind==='node'?flow.getInternalNode(id)?.internals.z:undefined, }); // A possible hit is deliberately not selected; a stale frame must not select a new incarnation. if(engine.getSnapshot().document!==captured.root.document||engine.sessions.getSnapshot()!==captured.sessions)return; 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; const t = projection.transforms.get(element.ownerCanvasId)!; const displayed = projectionCache.current!.getNode(id)!; const width = (change.width ?? displayed.width!) / t.scale, height = (change.height ?? displayed.height!) / t.scale; const position = change.position ?? displayed.position, center = toLocal(t, { x: position.x + width * t.scale / 2, y: position.y + height * t.scale / 2 }); 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 } : {}) }; // 入参 fontSize 未进入这个 Geometry patch;字体大小修改应走单独 data/style 命令,而不是几何租约。 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 } } }); }; // 返回分配的 ID 不代表创建成功:无 tx 时这里只 report 拒绝,调用方若要依赖结果须检查事务状态。 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); if (!source || !destination) return; 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。 // 资源键只反映当前投影的 nodeId/assetId,身份和 URL 可用性由 resourceView/engine 重新核验。 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'); // “已同步”文案依据这些 ledger/冲突计数,不是所有资源解码、所有后台任务或远端生成全部完成的证明。 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;}}); if(claim==='gesture'){event.preventDefault();event.stopPropagation();} }; window.addEventListener('blur', cancel); window.addEventListener('keydown', escape); return () => { window.removeEventListener('blur', cancel); window.removeEventListener('keydown', escape); }; }, [engine, viewId]); 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]'); const independentFocus=focusedUI?.dataset.canvasUiView===viewId; const focusedNative=activeEditor?.closest('.react-flow__node,.react-flow__edge'); const focusedId=focusedOverlay?.dataset.canvasOverlayView===viewId?focusedOverlay.dataset.canvasOverlayTarget:focusedNative?.closest('[data-headless-canvas]')===surface.current?focusedNative?.dataset.id:undefined; 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; // canFitImage 在显示上因任意视图的 gesture 都禁用;真正命令能否执行仍由 engine.fitImage 再判断。 const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture; const aiElement = selected?.structure === 'node' && engine.editor.registry.resolve(selected) === 'known' && ['text','plain-text','script','image','plain-image'].includes(selected.componentId) ? selected : undefined; // Runtime-only publications must not invalidate every card's editing context. // 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中切换;开启一个前先清同视图该类旧模式。 // data 命令收到的是这次传入的 textStyle 对象;本层不读取并深合并旧样式,合并语义由 editor 定义。 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; report(engine.editor.transaction(tx => { const created = add('text', undefined, tx, { x: node.position.x + dx, y: node.position.y + dy }); const source = tx.getDocument().elements.get(id)!, next = tx.getDocument().elements.get(created)!; tx.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(tx.getDocument(), source.ownerCanvasId, next.ownerCanvasId), source: { elementId: id, portId: direction }, target: { elementId: created }, data: {}, path: null } }); }, { label: 'Add branch' })); }, }),[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局部空间。 */ // owner 的坐标 transform 在 begin 时捕获;token 护栏只核手势身份,归属变化是否合法由 sessions 校验。 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; report(engine.editor.transaction(tx => { tx.dispatch({ type: 'element.data', target: target(id), patch: json(rest) }); if (changedPath) tx.dispatch({ type: 'connection.path', target: target(id), path }); }, { label: 'Edit connection' })); }, }),[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]); // rendererCommands 直接委托 editor;它不是额外按 viewId/渲染目标绑定的权限层,扩展 renderer 必须遵守调用契约。 // 本组件Hook全部调用后才判断视图关闭,保留React Hook调用顺序;已detach时不挂载交互surface。 if (!liveView) return

画布已关闭

; // surface集中处理焦点epoch、拖动起点和键盘路由:blur微任务只清旧epoch,多触点取消当前手势。 // 快捷键按输入上下文优先级处理Escape、H/V、撤销重做及删除;只有认领成功才阻止默认行为。 // ReactFlow位移变化只转session预览,测量单独缓存;拖动结束依据起始位置补最后位移后commit。 // 工具栏撤销/深复制/导出走各自editor/runtime接口,视口缩放只改view;导出完整文档JSON,不含文件字节。 // 复制完成回调只比较 engine/viewId,未另设卸载信号;文件暂存、复制和导出均不因关闭面板而自动回滚。 // 文件选择调用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); }); }} onMouseDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:pointerStart.current?.pointerId}; }} onTouchStartCapture={event => { if (event.touches.length > 1) { const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId||state.connectionGesture?.viewId===viewId)engine.sessions.cancel('multi_touch');drag.current = null;pointerStart.current=null;return; } const touch = event.touches[0]; if (touch && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: touch.clientX, y: touch.clientY }),pointerId:pointerStart.current?.pointerId}; }} onPointerDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:event.pointerId}; }} onPointerCancelCapture={event => { const currentDrag=drag.current;if(!currentDrag||currentDrag.pointerId!==undefined&¤tDrag.pointerId!==event.pointerId)return;const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId&&state.gesture.token===currentDrag.token)engine.sessions.cancel('pointer_cancel');drag.current=null; }} onKeyDown={event => { const claim=routeCanvasKey({key:event.key,composing:event.nativeEvent.isComposing,...canvasKeyContext(event.nativeEvent),defaultPrevented:event.defaultPrevented,meta:event.metaKey,ctrl:event.ctrlKey,alt:event.altKey,shift:event.shiftKey},{ gesture:input=>{const state=engine.sessions.getSnapshot();if(input.key!=='Escape'||state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;engine.sessions.cancel('escape');drag.current=null;return true;}, tool:input=>{if(input.ctrl||input.meta||input.alt||input.shift)return false;const key=input.key.toLowerCase();if(key!=='h'&&key!=='v')return false;engine.sessions.tool(viewId,key==='h'?'pan':'select');return true;}, shortcut:input=>{if(input.alt)return false;if((input.meta||input.ctrl)&&input.key.toLowerCase()==='z'){report(input.shift?engine.editor.history.redo():engine.editor.history.undo());return true;}if(!input.meta&&!input.ctrl&&!input.shift&&(input.key==='Delete'||input.key==='Backspace')&¤t.current.view.selection.size){removeSelected();return true;}return false;}, }); if(claim==='gesture'||claim==='tool'||claim==='shortcut'){event.preventDefault();event.stopPropagation();} }}> elevateEdgesOnSelect onlyRenderVisibleElements={cullVisible} nodes={rendered.nodes} edges={rendered.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose} nodesConnectable={view.tool === 'select'} nodesDraggable={view.tool === 'select'} panOnDrag={view.tool === 'pan' ? true : [1, 2]} selectionOnDrag={view.tool === 'select'} panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} defaultViewport={view.viewport} onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)} onNodeDragStart={(event, node, nodes) => { // 起始位置表借用投影 position 引用;后续 preview 必须保持不可变更新,不能原地篡改这些起点。 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])) }; const result = engine.sessions.begin(viewId, top.map(target)); report(result); if (result.enabled) drag.current.token = engine.sessions.getSnapshot().gesture!.token; else drag.current = null; }} 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 }); for (const [id, start] of saved.positions) transform(id, { position: { x: start.x + end.x - saved.start.x, y: start.y + end.y - saved.start.y } }); report(engine.sessions.commit(viewId)); }} onConnect={connect} onPaneClick={selectAtPane}>
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); }); }} /> flow.zoomOut()} /> flow.zoomIn()} /> flow.fitView({ padding: .2 })} /> { const snapshot = engine.getSnapshot(), url = URL.createObjectURL(new Blob([JSON.stringify({ documentFormatVersion: 1, projectId: snapshot.document.projectId, canvases: [...snapshot.document.canvases.values()], elements: [...snapshot.document.elements.values()], tombstones: Object.fromEntries(snapshot.document.tombstones) })], { type: 'application/json' })); const a = window.document.createElement('a'); a.href = url; a.download = board.title + '.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }} />

{blocked ? '有修改需要处理' : loading ? `正在加载… ${loading.receivedElements}/${loading.totalElements},${loading.version>=3?"按已就绪范围同步":"修改已在本地排队"}` : pending.length ? '正在同步…' : '已同步'}{blocked && <>}

engine.sessions.tool(viewId, 'select')} /> engine.sessions.tool(viewId, 'pan')} /> {if(selected&&selectedResource)report(engine.fitImage({id:selected.id,incarnation:selected.incarnation},selectedResource.id));}} /> setShowAI(value => !value)} /> add('plain-text')} /> add('text')} /> imageInput.current?.click()} />{const rect=surface.current?.getBoundingClientRect();setResourcePicker({canvasId:view.canvasId,position:flow.screenToFlowPosition({x:(rect?.left??0)+(rect?.width??window.innerWidth)/2,y:(rect?.top??0)+(rect?.height??window.innerHeight)/2})});}} /> add('frame')} />
{ const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file, defaultSize: true })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} /> {(!loading||loading.version>=5)&&} {resourcePicker&&resourcePicker.canvasId===view.canvasId&&setResourcePicker(null)} onAdded={id=>engine.sessions.select(viewId,[id])} />} {showAI && aiElement && setShowAI(false)} />} {showRecovery && setShowRecovery(false)} />} {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 }) { // 初始视口只捕获一次;同一挂载内 props.initialViewport 变化不会重新定位,切项目应由宿主正确重建/切会话。 const [handoffViewport]=useState(initialViewport); const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false); // attach完成后才挂载CanvasView所需Provider;清理时detach视图,CanvasView卸载清理自身订阅。 // attach/viewport 在 effect 中同步执行,未捕获异常;attached 是显示门闩,不负责服务端加载或持久化就绪。 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 ? :

正在准备画布…

; }