diff --git a/src/components/canvas/frame-node.tsx b/src/components/canvas/frame-node.tsx index aae47b7..c909bb6 100644 --- a/src/components/canvas/frame-node.tsx +++ b/src/components/canvas/frame-node.tsx @@ -8,10 +8,12 @@ import type { CanvasNode } from "@/lib/canvas"; import { NodeEditing, TransformableNode, ResizeToggle } from "./transformable-node"; import { FrameFeedback } from "./frame-feedback"; import { FrameLabelMenu } from "./frame-label-menu"; +import { ContentHandles } from "./content-handles"; import styles from "./canvas.module.css"; -export function FrameNode(props: NodeProps) { - const { id, data, selected } = props; +export function FrameNode(props: NodeProps & { directAnchors?: boolean }) { + const { directAnchors = false, ...nodeProps } = props; + const { id, data, selected } = nodeProps; const editing = useContext(NodeEditing); const feedback = useContext(FrameFeedback); const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length; @@ -47,11 +49,12 @@ export function FrameNode(props: NodeProps) { } - + {data.subgraph && data.presentation?.collapsed && event.stopPropagation()}>{data.title}打开子画布} {mode !== "hidden" &&
{data.title}
} {(incoming > 0 || outgoing > 0) &&
{incoming > 0 ? `松开加入 · ${incoming} 项` : `松开移出 · ${outgoing} 项`}
} {(["top", "right", "bottom", "left"] as const).map((side) =>
)} + {directAnchors && } {data.ports?.map((port) => { const peers = data.ports!.filter((item) => item.direction === port.direction); return ; diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 9777cd8..228a651 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection, type NodeProps } from "@xyflow/react"; import { Copy, Download, Frame, Hand, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react"; import type { Board } from "@/lib/workspace"; +import type { CanvasNode } from "@/lib/canvas-elements"; import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types"; import { equal } from "@/lib/canvas-core/values"; import { createProjectionCache } from "@/lib/canvas-bindings/projection-cache"; @@ -28,7 +29,8 @@ import { IconNode } from "./icon-node"; import { ImagePreviews } from "./canvas-image"; import styles from "./canvas.module.css"; -const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, plainImage: PlainImageNode, frame: FrameNode, icon: IconNode, +function HeadlessFrameNode(props: NodeProps) { return ; } +const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, plainImage: PlainImageNode, frame: HeadlessFrameNode, icon: IconNode, unsupported: ({ data }: NodeProps) =>
{data.title}此元素需要更新版本才能编辑
, }; const edgeTypes = { default: CanvasEdge }; @@ -41,6 +43,8 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B if (liveView) lastView.current = liveView; const view = liveView ?? lastView.current!, flow = useReactFlow(); const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId); + const [editorFocused, setEditorFocused] = useState(false), focusEpoch = useRef(0); + useEffect(() => { focusEpoch.current++; setEditorFocused(false); return () => { focusEpoch.current++; }; }, [engine,viewId]); const [copying, setCopying] = useState(false); const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false); const [error, setError] = useState(""), [sources, setSources] = useState(new Map()); @@ -53,7 +57,9 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B const result = projectCanvas(root.document, view.canvasId, engine.editor.registry, view.selection, engine.sessions.effectiveGeometry, sessions.connectionGesture); // Preserve renderer measurements across metadata/preview updates. Dropping them // makes React Flow discard handle bounds and unmount active edge controls. - for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height) node.measured = cached.measured; } + const visibleIds = new Set(result.nodes.map(node => node.id)); + for (const id of measurements.current.keys()) if (!visibleIds.has(id)) measurements.current.delete(id); + 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]); const current = useRef({ root, projection, view, engine }); current.current = { root, projection, view, engine }; @@ -78,7 +84,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B const { root, projection } = current.current, element = root.document.elements.get(id); if (element?.structure !== 'node') return; const t = projection.transforms.get(element.ownerCanvasId)!; - const displayed = projection.nodes.find(node => node.id === id)!; + const displayed = projectionCache.current!.getNode(id)!; const width = (change.width ?? displayed.width!) / t.scale, height = (change.height ?? displayed.height!) / t.scale; const position = change.position ?? displayed.position, center = toLocal(t, { x: position.x + width * t.scale / 2, y: position.y + height * t.scale / 2 }); const patch: Partial = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) }; @@ -121,6 +127,9 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B return () => { window.removeEventListener('blur', cancel); window.removeEventListener('keydown', escape, true); }; }, [engine, viewId]); 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 selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined; const aiElement = selected?.structure === 'node' && engine.editor.registry.resolve(selected) === 'known' && ['text','plain-text','script','image','plain-image'].includes(selected.componentId) ? selected : undefined; // Runtime-only publications must not invalidate every card's editing context. @@ -137,7 +146,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); }, resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); }, branch: (id, direction) => { - const node = current.current.projection.nodes.find(node => node.id === id); if (!node) return; + const node = projectionCache.current!.getNode(id); if (!node) return; const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0; report(engine.editor.transaction(tx => { const created = add('text', undefined, tx, { x: node.position.x + dx, y: node.position.y + dy }); const source = tx.getDocument().elements.get(id)!, next = tx.getDocument().elements.get(created)!; tx.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(tx.getDocument(), source.ownerCanvasId, next.ownerCanvasId), source: { elementId: id, portId: direction }, target: { elementId: created }, data: {}, path: null } }); }, { label: 'Add branch' })); }, @@ -165,6 +174,8 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B }),[engine,viewId,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 }); }} onTouchStartCapture={event => { if (event.touches.length > 1) { engine.sessions.cancel('multi_touch'); drag.current = null; return; } const touch = event.touches[0]; if (touch && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = flow.screenToFlowPosition({ x: touch.clientX, y: touch.clientY }); }} onPointerDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); }} @@ -181,7 +192,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B - nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose} + onlyRenderVisibleElements={cullVisible} nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose} nodesDraggable={view.tool === 'select'} panOnDrag={view.tool === 'pan' ? true : [1, 2]} selectionOnDrag={view.tool === 'select'} panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} defaultViewport={view.viewport} onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)} @@ -189,12 +200,12 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id)); const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); }); const point = 'clientX' in event ? event : event.changedTouches[0]; - drag.current = { token: 0, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, current.current.projection.nodes.find(node => node.id === id)!.position])) }; + drag.current = { token: 0, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, projectionCache.current!.getNode(id)!.position])) }; const result = engine.sessions.begin(viewId, top.map(target)); report(result); if (result.enabled) drag.current.token = engine.sessions.getSnapshot().gesture!.token; else drag.current = null; }} onEdgesChange={changes => selectChanges(changes.filter(change => change.type === 'select'))} - onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = current.current.projection.nodes.find(node => node.id === change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }} + onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = projectionCache.current!.getNode(change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }} onNodeDragStop={event => { const saved = drag.current; drag.current = null; pointerStart.current = null; if (engine.sessions.getSnapshot().gesture?.viewId !== viewId || !saved || engine.sessions.getSnapshot().gesture?.token !== saved.token) return; diff --git a/src/lib/canvas-bindings/projection-cache.ts b/src/lib/canvas-bindings/projection-cache.ts index f1343af..f1e15ce 100644 --- a/src/lib/canvas-bindings/projection-cache.ts +++ b/src/lib/canvas-bindings/projection-cache.ts @@ -23,6 +23,11 @@ export function createProjectionCache(){ nodes=retain(document,projection.nodes,nodes);edges=retain(document,projection.edges,edges); return projection; }, + getNode(id:Id){return nodes.get(id)?.value;}, + canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){ + const old=nodes.get(value.id),record=document.elements.get(value.id); + return scope===owner&&projectId===document.projectId&&canvasId===viewCanvasId&&!!old&&!!record&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&old.value.type===value.type&&old.value.width===value.width&&old.value.height===value.height&&equal(old.value.data,value.data); + }, clear(){nodes.clear();edges.clear();scope=undefined;projectId=undefined;canvasId=undefined;}, }); } diff --git a/tests/headless/projection-cache.test.mjs b/tests/headless/projection-cache.test.mjs index b4fe531..5580bd4 100644 --- a/tests/headless/projection-cache.test.mjs +++ b/tests/headless/projection-cache.test.mjs @@ -20,3 +20,10 @@ test('lifecycle, ownership, view scope and deletion never reuse an obsolete proj const canvases=[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],frame={...node('F'),componentId:'frame'},beforeMove=createDocument('project',canvases,[...document.elements.values(),frame]);const before=project(cache,scope,beforeMove).nodes.find(value=>value.id==='A');const moved=createDocument('project',canvases,[...beforeMove.elements.values()].map(record=>record.id==='A'?{...record,ownerCanvasId:'child',geometry:{...record.geometry,x:-24,y:-36}}:record)),after=project(cache,scope,moved).nodes.find(value=>value.id==='A');assert.deepEqual(before.position,after.position);assert.notEqual(before,after); const current=project(cache,scope,document),empty=createDocument('project',[{id:'root',parent:null}]);project(cache,scope,empty);assert.notEqual(project(cache,scope,document).nodes[0],current.nodes[0]);cache.clear();assert.notEqual(project(cache,scope,document).nodes[0],current.nodes[0]); }); + +test('measurement reuse preserves translated bounds but invalidates changed ports, scale and lifecycle',()=>{ + const cache=createProjectionCache(),scope={},canvases=[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],frame={...node('F'),componentId:'frame',data:{title:'F',text:'',ports:[]}},original=createDocument('project',canvases,[frame,node('A','child')]);project(cache,scope,original);assert.equal(cache.getNode('F').data.title,'F'); + const translated=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'geometry',before:frame.geometry,after:{...frame.geometry,x:200}}]),next=projectCanvas(translated,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,translated,'root',next.nodes.find(value=>value.id==='F')),true); + const ports=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'data.ports',before:[],after:[{id:'input',direction:'input',childNodeId:'A'}],beforePresent:true,afterPresent:true}]),portProjection=projectCanvas(ports,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,ports,'root',portProjection.nodes.find(value=>value.id==='F')),false); + const scaled=applyChanges(original,[{kind:'field',target:{id:'F',incarnation:1},field:'data.presentation',before:null,after:{contentScale:2},beforePresent:false,afterPresent:true}]),scaleProjection=projectCanvas(scaled,'root',registry,new Set(),()=>undefined);assert.equal(cache.canRetainMeasurement(scope,scaled,'root',scaleProjection.nodes.find(value=>value.id==='A')),false);assert.equal(cache.canRetainMeasurement({},original,'root',projectCanvas(original,'root',registry,new Set(),()=>undefined).nodes[0]),false);cache.clear();assert.equal(cache.getNode('A'),undefined); +}); diff --git a/tests/headless/renderer-performance.mjs b/tests/headless/renderer-performance.mjs index 714c85b..8dd1f3f 100644 --- a/tests/headless/renderer-performance.mjs +++ b/tests/headless/renderer-performance.mjs @@ -2,32 +2,40 @@ import assert from 'node:assert/strict'; import {writeFile} from 'node:fs/promises'; import {setTimeout as pause} from 'node:timers/promises'; +import {canvasTransform,toWorld} from '../../src/lib/canvas-core/coordinates.ts'; export async function measureRenderer({call,evaluate,documents,root,partition,count,exceptions,reportPath}){ - const initial=count+2,target=initial+count+1,started=Date.now(); + const target=2*count+3,started=Date.now(); const wait=async(expression,label)=>{const deadline=Date.now()+240000;while(Date.now()=2`,'initial large canvas did not render'); const initialReadyMs=await evaluate('performance.now()'); await evaluate(`document.querySelector('.react-flow__node[data-id="perf-frame"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`); await wait(`!!document.querySelector('[aria-label="深复制选中内容"]')&&!document.querySelector('[aria-label="深复制选中内容"]').disabled`,'partition selection did not enable deep copy'); await evaluate(`new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))`); await evaluate(`(()=>{ - const base=${initial},target=${target},start=performance.now(),tasks=[],gaps=[]; - let raf,last=start,sawPreparing=false,firstDOM=null,firstFrame=null,allDOM=null,allFrame=null,prepared=null; + const originals=new Set(['perf-source','perf-frame',...Array.from({length:${count}},(_,i)=>'perf-'+i)]),start=performance.now(),tasks=[],gaps=[]; + let raf,last=start,sawPreparing=false,firstDOM=null,firstFrame=null,localComplete=null,localCompleteFrame=null,prepared=null,maxNodes=0; const supported=PerformanceObserver.supportedEntryTypes.includes('longtask'),observer=supported?new PerformanceObserver(list=>tasks.push(...list.getEntries().map(entry=>({start:entry.startTime-start,duration:entry.duration})))):null; observer?.observe({type:'longtask'}); - function tick(now){gaps.push(now-last);last=now;const size=document.querySelectorAll('.react-flow__node').length;if(size>base){if(firstDOM===null)firstDOM=now-start;else if(firstFrame===null)firstFrame=now-start;}if(size>=target){if(allDOM===null)allDOM=now-start;else if(allFrame===null)allFrame=now-start;}if(document.querySelector('[aria-label="正在准备复制"]'))sawPreparing=true;else if(sawPreparing&&prepared===null)prepared=now-start;raf=requestAnimationFrame(tick);} + function tick(now){gaps.push(now-last);last=now;const mounted=[...document.querySelectorAll('.react-flow__node')];maxNodes=Math.max(maxNodes,mounted.length);if(mounted.some(node=>!originals.has(node.dataset.id))){if(firstDOM===null)firstDOM=now-start;else if(firstFrame===null)firstFrame=now-start;}if(document.querySelector('[aria-label="画布导入"]')?.textContent.includes('已加入画布')){if(localComplete===null)localComplete=now-start;else if(localCompleteFrame===null)localCompleteFrame=now-start;}if(document.querySelector('[aria-label="正在准备复制"]'))sawPreparing=true;else if(sawPreparing&&prepared===null)prepared=now-start;raf=requestAnimationFrame(tick);} raf=requestAnimationFrame(tick); - window.__canvasPerformance={read(){return{elapsedMs:performance.now()-start,firstDOMMs:firstDOM,firstPresentedFrameMs:firstFrame,allDOMMs:allDOM,allPresentedFrameMs:allFrame,prepareMs:prepared,nodeCount:document.querySelectorAll('.react-flow__node').length,longTasksSupported:supported,longTaskCount:tasks.length,maxLongTaskMs:Math.max(0,...tasks.map(task=>task.duration)),totalLongTaskMs:tasks.reduce((total,task)=>total+task.duration,0),maxFrameGapMs:Math.max(0,...gaps),longTasks:tasks};},stop(){cancelAnimationFrame(raf);if(observer){tasks.push(...observer.takeRecords().map(entry=>({start:entry.startTime-start,duration:entry.duration})));observer.disconnect();}return this.read();}}; + window.__canvasPerformance={read(){const sorted=[...gaps].sort((a,b)=>a-b),p95=sorted[Math.max(0,Math.ceil(sorted.length*.95)-1)]??0;return{frameGapP95Ms:p95,elapsedMs:performance.now()-start,firstDOMMs:firstDOM,firstPresentedFrameMs:firstFrame,localCompleteMs:localComplete,localCompleteFrameMs:localCompleteFrame,prepareMs:prepared,maxMountedNodes:maxNodes,nodeCount:document.querySelectorAll('.react-flow__node').length,longTasksSupported:supported,longTaskCount:tasks.length,maxLongTaskMs:Math.max(0,...tasks.map(task=>task.duration)),totalLongTaskMs:tasks.reduce((total,task)=>total+task.duration,0),maxFrameGapMs:Math.max(0,...gaps),longTasks:tasks};},stop(){cancelAnimationFrame(raf);if(observer){tasks.push(...observer.takeRecords().map(entry=>({start:entry.startTime-start,duration:entry.duration})));observer.disconnect();}return this.read();}}; document.querySelector('[aria-label="深复制选中内容"]').click(); })()`); - await wait(`document.querySelectorAll('.react-flow__node').length===${target}&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('已加入画布')&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'large copy did not finish rendering and synchronization'); + await wait(`document.querySelectorAll('.react-flow__node').length>0&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('已加入画布')&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'large copy did not finish rendering and synchronization'); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); const measurements=await evaluate('window.__canvasPerformance.stop()'),document=documents.get(root),copiedCanvas=[...document.canvases.values()].find(canvas=>canvas.parent&&canvas.id!==partition); - assert.equal(document.elements.size,2*count+5);assert.ok(copiedCanvas);assert.ok([...document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId==='perf-source'&&element.target.elementId===copiedCanvas.parent.entryElementId));assert.ok(document.elements.has('perf-incoming'));assert.equal(measurements.nodeCount,target);assert.ok(measurements.firstPresentedFrameMs!==null&&measurements.allPresentedFrameMs!==null); - report={scenario:'actual GraphPage deep copy of expanded partition',mode:'Next development webpack; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks',count,initialReadyMs,measurements,browser:await call('Browser.getVersion'),serverElements:document.elements.size,serverCanvases:document.canvases.size,wallMs:Date.now()-started}; + assert.equal(document.elements.size,2*count+5);assert.ok(copiedCanvas);assert.ok([...document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId==='perf-source'&&element.target.elementId===copiedCanvas.parent.entryElementId));assert.ok(document.elements.has('perf-incoming'));assert.ok(measurements.firstPresentedFrameMs!==null&&measurements.localCompleteFrameMs!==null);if(count>=100)assert.ok(measurements.nodeCountelement.structure==='node'&&element.ownerCanvasId===copiedCanvas.id&&element.geometry.x===originalLast.geometry.x&&element.geometry.y===originalLast.geometry.y);assert.ok(copiedLast); + const world=toWorld(canvasTransform(document,copiedLast.ownerCanvasId),{x:copiedLast.geometry.x+copiedLast.geometry.width/2,y:copiedLast.geometry.y+copiedLast.geometry.height/2}); + await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),rect=pane.getBoundingClientRect(),matrix=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);pane.dispatchEvent(new WheelEvent('wheel',{deltaX:(${world.x}*matrix.a+matrix.e-rect.width/2)/.5,deltaY:(${world.y}*matrix.a+matrix.f-rect.height/2)/.5,bubbles:true,cancelable:true}));})()`); + await wait(`!!document.querySelector('.react-flow__node[data-id="${copiedLast.id}"] textarea')`,'offscreen copied card did not mount after viewport pan'); + const viewportCheck={visibleEdgesBeforePan:visibleEdges,copiedId:copiedLast.id,mountedAfterPan:await evaluate('document.querySelectorAll(".react-flow__node").length')}; + report={scenario:'actual GraphPage deep copy of expanded partition',mode:'Next development webpack; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks',count,initialReadyMs,measurements,viewportCheck,browser:await call('Browser.getVersion'),serverElements:document.elements.size,serverCanvases:document.canvases.size,wallMs:Date.now()-started}; const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile(reportPath.endsWith('.json')?reportPath.slice(0,-5)+'.png':reportPath+'.png',Buffer.from(screenshot.data,'base64')); }catch(error){let measurements;try{measurements=await evaluate('window.__canvasPerformance?.stop()');}catch{}report={scenario:'actual GraphPage deep copy of expanded partition',count,error:error.message,measurements,wallMs:Date.now()-started};await writeFile(reportPath,JSON.stringify(report,null,2));throw error;} await writeFile(reportPath,JSON.stringify(report,null,2));console.log('MEASURE '+JSON.stringify({...report,measurements:{...report.measurements,longTasks:undefined}}));console.log('REPORT '+reportPath); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 7615d3f..f7321b1 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -258,6 +258,22 @@ try { await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'copy history did not undo its published content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'copy replay revived an undone record'); console.log('PASS actual deep copy toolbar action publishes a durable task, survives reload and does not revive its undone content'); + // Portalled frame-title drafts are not represented by a node editing mode. + await evaluate(`document.querySelector('.react-flow__node[data-id="relocation-frame"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`); + 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'); + 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'); + await evaluate('window.pinnedFrameTitle.blur()');for(let i=0;i<100&&documents.get(root).elements.get('relocation-frame').data.title!=='Pinned partition draft';i++)await pause(20);assert.equal(documents.get(root).elements.get('relocation-frame').data.title,'Pinned partition draft'); + await until(`document.querySelectorAll('.react-flow__node').length===0`,'viewport culling did not resume after title blur');await evaluate(panHome);await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'panning back did not remount the node'); + await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`); + await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'text editor did not enter pinned mode');await evaluate('window.pinnedText=document.activeElement; void 0');await call('Input.imeSetComposition',{text:'保',selectionStart:1,selectionEnd:1});await evaluate(panFar);await pause(250); + assert.equal(await evaluate(`window.pinnedText.isConnected&&document.activeElement===window.pinnedText&&window.pinnedText.value.includes('保')`),true,'viewport culling interrupted active IME');await call('Input.insertText',{text:'保留输入'});for(let i=0;i<100&&!documents.get(root).elements.get('b').data.text.endsWith('保留输入');i++)await pause(20);assert.ok(documents.get(root).elements.get('b').data.text.endsWith('保留输入')); + await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await until(`document.querySelectorAll('.react-flow__node').length===0`,'viewport culling did not resume after IME editing');await evaluate(panHome);await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'edited node did not remount');assert.ok(await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value.endsWith('保留输入')`)); + console.log('PASS native viewport culling retains unsaved portalled frame titles and active IME across pan, then resumes and remounts saved content'); + await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`); await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view'); console.log('PASS logout retires the runtime before dropping its view without a render error');