diff --git a/headless/README.md b/headless/README.md index 745635b..33ad132 100644 --- a/headless/README.md +++ b/headless/README.md @@ -383,3 +383,14 @@ they are not treated as empty paint. CanvasView queries a separate overflow grid against its viewport. This currently retains the whole view when needed; it is not selective edge mounting, exact curve bounds, overlay indexing or dirty-region rendering. Custom renderers must supply a compatible geometry protocol. + +`createOverlayIndex(viewId)` owns ephemeral screen-space placements keyed by +instance ID, each with a target incarnation, placement name, measured bounds, +focus and composition state. Registration returns an update/release lease; a +late old lease cannot remove its replacement. `visible(rect, currentTarget)` also +retains focused/composing editors outside the rect and rejects stale targets. +No overlay state enters the document, history, ledger or recovery image. The +application registers frame settings and node style toolbars through an optional +DOM context, including portalled content; legacy and pre-engine renderers use no +registry. Other overlays need explicit registration. Current retention still +suspends culling for the view; it does not selectively mount each overlay target. diff --git a/src/components/canvas/canvas-overlays.tsx b/src/components/canvas/canvas-overlays.tsx new file mode 100644 index 0000000..c157b54 --- /dev/null +++ b/src/components/canvas/canvas-overlays.tsx @@ -0,0 +1,20 @@ +"use client"; +import {createContext,useContext,useCallback,useLayoutEffect,useId,useRef} from 'react'; +import {useViewport} from '@xyflow/react'; +import type {OverlayIndex} from '@/lib/canvas-bindings/overlays'; +import type {ProjectEngine} from '@/lib/canvas-runtime/workspace'; +export const CanvasOverlays=createContext<{index:OverlayIndex;engine:ProjectEngine}|null>(null); +/** Optional for legacy/engine-less renderers; a portal keeps its explicit element target. */ +export function useCanvasOverlay(targetId:string,placement:string){ + const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{}); + useViewport(); + const incarnation=scope?.engine.getSnapshot().document.elements.get(targetId)?.incarnation; + const ref=useCallback((element:HTMLElement|null)=>{cleanup.current();refresh.current=()=>{};cleanup.current=()=>{};if(!element||!scope||incarnation===undefined)return; + const marker=crypto.randomUUID();element.dataset.canvasOverlayView=scope.index.viewId;element.dataset.canvasOverlayOwner=marker;element.dataset.canvasOverlayTarget=targetId;element.dataset.canvasOverlayPlacement=placement; + let composing=false;const value=()=>{const rect=element.getBoundingClientRect();return{placement,target:{id:targetId,incarnation},bounds:{x:rect.x-8,y:rect.y-8,width:rect.width+16,height:rect.height+16},focused:element.contains(document.activeElement),composing};}; + const lease=scope.index.register(instance,value()),measure=()=>lease.update(value()),start=()=>{composing=true;measure();},end=()=>{composing=false;measure();};refresh.current=measure; + const blur=()=>queueMicrotask(measure),observer=new ResizeObserver(measure);observer.observe(element);element.addEventListener('focusin',measure);element.addEventListener('focusout',blur);element.addEventListener('compositionstart',start);element.addEventListener('compositionend',end);let frame=requestAnimationFrame(measure); + cleanup.current=()=>{if(element.dataset.canvasOverlayOwner===marker){delete element.dataset.canvasOverlayView;delete element.dataset.canvasOverlayOwner;delete element.dataset.canvasOverlayTarget;delete element.dataset.canvasOverlayPlacement;}cancelAnimationFrame(frame);observer.disconnect();element.removeEventListener('focusin',measure);element.removeEventListener('focusout',blur);element.removeEventListener('compositionstart',start);element.removeEventListener('compositionend',end);lease.release();}; + },[scope,instance,targetId,incarnation,placement]); + useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref; +} diff --git a/src/components/canvas/frame-node.tsx b/src/components/canvas/frame-node.tsx index c909bb6..b550d51 100644 --- a/src/components/canvas/frame-node.tsx +++ b/src/components/canvas/frame-node.tsx @@ -9,11 +9,13 @@ import { NodeEditing, TransformableNode, ResizeToggle } from "./transformable-no import { FrameFeedback } from "./frame-feedback"; import { FrameLabelMenu } from "./frame-label-menu"; import { ContentHandles } from "./content-handles"; +import {useCanvasOverlay} from "./canvas-overlays"; import styles from "./canvas.module.css"; export function FrameNode(props: NodeProps & { directAnchors?: boolean }) { const { directAnchors = false, ...nodeProps } = props; const { id, data, selected } = nodeProps; + const overlay=useCanvasOverlay(id,"frame-settings"); const editing = useContext(NodeEditing); const feedback = useContext(FrameFeedback); const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length; @@ -31,7 +33,7 @@ export function FrameNode(props: NodeProps & { directAnchors?: boole } return <> - {selected && {selected && event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}> setTitle(event.target.value)} onBlur={() => { if (cancelBlur.current) cancelBlur.current = false; else saveTitle(); }} diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 3c751af..8433b9b 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -12,6 +12,8 @@ import { equal } from "@/lib/canvas-core/values"; import {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cache"; import {createPaintIndex} from "@/lib/canvas-bindings/paint-index"; import {projectedConnectionPaintOverflow} from "@/lib/canvas-bindings/connection-paint"; +import {createOverlayIndex} from "@/lib/canvas-bindings/overlays"; +import {CanvasOverlays} from "./canvas-overlays"; import {createProjectedPaintIndex} from "@/lib/canvas-bindings/projected-paint"; import {createSessionFrameSource} from "@/lib/canvas-bindings/session-frames"; import {routeCanvasKey} from "@/lib/canvas-bindings/input-router"; @@ -76,6 +78,11 @@ 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 surface=useRef(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]); + const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]); + const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot); + const surfaceBounds=surface.current?.getBoundingClientRect(); + const overlayVisible=!!overlaySnapshot.length&&(!surfaceBounds||overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation).length>0); const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]); const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]); const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height); @@ -168,7 +175,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 = !connectionPaintVisible && !paintVisible && !editorFocused && modes.size === 0 && sessions.gesture?.viewId !== viewId && sessions.connectionGesture?.viewId !== viewId; + const cullVisible = !overlayVisible && !connectionPaintVisible && !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; @@ -215,7 +222,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi }),[engine,viewId,report]); const rendererCommands=useMemo(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]); if (!liveView) return

