Reuse canvas scenes across viewport and interaction metadata changes

This commit is contained in:
2026-11-09 21:46:58 +08:00
parent bb5bc9d8e9
commit 732c15f7bf
5 changed files with 69 additions and 5 deletions
@@ -9,7 +9,7 @@ import { CanvasProvider, CanvasRendererCommandsContext, type CanvasRendererComma
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 {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cache";
import {createSceneProjection} from "@/lib/canvas-bindings/scene-projection";
import {createConnectionPaintIndex} from "@/lib/canvas-bindings/connection-paint";
import {nativeRectVisible} from "@/lib/canvas-bindings/visibility";
import {createOverlayIndex} from "@/lib/canvas-bindings/overlays";
@@ -23,7 +23,7 @@ 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 { projectCanvas, toLocal, transformPath, type ProjectionNode } from "@/lib/canvas-bindings/projection";
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";
@@ -65,20 +65,21 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
const pointerStart = useRef<{ x: number; y: number;pointerId?:number } | null>(null), drag = useRef<{ token: number;pointerId?:number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
projectionCache.current ??= createProjectionCache();
const geometryCache=useRef<ReturnType<typeof createGeometryProjectionCache>|null>(null);geometryCache.current??=createGeometryProjectionCache();
const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]);
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id,{incarnation:number;groupId:Id}>());
useEffect(()=>{for(const [id,group]of groups.current)if(root.document.elements.get(id)?.incarnation!==group.incarnation)groups.current.delete(id);},[root.document]);
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));
const projection = useMemo(() => {
const result = projectCanvas(root.document, view.canvasId, engine.editor.registry, view.selection, engine.sessions.effectiveGeometry, sessions.connectionGesture,catalog,{queries:engine.editor.getQueries(root.document),geometry:geometryCache.current!,owner:engine});
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, sessions, view,renderers,catalog]);
}, [engine, root.document, scene,view.canvasId]);
const surface=useRef<HTMLDivElement>(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]);
const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]);
const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot);
@@ -0,0 +1,27 @@
import {fault} from '../canvas-core/values.ts';
import type {Document,Registry} from '../canvas-core/types.ts';
import type {SessionSnapshot,ViewState} from '../canvas-core/gestures.ts';
import type {DocumentQueries} from '../canvas-core/queries.ts';
import {createGeometryProjectionCache} from './geometry-cache.ts';
import {projectCanvas} from './projection.ts';
import type {RendererCatalog} from './renderers.ts';
/** One view's derived scene. Viewport/tool/mode and task status do not change scene inputs. */
export function createSceneProjection(){
let geometry=createGeometryProjectionCache();
type Inputs={owner:object;document:Document;registry:Registry;catalog:RendererCatalog;canvasId:ViewState['canvasId'];selection:ViewState['selection'];preview:NonNullable<SessionSnapshot['gesture']>['preview']|undefined;edgeId:string|undefined;path:NonNullable<SessionSnapshot['connectionGesture']>['path']|undefined};
let previous:Inputs|undefined,value:ReturnType<typeof projectCanvas>|undefined;
return Object.freeze({
project(owner:object,document:Document,view:ViewState,sessions:SessionSnapshot,registry:Registry,catalog:RendererCatalog,queries?:DocumentQueries){
if(queries&&queries.document!==document)throw fault('stale_query_snapshot');
if(queries&&queries.registry!==registry)throw fault('query_registry_changed');
const preview=sessions.gesture?.preview,connection=sessions.connectionGesture;
const next:Inputs={owner,document,registry,catalog,canvasId:view.canvasId,selection:view.selection,preview:preview?.size?preview:undefined,edgeId:connection?.target.id,path:connection?.path};
if(previous&&Object.keys(next).every(key=>Object.is(next[key as keyof Inputs],previous![key as keyof Inputs])))return value!;
// Read the captured session, not a newer live gesture during an older React render.
const result=projectCanvas(document,view.canvasId,registry,view.selection,id=>next.preview?.get(id),connection,catalog,{queries,geometry,owner});
previous=next;value=result;return result;
},
clear(){previous=undefined;value=undefined;geometry=createGeometryProjectionCache();},
});
}