Cull offscreen canvas nodes while preserving active editing
This commit is contained in:
@@ -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<CanvasNode>) {
|
||||
const { id, data, selected } = props;
|
||||
export function FrameNode(props: NodeProps<CanvasNode> & { 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<CanvasNode>) {
|
||||
|
||||
</motion.div>}</AnimatePresence>
|
||||
</NodeToolbar>
|
||||
<TransformableNode {...props} hideStyleToolbar className={`${styles.frameNode} ${incoming ? styles.frameDropTarget : ""} ${outgoing ? styles.frameDropExit : ""}`}>
|
||||
<TransformableNode {...nodeProps} hideStyleToolbar className={`${styles.frameNode} ${incoming ? styles.frameDropTarget : ""} ${outgoing ? styles.frameDropExit : ""}`}>
|
||||
{data.subgraph && data.presentation?.collapsed && <a className={styles.collapsedSubgraph} href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} onPointerDown={(event) => event.stopPropagation()}><FoldVertical size={22} /><span>{data.title}</span><small>打开子画布</small></a>}
|
||||
{mode !== "hidden" && <div className={styles.frameLabel} style={{ scale: mode === "fixed" ? 1 / (zoom * (data.viewScale || 1)) : 1 }} title={data.title}>{data.title}</div>}
|
||||
{(incoming > 0 || outgoing > 0) && <div className={styles.frameDropHint} style={{ scale: 1 / (zoom * (data.viewScale || 1)) }}>{incoming > 0 ? `松开加入 · ${incoming} 项` : `松开移出 · ${outgoing} 项`}</div>}
|
||||
{(["top", "right", "bottom", "left"] as const).map((side) => <div key={side} className={`${styles.frameBorderHit} ${styles["frameBorder_" + side]}`} title="拖动分区,已有成员一起移动" />)}
|
||||
{directAnchors && <ContentHandles selected={!!selected} />}
|
||||
{data.ports?.map((port) => {
|
||||
const peers = data.ports!.filter((item) => item.direction === port.direction);
|
||||
return <Handle key={port.id} id={`port:${port.id}`} type="source" position={port.direction === "input" ? Position.Left : Position.Right} className={styles.framePort} style={{ top: `${(peers.indexOf(port) + 1) / (peers.length + 1) * 100}%` }} isConnectableStart={selected && port.direction === "output"} isConnectableEnd={port.direction === "input"} title={port.direction === "input" ? "子画布输入" : "子画布输出"} />;
|
||||
|
||||
@@ -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<CanvasNode>) { return <FrameNode {...props} directAnchors />; }
|
||||
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, plainImage: PlainImageNode, frame: HeadlessFrameNode, icon: IconNode,
|
||||
unsupported: ({ data }: NodeProps<ProjectionNode>) => <div className={styles.node} style={{ width: "100%", height: "100%", padding: 14 }} role="note">{data.title}<small style={{ display: "block" }}>此元素需要更新版本才能编辑</small></div>,
|
||||
};
|
||||
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<ProjectionNode>();
|
||||
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<string, string>());
|
||||
@@ -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<Geometry> = { 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 <p role="status">画布已关闭</p>;
|
||||
return <div className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas tabIndex={-1}
|
||||
onFocusCapture={event => { 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
|
||||
<ImagePreviews.Provider value={imagePreviews}>
|
||||
<NodeEditing.Provider value={nodeEditing}>
|
||||
<EdgeEditing.Provider value={edgeEditing}>
|
||||
<ReactFlow<ProjectionNode> nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
|
||||
<ReactFlow<ProjectionNode> 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;
|
||||
|
||||
@@ -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;},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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()<deadline){if(exceptions.length)throw Error(exceptions.join('\n'));if(await evaluate(expression))return;await pause(100);}throw Error(label);};
|
||||
let report;
|
||||
try{
|
||||
await wait(`document.querySelectorAll('.react-flow__node').length===${initial}`,'initial large canvas did not render');
|
||||
await wait(`!!document.querySelector('.react-flow__node[data-id="perf-frame"]')&&document.querySelectorAll('.react-flow__node').length>=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.nodeCount<target,'offscreen DOM was not culled');assert.ok(await evaluate(`document.querySelector('[aria-label="画布导入"]').textContent.includes('${count+3} 项')`),'copy progress did not include all source records');
|
||||
const visibleEdges=await evaluate('document.querySelectorAll(".react-flow__edge").length');assert.equal(visibleEdges,2,'frame connections did not render for the offscreen source');
|
||||
// Completion is a model/progress check. Verify an offscreen copied card actually mounts after panning.
|
||||
const originalLast=document.elements.get(`perf-${count-1}`),copiedLast=[...document.elements.values()].find(element=>element.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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user