Index projected node paint overflow for conservative viewport retention

This commit is contained in:
2026-11-09 17:44:20 +08:00
parent 04a9b0c2b2
commit c6d77bdefd
6 changed files with 67 additions and 2 deletions
+12
View File
@@ -309,3 +309,15 @@ The application uses it for CanvasView sessions. Pointerup reads its final event
and commits through the engine synchronously; persistence and Root notifications
are not frame-throttled. This does not implement paint extent indexing or selective
per-element drawing: dirty keys currently schedule a view notification.
Node paint overflow:
`createPaintIndex` owns a rebuildable view-space grid of rectangles, with bounded
cell expansion and oversized fallbacks. `projectedPaintOverflow` asks node geometry
for the `paint` role separately from logical/hit geometry, transforms its bounds
into scene space, and reports failed handlers conservatively. CanvasView queries
the overflow index against the live viewport; visible overflow or unknown bounds
suspend native culling so extended paint is retained. This currently disables
culling for the view rather than selectively retaining one node. Geometry
extensions must declare their paint extent; undeclared DOM overflow cannot be
inferred. Edge/overlay paint indexing and region-based drawing remain separate work.
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection,type Viewport } from "@xyflow/react";
import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, useViewport, useStore, type Connection,type Viewport } from "@xyflow/react";
import { Copy, Download, Frame, Hand, ImagePlus, Images, Maximize, Minus, Ratio, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
import type { Board } from "@/lib/workspace";
import type { ReactRenderers } from "@/lib/canvas-bindings/react-flow";
@@ -10,6 +10,8 @@ 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 {createPaintIndex} from "@/lib/canvas-bindings/paint-index";
import {projectedPaintOverflow} from "@/lib/canvas-bindings/projected-paint";
import {createSessionFrameSource} from "@/lib/canvas-bindings/session-frames";
import {routeCanvasKey} from "@/lib/canvas-bindings/input-router";
import {hitProjectedCanvas} from "@/lib/canvas-bindings/hit-query";
@@ -73,6 +75,9 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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]);
const paint=useMemo(()=>{const result=projectedPaintOverflow(projection,engine.editor.registry),index=createPaintIndex();index.sync(result.regions);return{index,unknown:result.unknown};},[projection,engine]);
const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height);
const paintVisible=paint.unknown.length>0||!surfaceWidth||!surfaceHeight||paint.index.query({x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom}).size>0;
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
@@ -158,7 +163,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
const modes = view.modes;
// Native culling keeps the full graph in React Flow. Retain every mounted editor/lease
// while an interaction is active so viewport changes cannot discard IME or capture.
const cullVisible = !editorFocused && modes.size === 0 && sessions.gesture?.viewId !== viewId && sessions.connectionGesture?.viewId !== viewId;
const cullVisible = !paintVisible && !editorFocused && modes.size === 0 && sessions.gesture?.viewId !== viewId && sessions.connectionGesture?.viewId !== viewId;
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
+3
View File
@@ -15,3 +15,6 @@ export type {CanvasKeyInput,InputClaim,KeyHandler} from './input-router.ts';
export {createFrameQueue} from './frame-queue.ts';
export type {FrameClock} from './frame-queue.ts';
export {createSessionFrameSource} from './session-frames.ts';
export {createPaintIndex} from './paint-index.ts';
export type {PaintRegion} from './paint-index.ts';
export {projectedPaintOverflow} from './projected-paint.ts';
+18
View File
@@ -0,0 +1,18 @@
import type {Id} from '../canvas-core/types.ts';
import type {Rect} from '../canvas-core/geometry.ts';
import {fault} from '../canvas-core/values.ts';
export type PaintRegion={readonly id:Id;readonly bounds:Rect};
const valid=(r:Rect)=>r&&[r.x,r.y,r.width,r.height,r.x+r.width,r.y+r.height].every(Number.isFinite)&&r.width>=0&&r.height>=0;
const intersects=(a:Rect,b:Rect)=>a.x<=b.x+b.width&&b.x<=a.x+a.width&&a.y<=b.y+b.height&&b.y<=a.y+a.height;
/** Rebuildable view-space grid. Oversized regions/queries fall back without false negatives. */
export function createPaintIndex(cellSize=512,maxCells=256){
if(!Number.isFinite(cellSize)||cellSize<=0||!Number.isSafeInteger(maxCells)||maxCells<1)throw fault('invalid_paint_index');
const rows=new Map<Id,Rect>(),cells=new Map<string,Set<Id>>(),members=new Map<Id,readonly string[]>(),overflow=new Set<Id>();
function keys(r:Rect){const x=Math.floor(r.x/cellSize),y=Math.floor(r.y/cellSize),right=Math.floor((r.x+r.width)/cellSize),bottom=Math.floor((r.y+r.height)/cellSize);if(![x,y,right,bottom].every(Number.isSafeInteger)||(right-x+1)*(bottom-y+1)>maxCells)return;const result:string[]=[];for(let a=x;a<=right;a++)for(let b=y;b<=bottom;b++)result.push(`${a},${b}`);return result;}
function remove(id:Id){for(const key of members.get(id)??[]){const bucket=cells.get(key)!;bucket.delete(id);if(!bucket.size)cells.delete(key);}members.delete(id);overflow.delete(id);rows.delete(id);}
return Object.freeze({
sync(regions:readonly PaintRegion[]){const incoming=new Map<Id,Rect>();for(const region of regions){if(!region.id||incoming.has(region.id)||!valid(region.bounds))throw fault('invalid_paint_region');incoming.set(region.id,{...region.bounds});}for(const id of rows.keys())if(!incoming.has(id))remove(id);for(const [id,r]of incoming){const old=rows.get(id);if(old&&old.x===r.x&&old.y===r.y&&old.width===r.width&&old.height===r.height)continue;remove(id);rows.set(id,r);const list=keys(r);if(!list){overflow.add(id);continue;}members.set(id,list);for(const key of list){let bucket=cells.get(key);if(!bucket)cells.set(key,bucket=new Set());bucket.add(id);}}},
query(region:Rect):ReadonlySet<Id>{if(!valid(region))throw fault('invalid_paint_region');const list=keys(region),candidates=list?new Set([...overflow,...list.flatMap(key=>[...cells.get(key)??[]])]):new Set(rows.keys());return new Set([...candidates].filter(id=>intersects(rows.get(id)!,region)));},
clear(){rows.clear();cells.clear();members.clear();overflow.clear();},
});
}
@@ -0,0 +1,19 @@
import {rectangleGeometry} from '../canvas-core/geometry.ts';
import {toLocal,toWorld} from '../canvas-core/coordinates.ts';
import type {Registry} from '../canvas-core/types.ts';
import type {projectCanvas} from './projection.ts';
import type {PaintRegion} from './paint-index.ts';
/** Extra node paint extents beyond native unrotated React Flow bounds. */
export function projectedPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry){
const regions:PaintRegion[]=[],unknown:string[]=[];
for(const node of projection.nodes)try{
const element=projection.document.elements.get(node.id),transform=element&&projection.transforms.get(element.ownerCanvasId);if(!element||element.structure!=='node'||!transform)throw Error('missing_geometry');
const width=node.width!/transform.scale,height=node.height!/transform.scale,center=toLocal(transform,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2}),geometry={x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-transform.rotation+360)%360};
const definition=!node.data.__headlessFallback&&registry.resolve(element)==='known'?registry.components.get(element.componentId)?.geometry:undefined,protocol=definition?.kind==='node'?definition:rectangleGeometry;
const local=protocol.bounds({element,geometry,role:'paint'});if(![local.x,local.y,local.width,local.height].every(Number.isFinite)||local.width<0||local.height<0)throw Error('invalid_paint_bounds');
const points=[{x:local.x,y:local.y},{x:local.x+local.width,y:local.y},{x:local.x,y:local.y+local.height},{x:local.x+local.width,y:local.y+local.height}].map(p=>toWorld(transform,p)),x=Math.min(...points.map(p=>p.x)),y=Math.min(...points.map(p=>p.y)),right=Math.max(...points.map(p=>p.x)),bottom=Math.max(...points.map(p=>p.y));
if(![x,y,right,bottom,right-x,bottom-y].every(Number.isFinite))throw Error('invalid_paint_bounds');
if(x<node.position.x||y<node.position.y||right>node.position.x+node.width!||bottom>node.position.y+node.height!)regions.push({id:node.id,bounds:{x,y,width:right-x,height:bottom-y}});
}catch{unknown.push(node.id);}
return{regions,unknown};
}
+8
View File
@@ -0,0 +1,8 @@
import {test} from 'node:test';import assert from 'node:assert/strict';import {createPaintIndex} from '../../src/lib/canvas-bindings/paint-index.ts';
const region=(id,x,y,width=10,height=10)=>({id,bounds:{x,y,width,height}});
test('paint grid updates moves/deletes, includes boundary and oversized entries without changing model geometry',()=>{const index=createPaintIndex(10,4),a=region('a',-10,-10),huge=region('huge',-1000,-1000,2000,2000);index.sync([a,huge]);assert.deepEqual([...index.query({x:0,y:0,width:0,height:0})].sort(),['a','huge']);index.sync([region('a',100,100),huge]);assert.deepEqual([...index.query({x:0,y:0,width:1,height:1})],['huge']);index.sync([region('a',100,100)]);assert.equal(index.query({x:0,y:0,width:1,height:1}).size,0);assert.equal(index.query({x:-1e9,y:-1e9,width:2e9,height:2e9}).size,1);assert.equal(a.bounds.x,-10);index.clear();assert.equal(index.query(a.bounds).size,0);});
test('invalid replacement is rejected before any grid changes',()=>{const index=createPaintIndex();index.sync([region('safe',0,0)]);assert.throws(()=>index.sync([region('safe',50,50),region('bad',NaN,0)]));assert.equal(index.query({x:0,y:0,width:1,height:1}).has('safe'),true);assert.throws(()=>index.sync([region('a',0,0),region('a',1,1)]));assert.throws(()=>index.query({x:Infinity,y:0,width:1,height:1}));});
import {projectedPaintOverflow} from '../../src/lib/canvas-bindings/projected-paint.ts';
import {createRegistry,createDocument,builtinComponents} from '../../src/lib/canvas-core/index.ts';
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
test('projected paint uses paint role independently of logical bounds and conservatively retains failed handlers',()=>{const text=builtinComponents().find(c=>c.id==='text'),roles=[],geometry={...text.geometry,bounds(context){roles.push(context.role);if(context.element.data.title==='broken')throw Error('broken');return context.role==='paint'?{x:-100,y:-50,width:400,height:200}:{x:0,y:0,width:100,height:100};}},registry=createRegistry(builtinComponents().map(c=>c.id==='text'?{...c,geometry}:c)),node=(id,title)=>({id,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title,text:''}}),document=createDocument('p',[{id:'p',parent:null}],[node('a','okay'),node('b','broken')]),projection=projectCanvas(document,'p',registry,new Set(),()=>undefined),result=projectedPaintOverflow(projection,registry);assert.ok(roles.includes('paint'));assert.deepEqual(result.regions,[{id:'a',bounds:{x:-100,y:-50,width:400,height:200}}]);assert.deepEqual(result.unknown,['b']);assert.equal(document.elements.get('a').geometry.width,100);});