Patch fixed-layout scene projections from coalesced element changes
This commit is contained in:
+9
-2
@@ -570,8 +570,15 @@ 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
|
||||
Field publications, selection changes and ordinary node/edge previews patch the
|
||||
changed projections through view-owned ID-to-array-position Maps. Continuous
|
||||
publication changes are coalesced in a dirty Set (up to 16384 IDs); missing change
|
||||
chains and canonical replacements compare document records and order first.
|
||||
Equivalent replacement records retain projected objects and transforms. Entry
|
||||
geometry/presentation, topology, membership/lifecycle, redirected endpoints and
|
||||
unsupported scenes retain full traversal. Canvas/renderer replacement also rebuilds.
|
||||
Array copying and existing paint/measurement/retention passes can still be linear;
|
||||
this is not a claim that the entire view is O(changed IDs). 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.
|
||||
|
||||
@@ -41,7 +41,11 @@ import styles from "./canvas.module.css";
|
||||
|
||||
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
|
||||
function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) {
|
||||
const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
|
||||
const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]);
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
let before=engine.getSnapshot().document;
|
||||
return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.document;notify();});
|
||||
}, [engine,sceneCache]);
|
||||
const root = useSyncExternalStore(subscribe, engine.getSnapshot, engine.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);
|
||||
@@ -65,7 +69,6 @@ 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 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]);
|
||||
|
||||
@@ -16,6 +16,30 @@ export type ProjectionNode = {id:Id;type:string;position:Point;width?:number;hei
|
||||
export type ProjectionEdge={id:Id;type?:string;source:Id;target:Id;sourceHandle?:string|null;targetHandle?:string|null;selected?:boolean;zIndex?:number;data:EdgeData&{__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
|
||||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
|
||||
/** Construct one node. Scene updates and full traversal share exactly the same projection rules. */
|
||||
export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,canvasId:Id,registry:Registry,selected:{has(id:Id):boolean},geometry:Geometry|undefined,transform:Transform,depth:number,child:Id|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionNode{
|
||||
const g = geometry ?? element.geometry, projectedGeometry=cache?.node(element.id,element.incarnation,g,transform),center = projectedGeometry?undefined:toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 });
|
||||
const width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, resolved=registry.resolve(element),known=resolved==='known';
|
||||
const renderer=renderers.resolve(element);
|
||||
const fallback:FallbackProjection|undefined=!known||!renderer?{reason:resolved==='known'?'renderer-unavailable':resolved,componentId:element.componentId,dataVersion:element.dataVersion,handles:[]}:undefined;
|
||||
const type = fallback ? 'unsupported' : renderer!.rendererId;
|
||||
const data = element.data;
|
||||
const projected: ProjectionNode = { id: element.id, type, position: projectedGeometry?{...projectedGeometry.position}:{ x: center!.x - width / 2, y: center!.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2,
|
||||
style: { width, height, ...(element.componentId === 'frame'&&!fallback&&renderer?.projection==='compat-v1' ? { pointerEvents: 'none' as const } : {}) },
|
||||
data: { ...(fallback?{}:data), __headlessTarget:Object.freeze({id:element.id,incarnation:element.incarnation,ownerCanvasId:element.ownerCanvasId,canvasId:canvasId}), kind: element.componentId as CanvasNodeData['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNodeData,
|
||||
};
|
||||
return projected;
|
||||
}
|
||||
|
||||
/** Endpoints are already resolved to their displayed node IDs by the caller. */
|
||||
export function projectEdge(element:Extract<ElementRecord,{structure:'connection'}>,source:Id,target:Id,registry:Registry,selected:{has(id:Id):boolean},transform:Transform,edgePreview:{target:{id:Id};path:Json}|null|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionEdge{
|
||||
const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer;
|
||||
const path = fallbackEdge||renderer?.projection==='record-v1' ? undefined : (cache?cache.edge(element.id,element.incarnation,edgePreview?.target.id===element.id?edgePreview.path:element.path,transform):transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transform));
|
||||
const renderPath = path!==undefined&&connectionPathValid(path) ? path : undefined;
|
||||
const { path: unusedPath, ...edgeData } = element.data; void unusedPath;
|
||||
return { id: element.id, ...(fallbackEdge?{type:'unsupported'}:renderer!.rendererId==='default'?{}:{type:renderer!.rendererId}), source, target, sourceHandle: source === element.source.elementId ? element.source.portId : undefined, targetHandle: target === element.target.elementId ? element.target.portId : undefined, selected: selected.has(element.id), data: fallbackEdge?{}:renderer?.projection==='record-v1'?{__headlessRecord:element,__headlessTransform:transform}:{ ...edgeData, ...(renderPath ? { path: renderPath } : {}) } as EdgeData };
|
||||
}
|
||||
|
||||
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
|
||||
export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet<Id> | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog,services:{queries?:DocumentQueries;geometry?:GeometryProjectionCache;owner?:object}={}) {
|
||||
if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]);
|
||||
@@ -29,16 +53,8 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
||||
const visit = (id: Id, depth: number) => {
|
||||
const transform = transforms.get(id)!;
|
||||
for (const element of getMembers(id)) if (element.structure === 'node') {
|
||||
const g = geometry(element.id) ?? element.geometry, projectedGeometry=cache?.node(element.id,element.incarnation,g,transform),center = projectedGeometry?undefined:toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 });
|
||||
const width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, known = registry.resolve(element) === 'known';
|
||||
const resolved=registry.resolve(element),renderer=renderers.resolve(element);
|
||||
const fallback:FallbackProjection|undefined=!known||!renderer?{reason:resolved==='known'?'renderer-unavailable':resolved,componentId:element.componentId,dataVersion:element.dataVersion,handles:[]}:undefined;
|
||||
const type = fallback ? 'unsupported' : renderer!.rendererId;
|
||||
const child = queries?queries.relations.childCanvasByEntry.get(element.id):childByEntry.get(element.id), data = element.data;
|
||||
const projected: ProjectionNode = { id: element.id, type, position: projectedGeometry?{...projectedGeometry.position}:{ x: center!.x - width / 2, y: center!.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2,
|
||||
style: { width, height, ...(element.componentId === 'frame'&&!fallback&&renderer?.projection==='compat-v1' ? { pointerEvents: 'none' as const } : {}) },
|
||||
data: { ...(fallback?{}:data), __headlessTarget:Object.freeze({id:element.id,incarnation:element.incarnation,ownerCanvasId:element.ownerCanvasId,canvasId:canvasId}), kind: element.componentId as CanvasNodeData['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNodeData,
|
||||
};
|
||||
const g=geometry(element.id)??element.geometry,child=queries?queries.relations.childCanvasByEntry.get(element.id):childByEntry.get(element.id),data=element.data;
|
||||
const projected=projectNode(element,canvasId,registry,selected,g,transform,depth,child,renderers,cache),fallback=projected.data.__headlessFallback;
|
||||
nodes.push(projected); visible.add(element.id);
|
||||
if (child) {
|
||||
const presentation = object(data.presentation);
|
||||
@@ -56,11 +72,7 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
||||
const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? '');
|
||||
const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId);
|
||||
if (!source || !target || source === target) continue;
|
||||
const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer;
|
||||
const path = fallbackEdge||renderer?.projection==='record-v1' ? undefined : (cache?cache.edge(element.id,element.incarnation,edgePreview?.target.id===element.id?edgePreview.path:element.path,transforms.get(element.ownerCanvasId)!):transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transforms.get(element.ownerCanvasId)!));
|
||||
const renderPath = path!==undefined&&connectionPathValid(path) ? path : undefined;
|
||||
const { path: unusedPath, ...edgeData } = element.data; void unusedPath;
|
||||
edges.push({ id: element.id, ...(fallbackEdge?{type:'unsupported'}:renderer!.rendererId==='default'?{}:{type:renderer!.rendererId}), source, target, sourceHandle: source === element.source.elementId ? element.source.portId : undefined, targetHandle: target === element.target.elementId ? element.target.portId : undefined, selected: selected.has(element.id), data: fallbackEdge?{}:renderer?.projection==='record-v1'?{__headlessRecord:element,__headlessTransform:transforms.get(element.ownerCanvasId)}:{ ...edgeData, ...(renderPath ? { path: renderPath } : {}) } as EdgeData });
|
||||
edges.push(projectEdge(element,source,target,registry,selected,transforms.get(element.ownerCanvasId)!,edgePreview,renderers,cache));
|
||||
}
|
||||
// Handles come from known envelope endpoints, never from opaque component payloads.
|
||||
const nodesById=new Map(nodes.map(node=>[node.id,node]));
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type {Document,Id,Registry} from '../canvas-core/types.ts';
|
||||
import type {SessionSnapshot,ViewState} from '../canvas-core/gestures.ts';
|
||||
import {equal} from '../canvas-core/values.ts';
|
||||
import {projectNode,projectEdge,type projectCanvas} from './projection.ts';
|
||||
import type {RendererCatalog} from './renderers.ts';
|
||||
|
||||
type Projection=ReturnType<typeof projectCanvas>;
|
||||
export type SceneChangeHint={from:Document;to:Document;ids:Set<Id>;topology:boolean};
|
||||
type Preview=NonNullable<SessionSnapshot['gesture']>['preview']|undefined;
|
||||
export type SceneInputs={owner:object;document:Document;registry:Registry;catalog:RendererCatalog;canvasId:Id;selection:ViewState['selection'];preview:Preview;edgeId:Id|undefined;path:NonNullable<SessionSnapshot['connectionGesture']>['path']|undefined};
|
||||
export function indexScene(projection:Projection){
|
||||
const nodes=new Map(projection.nodes.map((node,index)=>[node.id,index])),edges=new Map(projection.edges.map((edge,index)=>[edge.id,index])),entries=new Map<Id,Id>(),depths=new Map<Id,number>([[projection.canvasId,0]]);
|
||||
for(const canvas of projection.document.canvases.values())if(canvas.parent)entries.set(canvas.parent.entryElementId,canvas.id);
|
||||
for(const id of projection.transforms.keys())if(id!==projection.canvasId){const parent=projection.document.canvases.get(id)?.parent;if(parent&&depths.has(parent.canvasId))depths.set(id,depths.get(parent.canvasId)!+1);}
|
||||
return{nodes,edges,entries,depths,unsupported:projection.nodes.some(node=>!!node.data.__headlessFallback)||projection.edges.some(edge=>edge.type==='unsupported')};
|
||||
}
|
||||
/** A checked fixed-layout update. Structural/visibility changes keep the complete traversal. */
|
||||
export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,next:SceneInputs,previous:SceneInputs,connection:SessionSnapshot['connectionGesture'],hint?:SceneChangeHint):Projection|undefined{
|
||||
const {document,selection,preview,registry,catalog,canvasId}=next;
|
||||
const {selection:oldSelection,preview:oldPreview,edgeId:oldEdgeId,path:oldPath}=previous;
|
||||
if(layout.unsupported||document.projectId!==base.document.projectId)return;
|
||||
const dirty=new Set<Id>();
|
||||
if(document!==base.document){
|
||||
if(hint?.from===base.document&&hint.to===document){if(hint.topology)return;for(const id of hint.ids)dirty.add(id);}
|
||||
else{
|
||||
// Canonical replacement and missed publications carry no trustworthy write set.
|
||||
if(document.canvases.size!==base.document.canvases.size)return;
|
||||
const canvasOrder=base.document.canvases.keys();
|
||||
for(const[id,canvas]of document.canvases)if(id!==canvasOrder.next().value||!equal(canvas,base.document.canvases.get(id)))return;
|
||||
if(document.elements.size!==base.document.elements.size)return;
|
||||
const elementOrder=base.document.elements.keys();
|
||||
for(const[id,record]of document.elements){if(id!==elementOrder.next().value)return;if(record!==base.document.elements.get(id)&&!equal(record,base.document.elements.get(id)))dirty.add(id);}
|
||||
}
|
||||
}
|
||||
if(selection!==oldSelection){for(const id of oldSelection)if(!selection.has(id))dirty.add(id);for(const id of selection)if(!oldSelection.has(id))dirty.add(id);}
|
||||
if(preview!==oldPreview){for(const[id,g]of oldPreview??[])if(!equal(g,preview?.get(id)))dirty.add(id);for(const[id,g]of preview??[])if(!equal(g,oldPreview?.get(id)))dirty.add(id);}
|
||||
if(connection?.target.id!==oldEdgeId||connection?.path!==oldPath){if(oldEdgeId)dirty.add(oldEdgeId);if(connection)dirty.add(connection.target.id);}
|
||||
// Validate the whole dirty footprint before constructing or publishing any replacement.
|
||||
for(const id of dirty){const before=base.document.elements.get(id),after=document.elements.get(id);
|
||||
if(!before||!after||before.incarnation!==after.incarnation||before.structure!==after.structure||before.componentId!==after.componentId||before.dataVersion!==after.dataVersion||before.ownerCanvasId!==after.ownerCanvasId)return;
|
||||
if(before.structure==='connection'&&after.structure==='connection'&&(!equal(before.source,after.source)||!equal(before.target,after.target)))return;
|
||||
if(before.structure==='node'&&after.structure==='node'&&layout.entries.has(id)&&(!equal(before.geometry,after.geometry)||!equal(before.data.presentation,after.data.presentation)||!equal(oldPreview?.get(id),preview?.get(id))))return;
|
||||
}
|
||||
let nodes=base.nodes,edges=base.edges;
|
||||
for(const id of dirty){const element=document.elements.get(id)!;
|
||||
if(element.structure==='node'){
|
||||
const index=layout.nodes.get(id);if(index===undefined)continue;
|
||||
const transform=base.transforms.get(element.ownerCanvasId)!,depth=layout.depths.get(element.ownerCanvasId)!;
|
||||
const node=projectNode(element,canvasId,registry,selection,preview?.get(id),transform,depth,layout.entries.get(id),catalog);
|
||||
if(nodes===base.nodes)nodes=[...nodes];nodes[index]=node;
|
||||
}else{
|
||||
const index=layout.edges.get(id);if(index===undefined)continue;const prior=base.edges[index];
|
||||
const edge=projectEdge(element,prior.source,prior.target,registry,selection,base.transforms.get(element.ownerCanvasId)!,connection,catalog);
|
||||
if(edges===base.edges)edges=[...edges];edges[index]=edge;
|
||||
}
|
||||
}
|
||||
return nodes===base.nodes&&edges===base.edges&&document===base.document?base:{...base,document,nodes,edges};
|
||||
}
|
||||
@@ -1,27 +1,40 @@
|
||||
import {fault} from '../canvas-core/values.ts';
|
||||
import type {Document,Registry} from '../canvas-core/types.ts';
|
||||
import type {Change,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 {indexScene,patchScene,type SceneChangeHint,type SceneInputs} from './scene-changes.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;
|
||||
|
||||
let previous:SceneInputs|undefined,value:ReturnType<typeof projectCanvas>|undefined,layout:ReturnType<typeof indexScene>|undefined,pending:SceneChangeHint|undefined;
|
||||
return Object.freeze({
|
||||
advance(before:Document,after:Document,changes:readonly Change[]){
|
||||
if(before===after)return;
|
||||
if(!previous||previous.document===after){pending=undefined;return;}
|
||||
if(pending?.to!==before){pending=previous.document===before?{from:before,to:after,ids:new Set(),topology:false}:undefined;}
|
||||
if(!pending)return;pending.to=after;
|
||||
if(!changes.length){pending=undefined;return;}
|
||||
for(const change of changes){if(change.kind==='canvas'||change.kind==='element'){pending.topology=true;break;}pending.ids.add(change.target.id);if(pending.ids.size>16384){pending.topology=true;break;}}
|
||||
if(pending.topology)pending.ids.clear();
|
||||
},
|
||||
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!;
|
||||
const next:SceneInputs={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 SceneInputs],previous![key as keyof SceneInputs])))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;
|
||||
const compatible=previous&&value&&layout&&previous.owner===owner&&previous.registry===registry&&previous.catalog===catalog&&previous.canvasId===view.canvasId;
|
||||
const patched=compatible?patchScene(value!,layout!,next,previous!,connection,pending):undefined;
|
||||
const result=patched??projectCanvas(document,view.canvasId,registry,view.selection,id=>next.preview?.get(id),connection,catalog,{queries,geometry,owner});
|
||||
if(!patched)layout=indexScene(result);
|
||||
previous=next;value=result;pending=undefined;return result;
|
||||
},
|
||||
clear(){previous=undefined;value=undefined;geometry=createGeometryProjectionCache();},
|
||||
clear(){previous=undefined;value=undefined;layout=undefined;pending=undefined;geometry=createGeometryProjectionCache();},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,7 +109,8 @@ try {
|
||||
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(`));
|
||||
const sceneFile=join(directory,'src/lib/canvas-bindings/scene-projection.ts'),sceneSource=await readFile(sceneFile,'utf8');assert.ok(sceneSource.includes('const result=patched??projectCanvas('));await writeFile(sceneFile,sceneSource.replace('const result=patched??projectCanvas(',`const host=globalThis as typeof globalThis&{__fixtureSceneBuilds?:number};if(!patched)host.__fixtureSceneBuilds=(host.__fixtureSceneBuilds??0)+1;const result=patched??projectCanvas(`));
|
||||
const projectionFile=join(directory,'src/lib/canvas-bindings/projection.ts'),projectionSource=await readFile(projectionFile,'utf8');assert.ok(projectionSource.includes(':ProjectionNode{'));await writeFile(projectionFile,projectionSource.replace(':ProjectionNode{',`:ProjectionNode{const host=globalThis as typeof globalThis&{__fixtureProjectedNodes?:Record<string,number>};host.__fixtureProjectedNodes??={};host.__fixtureProjectedNodes[element.id]=(host.__fixtureProjectedNodes[element.id]??0)+1;`));
|
||||
// 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]);
|
||||
@@ -378,7 +379,7 @@ try {
|
||||
console.log('PASS unknown types and future versions render safe read-only placeholders with custom/partition connections, preserve opaque payloads through edits and recovery, and never load opaque asset fields');
|
||||
|
||||
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');
|
||||
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})'),projectsBefore=await evaluate('({...window.__fixtureProjectedNodes})');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');const projectsAfter=await evaluate('({...window.__fixtureProjectedNodes})');assert.ok(projectsAfter['meter-one']>projectsBefore['meter-one']);assert.equal(projectsAfter['meter-two'],projectsBefore['meter-two'],'unrelated node projection rebuilt during edit/ACK');assert.equal(projectsAfter['meter-three'],projectsBefore['meter-three'],'unrelated node projection rebuilt 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');})()`);
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 {projectCanvas} from '../../src/lib/canvas-bindings/projection.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));}};}
|
||||
@@ -11,3 +12,40 @@ test('connection path preview and cancel invalidate, ownership-only phase does n
|
||||
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);});
|
||||
|
||||
test('continuous field publications rebuild only their dirty node, including several cuts before a frame',()=>{const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});const initial=f.project(),reads=f.reads;for(const [id,text]of [['B','one'],['B','two']])assert.equal(f.editor.dispatch({type:'element.data',target:{id,incarnation:1},patch:{text}}).status,'applied');const next=f.project();assert.equal(f.reads-reads,1);assert.equal(next.nodes.find(n=>n.id==='B').data.text,'two');assert.strictEqual(next.nodes.find(n=>n.id==='A'),initial.nodes.find(n=>n.id==='A'));assert.strictEqual(next.edges,initial.edges);stop();});
|
||||
test('equivalent canonical clones retain every projected object and numeric transform',()=>{const f=fixture(),first=f.project(),reads=f.reads;const document=createDocument('p',[...first.document.canvases.values()],[...first.document.elements.values()]);const snapshot=f.sessions.getSnapshot(),result=f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,f.catalog);assert.equal(f.reads,reads);assert.strictEqual(result.nodes,first.nodes);assert.strictEqual(result.edges,first.edges);assert.strictEqual(result.transforms,first.transforms);assert.strictEqual(result.document,document);});
|
||||
test('a missed publication cannot let an incomplete change hint hide a newer document',()=>{const f=fixture(),initial=f.project();f.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'missed'}});const middle=f.editor.getSnapshot().document;let later;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(middle,next.document,commit.changes);later=next.document;});f.editor.dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'seen'}});const result=f.project();assert.notStrictEqual(result,initial);assert.strictEqual(result.document,later);assert.equal(result.nodes.find(n=>n.id==='A').data.text,'missed');assert.equal(result.nodes.find(n=>n.id==='B').data.text,'seen');stop();});
|
||||
|
||||
|
||||
test('mixed commands, hierarchy changes, selection and gestures match full traversal at every cut',()=>{
|
||||
const f=fixture();let before=f.editor.getSnapshot().document;const stop=f.editor.subscribe((commit,next)=>{f.scene.advance(before,next.document,commit.changes);before=next.document;});
|
||||
const compare=()=>{const document=f.editor.getSnapshot().document,snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),expected=projectCanvas(document,view.canvasId,f.registry,view.selection,id=>snapshot.gesture?.preview.get(id),snapshot.connectionGesture,f.catalog,{queries:f.editor.getQueries(document)});assert.deepEqual(f.project(),expected);};
|
||||
const dispatch=command=>{const result=f.editor.dispatch(command);assert.equal(result.status,'applied',JSON.stringify({command,result}));compare();};compare();
|
||||
for(let i=0;i<20;i++){
|
||||
dispatch({type:'element.data',target:{id:'B',incarnation:1},patch:{text:'step'+i}});
|
||||
dispatch({type:'element.geometry',target:{id:'A',incarnation:1},patch:{x:i*2,rotation:i*3}});
|
||||
f.sessions.select('v',i%2?['B']:['A','edge']);compare();
|
||||
f.sessions.begin('w',[{id:'A',incarnation:1}]);f.sessions.update('w',{id:'A',incarnation:1},{y:30+i});compare();f.sessions.cancel();compare();
|
||||
dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'frame'+i}});
|
||||
dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:i*5}});
|
||||
dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:i%2===0,contentScale:1+i/10}}});
|
||||
}
|
||||
dispatch({type:'element.create',element:node('C')});dispatch({type:'connection.endpoints',target:{id:'edge',incarnation:1},destination:{elementId:'C'}});
|
||||
dispatch({type:'element.move',target:{id:'C',incarnation:1},canvasId:'child'});dispatch({type:'element.remove',target:{id:'C',incarnation:1}});assert.equal(f.editor.history.undo().status,'applied');compare();assert.equal(f.editor.history.redo().status,'applied');compare();stop();
|
||||
});
|
||||
|
||||
test('1000-node continuous update performs no document enumeration and resolves only one renderer',()=>{
|
||||
const registry=createRegistry(builtinComponents()),original=createDocument('p',[{id:'root',parent:null}],Array.from({length:1000},(_,i)=>node(String(i)))),scene=createSceneProjection(),owner={},selection=new Set(),view={id:'v',canvasId:'root',selection},snapshot={views:new Map([['v',view]]),gesture:null,connectionGesture:null};let enumerate=true,resolves=0;
|
||||
const protect=document=>({...document,elements:{size:document.elements.size,get:id=>document.elements.get(id),has:id=>document.elements.has(id),values(){assert.ok(enumerate,'unexpected whole-document values scan');return document.elements.values();},[Symbol.iterator](){assert.ok(enumerate,'unexpected whole-document iterator');return document.elements[Symbol.iterator]();}}});
|
||||
const catalog={...builtinRendererCatalog,resolve(record){resolves++;return builtinRendererCatalog.resolve(record);}},firstDoc=protect(original),first=scene.project(owner,firstDoc,view,snapshot,registry,catalog);
|
||||
const elements=[...original.elements.values()].map(record=>record.id==='500'?{...record,data:{...record.data,text:'only target'}}:record),after=protect(createDocument('p',[...original.canvases.values()],elements));
|
||||
scene.advance(firstDoc,after,[{kind:'field',target:{id:'500',incarnation:1},field:'data.text',before:'',after:'only target'}]);enumerate=false;resolves=0;
|
||||
const next=scene.project(owner,after,view,snapshot,registry,catalog);assert.equal(resolves,1);assert.equal(next.nodes[500].data.text,'only target');assert.strictEqual(next.nodes[499],first.nodes[499]);assert.equal(next.document,after);
|
||||
});
|
||||
|
||||
test('replacement without changes compares records and detects reordered membership',()=>{const f=fixture(),initial=f.project(),doc=initial.document,records=[...doc.elements.values()].map(record=>record.id==='B'?{...record,data:{...record.data,text:'remote'}}:record),next=createDocument('p',[...doc.canvases.values()],records),snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),reads=f.reads;f.scene.advance(doc,next,[]);const updated=f.scene.project(f.owner,next,view,snapshot,f.registry,f.catalog);assert.equal(f.reads-reads,1);assert.equal(updated.nodes.find(node=>node.id==='B').data.text,'remote');assert.strictEqual(updated.nodes.find(node=>node.id==='A'),initial.nodes.find(node=>node.id==='A'));const reordered=createDocument('p',[...next.canvases.values()],records.reverse());f.scene.advance(next,reordered,[]);assert.deepEqual(f.scene.project(f.owner,reordered,view,snapshot,f.registry,f.catalog),projectCanvas(reordered,'root',f.registry,view.selection,()=>undefined,null,f.catalog));});
|
||||
|
||||
test('a failed local renderer never mutates or publishes a partially updated scene',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),document=f.editor.getSnapshot().document;let fail=false;const catalog={...f.catalog,resolve(record){if(fail&&record.id==='B')throw Error('renderer failed');return f.catalog.resolve(record);}},first=f.scene.project(f.owner,document,snapshot.views.get('v'),snapshot,f.registry,catalog);f.sessions.select('v',['A','B']);const selected=f.sessions.getSnapshot();fail=true;assert.throws(()=>f.scene.project(f.owner,document,selected.views.get('v'),selected,f.registry,catalog),/renderer failed/);assert.equal(first.nodes.find(node=>node.id==='A').selected,false);assert.equal(first.nodes.find(node=>node.id==='B').selected,false);fail=false;const result=f.scene.project(f.owner,document,selected.views.get('v'),selected,f.registry,catalog);assert.equal(result.nodes.find(node=>node.id==='A').selected,true);assert.equal(result.nodes.find(node=>node.id==='B').selected,true);});
|
||||
|
||||
test('unknown placeholders and redirected handles keep the complete projection rules during a neighbour edit',()=>{const f=fixture(),snapshot=f.sessions.getSnapshot(),view=snapshot.views.get('v'),base=f.editor.getSnapshot().document,records=[...base.elements.values()].map(record=>record.id==='F'?{...record,dataVersion:99,data:{unreadable:{preserve:true}}}:record.id==='edge'?{...record,source:{elementId:'A',portId:'unknown-custom'},target:{elementId:'B'}}:record),document=createDocument('p',[...base.canvases.values()],records),first=f.scene.project(f.owner,document,view,snapshot,f.registry,f.catalog),changed=createDocument('p',[...document.canvases.values()],records.map(record=>record.id==='B'?{...record,data:{...record.data,text:'still editable'}}:record));assert.equal(first.nodes.find(node=>node.id==='F').type,'unsupported');assert.equal(first.edges[0].source,'F');f.scene.advance(document,changed,[{kind:'field',target:{id:'B',incarnation:1},field:'data.text',before:'',after:'still editable'}]);const result=f.scene.project(f.owner,changed,view,snapshot,f.registry,f.catalog);assert.deepEqual(result,projectCanvas(changed,'root',f.registry,view.selection,()=>undefined,null,f.catalog));assert.deepEqual(result.document.elements.get('F').data,{unreadable:{preserve:true}});});
|
||||
|
||||
Reference in New Issue
Block a user