From c6d77bdefd776d27ce4982a0385d42086c10004f Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 9 Nov 2026 17:44:20 +0800 Subject: [PATCH] Index projected node paint overflow for conservative viewport retention --- headless/README.md | 12 ++++++++++++ .../canvas/headless-graph-canvas.tsx | 9 +++++++-- src/lib/canvas-bindings/index.ts | 3 +++ src/lib/canvas-bindings/paint-index.ts | 18 ++++++++++++++++++ src/lib/canvas-bindings/projected-paint.ts | 19 +++++++++++++++++++ tests/headless/paint-index.test.mjs | 8 ++++++++ 6 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 src/lib/canvas-bindings/paint-index.ts create mode 100644 src/lib/canvas-bindings/projected-paint.ts create mode 100644 tests/headless/paint-index.test.mjs diff --git a/headless/README.md b/headless/README.md index 15f8918..e63372c 100644 --- a/headless/README.md +++ b/headless/README.md @@ -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. diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 07a460e..1f5add8 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -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; diff --git a/src/lib/canvas-bindings/index.ts b/src/lib/canvas-bindings/index.ts index 96af7ce..85358db 100644 --- a/src/lib/canvas-bindings/index.ts +++ b/src/lib/canvas-bindings/index.ts @@ -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'; diff --git a/src/lib/canvas-bindings/paint-index.ts b/src/lib/canvas-bindings/paint-index.ts new file mode 100644 index 0000000..1ce82b4 --- /dev/null +++ b/src/lib/canvas-bindings/paint-index.ts @@ -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(),cells=new Map>(),members=new Map(),overflow=new Set(); + 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();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{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();}, + }); +} diff --git a/src/lib/canvas-bindings/projected-paint.ts b/src/lib/canvas-bindings/projected-paint.ts new file mode 100644 index 0000000..73686ef --- /dev/null +++ b/src/lib/canvas-bindings/projected-paint.ts @@ -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,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&®istry.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(xnode.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}; +} diff --git a/tests/headless/paint-index.test.mjs b/tests/headless/paint-index.test.mjs new file mode 100644 index 0000000..bb3cd91 --- /dev/null +++ b/tests/headless/paint-index.test.mjs @@ -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);});