Reuse canvas scenes across viewport and interaction metadata changes
This commit is contained in:
@@ -563,3 +563,15 @@ still require a traversal, not a per-record ledger write-set update.
|
||||
Asset display effects keep their previous ownership, abort/release and retry
|
||||
rules. Durable-only completion/error/close is still read from the independent
|
||||
durability source, so saving and synchronization remain distinct.
|
||||
|
||||
The host's view-owned scene cache retains one current projection and its document,
|
||||
canvas, registry/catalog, selection, geometry preview, and connection target/path
|
||||
inputs. Viewport, tool, modes, task receipts and other views' selections do not
|
||||
rebuild that scene. Geometries are read from the captured session preview rather
|
||||
than a newer live session; shared previews still appear in every view. An empty
|
||||
node gesture and phase-only transitions do not require geometry projection.
|
||||
Changing a document, selection, preview, canvas or renderer recomputes the scene;
|
||||
this is not yet an element write-set driven traversal. Numeric geometry caches
|
||||
remain derived Maps, with no history or durable task ownership. Engine/view
|
||||
replacement discards this cache; `clear()` also drops the retained scene and
|
||||
numeric caches. Query provenance is validated even on a cache hit.
|
||||
|
||||
@@ -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();},
|
||||
});
|
||||
}
|
||||
@@ -108,6 +108,8 @@ try {
|
||||
if(!performanceMode){
|
||||
await copyFile(join(source,'tests/headless/renderer-extension.tsx'),join(directory,'src/components/canvas/renderer-fixture.tsx'));
|
||||
await copyFile(join(source,'tests/headless/react-hooks-fixture.tsx'),join(directory,'src/components/canvas/hooks-fixture.tsx'));
|
||||
// Count actual scene rebuilds in the isolated fixture copy; production has no diagnostic global.
|
||||
const sceneFile=join(directory,'src/lib/canvas-bindings/scene-projection.ts'),sceneSource=await readFile(sceneFile,'utf8');assert.ok(sceneSource.includes('const result=projectCanvas('));await writeFile(sceneFile,sceneSource.replace('const result=projectCanvas(',`const host=globalThis as typeof globalThis&{__fixtureSceneBuilds?:number};host.__fixtureSceneBuilds=(host.__fixtureSceneBuilds??0)+1;const result=projectCanvas(`));
|
||||
// A fixture Host action exercises the public engine API; no debug entry point ships in the app.
|
||||
const importsFile=join(directory,'src/components/canvas/headless-imports.tsx'),importsSource=await readFile(importsFile,'utf8');
|
||||
await writeFile(importsFile,importsSource.replace('import { useCallback,','import { useEffect, useCallback,').replace(' const subscribe =',` useEffect(()=>{const host=globalThis as typeof globalThis&{__headlessCollectSources?:()=>ReturnType<ProjectEngine['collectImportSources']>;__fixtureActualEngine?:ProjectEngine};const collect=()=>engine.collectImportSources({retentionMs:0});host.__headlessCollectSources=collect;host.__fixtureActualEngine=engine;return()=>{if(host.__headlessCollectSources===collect)delete host.__headlessCollectSources;};},[engine]);
|
||||
@@ -377,6 +379,15 @@ try {
|
||||
|
||||
const extensionRoot=id(810),meter={...node('meter-one',extensionRoot,350),componentId:'fixture:meter',data:{metric:3,raw:{retained:true}}},meterTwo={...meter,id:'meter-two',geometry:{...meter.geometry,x:750},data:{metric:9}},meterThree={...meter,id:'meter-three',geometry:{...meter.geometry,x:1150},data:{metric:12}},meterEdge={id:'meter-edge',incarnation:1,structure:'connection',componentId:'fixture:metric-edge',dataVersion:1,ownerCanvasId:extensionRoot,source:{elementId:meter.id,portId:'custom-out'},target:{elementId:meterTwo.id,portId:'custom-in'},data:{metric:55},path:{type:'metric-curve',retained:true}};
|
||||
documents.set(extensionRoot,createDocument(extensionRoot,[{id:extensionRoot,parent:null}],[meter,meterTwo,meterThree,meterEdge,{...meterEdge,id:'unrelated-meter-edge',source:{elementId:'meter-two',portId:'custom-out'},target:{elementId:'meter-three',portId:'custom-in'}},{...meterEdge,id:'meter-builtin-edge',componentId:'connection',data:{},path:null}]));versions.set(extensionRoot,1);boards.push({id:extensionRoot,workspace_id:workspace,kind:'board',version:1,title:'Extension board'});await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${extensionRoot}`});await until(`document.querySelectorAll('[data-renderer-fixture="meter"]').length===3&&document.querySelectorAll('[data-renderer-fixture="metric-edge"]').length===2`,'host-installed custom node/edge renderers did not mount');await until(`document.querySelectorAll('.react-flow__edge').length===3`,'built-in edge did not connect custom renderer ports');assert.equal(await evaluate(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length`),0);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension baseline did not settle');await pause(200);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersBefore=await evaluate('({...window.__fixtureRenders})');await click('Increase meter-one');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data,{metric:4,raw:{retained:true,edited:true}});assert.deepEqual(documents.get(extensionRoot).elements.get('meter-edge'),meterEdge);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension edit/ACK did not settle');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersAfter=await evaluate('({...window.__fixtureRenders})');assert.ok(rendersAfter['meter-one']>rendersBefore['meter-one'],'edited custom node did not render');assert.equal(rendersAfter['meter-two'],rendersBefore['meter-two'],'unrelated custom node rendered during edit/ACK');assert.equal(rendersAfter['unrelated-meter-edge'],rendersBefore['unrelated-meter-edge'],'unrelated custom edge rendered during edit/ACK');console.log('PASS actual registered renderer updates the edited node while unrelated node/edge render counts remain unchanged through ACK');
|
||||
await until(`window.__fixtureActualEngine?.projectId===${JSON.stringify(extensionRoot)}||window.__fixtureActualEngine?.getSnapshot().document.projectId===${JSON.stringify(extensionRoot)}`,'extension engine unavailable');
|
||||
const beforeScene=await evaluate('window.__fixtureSceneBuilds');assert.ok(beforeScene>0);
|
||||
await evaluate(`(()=>{const e=window.__fixtureActualEngine,v=[...e.sessions.getSnapshot().views.values()][0];window.__fixtureViewBefore={id:v.id,viewport:v.viewport,tool:v.tool};e.sessions.viewport(v.id,{x:80,y:45,zoom:1.2});e.sessions.tool(v.id,'pan');e.sessions.mode(v.id,'meter-one','projection-check');})()`);
|
||||
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');await pause(100);
|
||||
assert.equal(await evaluate('window.__fixtureSceneBuilds'),beforeScene,'viewport/tool/mode rebuilt the entire scene');
|
||||
await evaluate(`(()=>{const e=window.__fixtureActualEngine,v=window.__fixtureViewBefore;e.sessions.mode(v.id,'meter-one',null);e.sessions.tool(v.id,v.tool);e.sessions.viewport(v.id,v.viewport);delete window.__fixtureViewBefore;})()`);
|
||||
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');assert.equal(await evaluate('window.__fixtureSceneBuilds'),beforeScene);
|
||||
console.log('PASS actual CanvasView viewport/tool/mode transitions reuse the scene without projection traversal');
|
||||
|
||||
await click('撤销');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==3;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,3);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data.raw,{retained:true});await click('重做');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,4);await call('Page.reload');await until(`document.querySelector('[data-id="meter-one"] [data-renderer-fixture="meter"]')?.textContent.includes('Metric 4')&&!!document.querySelector('[data-renderer-fixture="metric-edge"]')`,'registered renderer edits did not recover');
|
||||
console.log('PASS Host-installed custom model/node/edge definitions render without core type switches; command-backed edits preserve raw fields and recover through undo/redo/reload');
|
||||
assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,before=engine.getSnapshot();window.__fixtureUnbound['meter-one'].rename('meter-one','unbound callback');return engine.getSnapshot()===before;})()`),true,'headless legacy editing without a rendered target was allowed');
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts';
|
||||
import {builtinRendererCatalog} from '../../src/lib/canvas-bindings/renderers.ts';
|
||||
import {createSceneProjection} from '../../src/lib/canvas-bindings/scene-projection.ts';
|
||||
const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
||||
function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],[node('F','root','frame'),node('A','child'),node('B'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor),scene=createSceneProjection(),owner={};sessions.attach('v');sessions.attach('w');let reads=0;const catalog={...builtinRendererCatalog,resolve(record){reads++;return builtinRendererCatalog.resolve(record);}};return{editor,sessions,scene,owner,registry,catalog,get reads(){return reads;},project(snapshot=sessions.getSnapshot(),document=editor.getSnapshot().document,scope=owner,renderers=catalog){return scene.project(scope,document,snapshot.views.get('v'),snapshot,registry,renderers,editor.getQueries(document));}};}
|
||||
test('viewport, tool, modes and other-view selection do no scene traversal; this-view selection does',()=>{const f=fixture(),first=f.project(),reads=f.reads;assert.ok(reads>0);f.sessions.viewport('v',{x:40,y:20,zoom:2});f.sessions.tool('v','pan');f.sessions.mode('v','B','editing');f.sessions.select('w',['A']);assert.strictEqual(f.project(),first);assert.equal(f.reads,reads);f.sessions.select('v',['B']);const selected=f.project();assert.equal(selected.nodes.find(n=>n.id==='B').selected,true);assert.notStrictEqual(selected,first);assert.ok(f.reads>reads);});
|
||||
test('captured geometry previews remain coherent even after live update, and are shared across views',()=>{const f=fixture(),first=f.project();f.sessions.begin('w',[{id:'F',incarnation:1}]);assert.strictEqual(f.project(),first);f.sessions.update('w',{id:'F',incarnation:1},{x:60});const captured=f.sessions.getSnapshot();f.sessions.update('w',{id:'F',incarnation:1},{x:120});const old=f.project(captured),live=f.project();assert.equal(old.nodes.find(n=>n.id==='F').position.x,60);assert.equal(live.nodes.find(n=>n.id==='F').position.x,120);assert.equal(live.nodes.find(n=>n.id==='A').position.x-old.nodes.find(n=>n.id==='A').position.x,60);f.sessions.cancel();assert.equal(f.project().nodes.find(n=>n.id==='F').position.x,0);});
|
||||
test('connection path preview and cancel invalidate, ownership-only phase does not',()=>{const f=fixture();f.sessions.beginConnection('w',{id:'edge',incarnation:1});const initial=f.project(),snapshot=f.sessions.getSnapshot();assert.strictEqual(f.project({...snapshot,connectionGesture:{...snapshot.connectionGesture,phase:'committing'}}),initial);const path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:40},in:{x:-10,y:0},out:{x:10,y:0}}]};assert.equal(f.sessions.updateConnection('w',path).enabled,true);const preview=f.project();assert.deepEqual(preview.edges[0].data.path,path);f.sessions.cancel();assert.notStrictEqual(f.project(),preview);assert.equal(f.project().edges[0].data.path,undefined);});
|
||||
test('document and renderer/scope replacement and clearing cannot retain stale scene data',()=>{const f=fixture(),first=f.project();assert.equal(f.editor.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'edited'}}).status,'applied');const next=f.project();assert.equal(next.document,f.editor.getSnapshot().document);assert.equal(next.nodes.find(n=>n.id==='B').data.text,'edited');assert.notStrictEqual(next,first);assert.notStrictEqual(f.project(undefined,undefined,{}),next);const previous=f.project(),custom={...f.catalog,resolve(element){const value=f.catalog.resolve(element);return value&&{...value,rendererId:'replacement'};}};assert.equal(f.project(undefined,undefined,undefined,custom).nodes[0].type,'replacement');f.scene.clear();assert.notStrictEqual(f.project(),previous);});
|
||||
|
||||
test('a cached scene does not hide an invalid query provenance',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),document=f.editor.getSnapshot().document;f.project();const project=queries=>f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,f.catalog,queries);assert.throws(()=>project({document:{...document},registry:f.registry}),/stale_query_snapshot/);assert.throws(()=>project({document,registry:{}}),/query_registry_changed/);assert.equal(f.project().document,document);});
|
||||
Reference in New Issue
Block a user