画布已关闭

; - return
{ if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setEditorFocused(true); } }} onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setEditorFocused(false); }); }} onMouseDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); }} @@ -231,7 +238,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi }); if(claim==='gesture'||claim==='tool'||claim==='shortcut'){event.preventDefault();event.stopPropagation();} }}> - + @@ -259,7 +266,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi onConnect={connect} onPaneClick={selectAtPane}> - +
report(engine.editor.history.undo())} /> report(engine.editor.history.redo())} /> { diff --git a/src/components/canvas/node-style-toolbar.tsx b/src/components/canvas/node-style-toolbar.tsx index 58222a0..5ef87a6 100644 --- a/src/components/canvas/node-style-toolbar.tsx +++ b/src/components/canvas/node-style-toolbar.tsx @@ -7,16 +7,18 @@ import { AlignCenter, AlignLeft, AlignRight, Bold, Italic, RotateCw, Underline } import type { CanvasNode } from "@/lib/canvas"; import type { TextStyle } from "@/lib/canvas-elements"; import { NodeEditing, ResizeToggle } from "./transformable-node"; +import {useCanvasOverlay} from "./canvas-overlays"; import styles from "./canvas.module.css"; export function NodeStyleToolbar({ id, data, selected }: NodeProps) { + const overlay=useCanvasOverlay(id,"node-style"); const reduce = useReducedMotion(); const editing = useContext(NodeEditing); const style = data.textStyle || {}; const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text"; const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize); return - {selected && {selected && event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key !== "Escape") event.stopPropagation(); }}> {!(["image", "plain-image"].includes(data.kind)) && } diff --git a/src/lib/canvas-bindings/index.ts b/src/lib/canvas-bindings/index.ts index 8e4d803..4c5b60e 100644 --- a/src/lib/canvas-bindings/index.ts +++ b/src/lib/canvas-bindings/index.ts @@ -19,3 +19,5 @@ export {createPaintIndex} from './paint-index.ts'; export type {PaintRegion} from './paint-index.ts'; export {projectedPaintOverflow,createProjectedPaintIndex} from './projected-paint.ts'; export {projectedConnectionPaintOverflow} from './connection-paint.ts'; +export {createOverlayIndex} from './overlays.ts'; +export type {OverlayEntry,OverlayIndex} from './overlays.ts'; diff --git a/src/lib/canvas-bindings/overlays.ts b/src/lib/canvas-bindings/overlays.ts new file mode 100644 index 0000000..71bd5bc --- /dev/null +++ b/src/lib/canvas-bindings/overlays.ts @@ -0,0 +1,17 @@ +import type {Target} from '../canvas-core/types.ts'; +import type {Rect} from '../canvas-core/geometry.ts'; +import {createPaintIndex} from './paint-index.ts'; +import {fault} from '../canvas-core/values.ts'; +export type OverlayEntry={readonly id:string;readonly viewId:string;readonly placement:string;readonly target:Target;readonly bounds:Rect;readonly focused:boolean;readonly composing:boolean}; +/** Ephemeral screen-space placements. A lease prevents late cleanup from removing a replacement. */ +export function createOverlayIndex(viewId:string){ + if(typeof viewId!=='string'||!viewId)throw fault('view_session_required');let serial=0,snapshot:readonly OverlayEntry[]=Object.freeze([]);const entries=new Map(),listeners=new Set<()=>void>(),index=createPaintIndex(); + function publish(){snapshot=Object.freeze([...entries.values()].map(row=>row.entry));index.sync(snapshot.map(entry=>({id:entry.id,bounds:entry.bounds})));for(const listener of [...listeners])listener();} + function capture(id:string,value:Omit):OverlayEntry{if(typeof id!=='string'||!id||typeof value.placement!=='string'||!value.placement||typeof value.target.id!=='string'||!value.target.id||!Number.isSafeInteger(value.target.incarnation)||value.target.incarnation<1)throw fault('invalid_overlay');const entry=Object.freeze({...value,id,viewId,target:Object.freeze({...value.target}),bounds:Object.freeze({...value.bounds})});const check=createPaintIndex();check.sync([{id,bounds:entry.bounds}]);return entry;} + return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};}, + register(id:string,value:Omit){const token=++serial,entry=capture(id,value);entries.set(id,{token,entry});publish();return Object.freeze({update(value:Omit){if(entries.get(id)?.token!==token)return;const entry=capture(id,value),old=entries.get(id)!.entry;if(JSON.stringify(old)===JSON.stringify(entry))return;entries.set(id,{token,entry});publish();},release(){if(entries.get(id)?.token!==token)return;entries.delete(id);publish();}});}, + visible(bounds:Rect,current:(target:Target)=>boolean){const ids=index.query(bounds);return snapshot.filter(entry=>current(entry.target)&&(entry.focused||entry.composing||ids.has(entry.id)));}, + clear(){entries.clear();publish();}, + }); +} +export type OverlayIndex=ReturnType; diff --git a/tests/headless/overlays.test.mjs b/tests/headless/overlays.test.mjs new file mode 100644 index 0000000..4c45c04 --- /dev/null +++ b/tests/headless/overlays.test.mjs @@ -0,0 +1,5 @@ +import {test} from 'node:test';import assert from 'node:assert/strict';import {createOverlayIndex} from '../../src/lib/canvas-bindings/overlays.ts'; +const value=(incarnation=1,x=0)=>({placement:'title',target:{id:'a',incarnation},bounds:{x,y:0,width:100,height:30},focused:false,composing:false}),viewport={x:0,y:0,width:200,height:200}; +test('screen placements are scoped by view, reject stale incarnations and preserve focused or composing offscreen editors',()=>{const a=createOverlayIndex('view-a'),b=createOverlayIndex('view-b');a.register('portal',value());b.register('portal',value(2));assert.equal(a.visible(viewport,target=>target.incarnation===1).length,1);assert.equal(b.visible(viewport,target=>target.incarnation===1).length,0);const lease=a.register('portal',value(1,1000));assert.equal(a.visible(viewport,()=>true).length,0);lease.update({...value(1,1000),composing:true});assert.equal(a.visible(viewport,()=>true).length,1);lease.update({...value(1,1000),focused:true});assert.equal(a.visible(viewport,()=>true).length,1);assert.equal(a.visible(viewport,()=>false).length,0);}); +test('late old-instance updates or cleanup cannot remove a replacement overlay; unchanged measurements do not notify',()=>{const index=createOverlayIndex('v');let changes=0;const stop=index.subscribe(()=>changes++),old=index.register('same',value()),fresh=index.register('same',value(2));const snapshot=index.getSnapshot(),baseline=changes;old.release();old.update(value());fresh.update(value(2));assert.strictEqual(index.getSnapshot(),snapshot);assert.equal(changes,baseline);fresh.release();assert.equal(index.getSnapshot().length,0);stop();}); +test('invalid overlay replacement leaves the prior placement and index intact',()=>{const index=createOverlayIndex('v');index.register('x',value());const before=index.getSnapshot();assert.throws(()=>index.register('x',{...value(),bounds:{x:NaN,y:0,width:1,height:1}}));assert.strictEqual(index.getSnapshot(),before);assert.equal(index.visible(viewport,()=>true).length,1);index.clear();assert.equal(index.visible(viewport,()=>true).length,0);}); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 8c2d517..c376c8d 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -344,6 +344,7 @@ try { await until(`!!document.querySelector('[aria-label="分区名称"]')`,'frame title editor did not open'); await click('收起子画布');await until(`!document.querySelector('.react-flow__node[data-id=b]')&&document.querySelectorAll('.react-flow__edge').length>0`,'collapsed partition did not render its upper-canvas connection');assert.equal(documents.get(root).elements.get('edge').target.elementId,'b');assert.ok(documents.get(root).elements.has('b'));await click('展开子画布');await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'expanding partition did not restore the member'); console.log('PASS collapsed partitions retain model endpoints and render upper-canvas connections through headless frame anchors'); + assert.ok(await evaluate(`document.querySelector('[aria-label="分区名称"]').closest('[data-canvas-overlay-target]')?.dataset.canvasOverlayView`),'portalled frame title did not register its explicit view/target placement'); await evaluate(`(()=>{window.pinnedFrameTitle=document.querySelector('[aria-label="分区名称"]');window.pinnedFrameTitle.focus();window.pinnedFrameTitle.select();})()`);await call('Input.insertText',{text:'Pinned partition draft'}); const panFar=`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`,panHome=`(()=>{const matrix=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:matrix.e/.5,deltaY:matrix.f/.5,bubbles:true,cancelable:true}));})()`; await evaluate(panFar);await pause(250);assert.equal(await evaluate(`window.pinnedFrameTitle.isConnected&&document.activeElement===window.pinnedFrameTitle&&window.pinnedFrameTitle.value==='Pinned partition draft'`),true,'viewport culling discarded an unsaved frame title');