Reuse existing scene projections when inserting ordinary canvas nodes

This commit is contained in:
2026-11-09 23:38:12 +08:00
parent 0ce3abbe04
commit da7d9d056f
5 changed files with 71 additions and 9 deletions
+18 -6
View File
@@ -1,3 +1,4 @@
import type {DocumentQueries} from '../canvas-core/queries.ts';
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';
@@ -14,12 +15,12 @@ export function indexScene(projection:Projection){
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{
/** Reuse existing projections for checked field edits and ordinary node insertions. */
export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,next:SceneInputs,previous:SceneInputs,connection:SessionSnapshot['connectionGesture'],hint?:SceneChangeHint,queries?:DocumentQueries):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>();
const dirty=new Set<Id>(),created=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{
@@ -37,22 +38,33 @@ export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,
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?.structure==='node'){created.add(id);continue;}
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;
let nodes=base.nodes,edges=base.edges;const inserted=new Map<Id,Projection['nodes'][number]>();
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 index=layout.nodes.get(id);if(index===undefined&&!created.has(id))continue;
if(!base.transforms.has(element.ownerCanvasId))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;
if(created.has(id)){if(node.data.__headlessFallback)return;inserted.set(id,node);}
else{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;
}
}
if(inserted.size){
// Membership order is depth-first, not global insertion order. Reorder references
// without reconstructing unaffected nodes, transforms or edge projections.
const members=new Map<Id,Id[]>();if(!queries)for(const element of document.elements.values()){const list=members.get(element.ownerCanvasId)??[];list.push(element.id);members.set(element.ownerCanvasId,list);}
const ordered:Projection['nodes']=[];let complete=true;
const visit=(owner:Id)=>{for(const id of queries?queries.members(owner):members.get(owner)??[]){const element=document.elements.get(id)!;if(element.structure!=='node')continue;const projected=inserted.get(id)??nodes[layout.nodes.get(id)!];if(!projected){complete=false;return;}ordered.push(projected);const child=layout.entries.get(id);if(child&&base.transforms.has(child))visit(child);}};
visit(canvasId);if(!complete)return;nodes=ordered;
}
return nodes===base.nodes&&edges===base.edges&&document===base.document?base:{...base,document,nodes,edges};
}
+3 -3
View File
@@ -19,7 +19,7 @@ export function createSceneProjection(){
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;}}
for(const change of changes){if(change.kind==='canvas'){pending.topology=true;break;}pending.ids.add(change.kind==='element'?change.id: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){
@@ -30,9 +30,9 @@ export function createSceneProjection(){
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 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 patched=compatible?patchScene(value!,layout!,next,previous!,connection,pending,queries):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);
if(!patched||result.nodes.length!==value!.nodes.length)layout=indexScene(result);
previous=next;value=result;pending=undefined;return result;
},
clear(){previous=undefined;value=undefined;layout=undefined;pending=undefined;geometry=createGeometryProjectionCache();},