Patch fixed-layout scene projections from coalesced element changes
This commit is contained in:
@@ -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();},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user