@@ -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 ?
:
正在准备画布…
;
}