Batch node measurements and mount toolbars only while active

This commit is contained in:
2026-11-10 00:03:14 +08:00
parent da7d9d056f
commit d8bf6406c6
9 changed files with 97 additions and 14 deletions
+26
View File
@@ -694,3 +694,29 @@ are not an SLA or proof of overall speedup. The 10000-child baseline probe at
0ce3abb remained unresponsive with a CPU-active renderer and approximately
4.4 GiB sampled physical footprint, and was interrupted. Its completion, first
frame and root cause remain unverified; 10000-node support is not claimed.
Node measurement requests now share a microtask batch per React Flow store.
`createNodeInternalsBatch` owns a Map from mount tokens to node IDs and a dirty
Set of tokens. A flush deduplicates live IDs and invokes the public bulk update
once. Releasing a mount removes its pending request without touching a replacement
mount for the same ID. Reentrant invalidation schedules another flush; separate
stores never share work. The React adapter keys its WeakMap by `getState`, because
`useStoreApi()` creates a separate wrapper per hook. Acquisition/release happens
in effects, and the invalidate callback stays stable across renders. The SDK
still schedules its own animation frame; the adapter adds no extra frame timer.
Inactive node/frame toolbars no longer mount `NodeToolbar`. AnimatePresence owns
the conditional toolbar so selection, portal editing and exit animation remain
available. In React Flow 12.12.1 each mounted toolbar subscribes to a selector
that scans the whole node array; an empty child was not an inactive subscription.
Likewise each individual forced measurement updates all absolute positions, so
per-node mount calls multiplied work. Both paths are bounded by actual active
UI needs now. These are renderer resources; no model, history or durable queue
state changes.
A 10000-node page reached its initial scene after these changes, but its copy
probe still exceeded the 240-second completion window. During the copy it accepted
read-only diagnostics and displayed incremental content; commit/persistence costs
remain unresolved at that scale. Do not interpret the initial-scene improvement
or the batching checks as a passed 10000-node copy budget.
+4 -4
View File
@@ -33,8 +33,8 @@ export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boole
setTitle(name);
}
return <>
<NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
<AnimatePresence>{selected && <motion.div ref={overlay} className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
<AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
<motion.div ref={overlay} className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }}
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<input className={styles.frameNameInput} aria-label="分区名称" value={title} maxLength={300} onChange={(event) => setTitle(event.target.value)} onBlur={() => { if (cancelBlur.current) cancelBlur.current = false; else saveTitle(); }}
@@ -50,8 +50,8 @@ export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boole
<label className={styles.subgraphScale} title="子画布概览比例,不修改内容尺寸"><input aria-label="子画布概览比例" type="number" min={5} max={400} step={5} value={Math.round((data.presentation?.contentScale || 1) * 100)} onChange={(event) => { const scale = +event.target.value / 100; if (scale >= .05 && scale <= 4) editing.frame(id, { presentation: { ...data.presentation, contentScale: scale } }); }} />%</label>
<a href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} aria-label="打开子画布" title="打开子画布"><ExternalLink size={15} /></a></>}
</motion.div>}</AnimatePresence>
</NodeToolbar>
</motion.div>
</NodeToolbar>}</AnimatePresence>
<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>}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import {useCallback,useEffect,useRef} from 'react';
import {useStoreApi,useUpdateNodeInternals} from '@xyflow/react';
import {createNodeInternalsBatch} from '@/lib/canvas-bindings/node-internals';
// useStoreApi wraps the API per hook. Its shared getState function identifies the
// actual store, separating canvases even when they render the same project.
const batches=new WeakMap<object,ReturnType<typeof createNodeInternalsBatch>>();
export function useNodeInternalsUpdate(id:string){
const scope=useStoreApi().getState,update=useUpdateNodeInternals();
const lease=useRef<ReturnType<ReturnType<typeof createNodeInternalsBatch>['acquire']>|undefined>(undefined);
useEffect(()=>{
let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);}
const owner=batch.acquire(id);lease.current=owner;
return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;};
},[scope,update,id]);
return useCallback(()=>lease.current?.invalidate(),[]);
}
+4 -4
View File
@@ -18,8 +18,8 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
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 <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
<AnimatePresence>{selected && <motion.div ref={overlay} initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
return <AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
<motion.div ref={overlay} initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if(event.key!=="Escape"){event.stopPropagation();return;}
@@ -39,6 +39,6 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
{!(["image", "plain-image"].includes(data.kind)) && <button title="清除背景" aria-label="清除背景" onClick={() => patch({ background: "transparent" })}>∅</button>}
{data.kind !== "icon" && <ResizeToggle id={id} renderedTarget={data.__headlessTarget} />}
<button data-rotate-toggle={id} title="旋转模式" aria-label="旋转模式" aria-pressed={editing.rotating === id} onClick={() => editing.rotate(editing.rotating === id ? null : id)}><RotateCw size={16} /></button>
</motion.div>}</AnimatePresence>
</NodeToolbar>;
</motion.div>
</NodeToolbar>}</AnimatePresence>;
}
+4 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
import { useReactFlow, useUpdateNodeInternals, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
import { useReactFlow, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
import { Grip, Maximize2, RotateCw } from "lucide-react";
import { elementCapabilities } from "@/lib/canvas-elements";
import type { CanvasNode } from "@/lib/canvas";
@@ -13,6 +13,7 @@ import { ContentHandles } from "./content-handles";
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture";
import {canvasKeyContext,routeCanvasKey} from "@/lib/canvas-bindings/input-router";
import styles from "./canvas.module.css";
import {useNodeInternalsUpdate} from "./node-internals";
export {NodeEditing} from "./node-editing";
import {useScopedNodeEditing} from "./node-editing";
@@ -22,7 +23,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
const { id, data, selected, width, height, children, className = "" } = props;
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const flow = useReactFlow<CanvasNode>();
const update = useUpdateNodeInternals();
const update = useNodeInternalsUpdate(id);
const { zoom } = useViewport();
const frame = useRef<HTMLDivElement>(null);
const gesture = useRef<Gesture | null>(null);
@@ -33,7 +34,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
const capabilities = elementCapabilities(data.kind);
const resizing = capabilities.resizable && editing.resizing === id && selected;
const rotating = capabilities.rotatable && editing.rotating === id && selected;
useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]);
useEffect(() => { update(); }, [id, rotation, width, height, selected, update]);
useEffect(() => {
if (!rotating && !resizing) return;
const outside = (event: globalThis.PointerEvent) => {
+17
View File
@@ -0,0 +1,17 @@
/** One renderer store's transient measurement requests. Not model or history state. */
export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flush:()=>void)=>void=queueMicrotask){
const owners=new Map<symbol,string>(),dirty=new Set<symbol>();let queued=false;
function flush(){
queued=false;const ids=new Set<string>();for(const owner of dirty){const id=owners.get(owner);if(id!==undefined)ids.add(id);}dirty.clear();
if(ids.size)update([...ids]);
}
return Object.freeze({
acquire(id:string){
const owner=Symbol(id);owners.set(owner,id);
return Object.freeze({
invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}},
dispose(){owners.delete(owner);dirty.delete(owner);},
});
},
});
}
+12
View File
@@ -0,0 +1,12 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createNodeInternalsBatch} from '../../src/lib/canvas-bindings/node-internals.ts';
test('10000 mounted nodes coalesce repeated measurement requests into one renderer update',()=>{
const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),leases=Array.from({length:10000},(_,i)=>batch.acquire('n'+i));for(const lease of leases){lease.invalidate();lease.invalidate();}assert.equal(tasks.length,1);assert.equal(calls.length,0);tasks.shift()();assert.deepEqual(calls,[Array.from({length:10000},(_,i)=>'n'+i)]);for(const lease of leases)lease.dispose();
});
test('unmounted and replaced owners cannot retain or cancel another lease for the same node',()=>{
const tasks=[],calls=[],batch=createNodeInternalsBatch(ids=>calls.push(ids),fn=>tasks.push(fn)),old=batch.acquire('A'),next=batch.acquire('A'),removed=batch.acquire('B');old.invalidate();next.invalidate();removed.invalidate();old.dispose();removed.dispose();old.invalidate();tasks.shift()();assert.deepEqual(calls,[['A']]);next.invalidate();next.dispose();tasks.shift()();assert.equal(calls.length,1);
});
test('renderer stores and later updates remain independent, including reentrant invalidation',()=>{
const tasks=[],calls=[];let again;const one=createNodeInternalsBatch(ids=>{calls.push(['one',ids]);again?.();},fn=>tasks.push(fn)),two=createNodeInternalsBatch(ids=>calls.push(['two',ids]),fn=>tasks.push(fn)),a=one.acquire('A'),b=two.acquire('A');let reentered=false;again=()=>{if(!reentered){reentered=true;a.invalidate();}};a.invalidate();b.invalidate();assert.equal(tasks.length,2);tasks.shift()();assert.equal(tasks.length,2);tasks.shift()();tasks.shift()();assert.deepEqual(calls,[['one',['A']],['two',['A']],['one',['A']]]);a.dispose();b.dispose();
});
+6 -2
View File
@@ -10,10 +10,12 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co
let report;
try{
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()');
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node-toolbar").length'),0,'unselected nodes retained empty toolbar subscriptions');
const initialReadyMs=await evaluate('performance.now()');console.log('PERF initial scene ready '+Math.round(initialReadyMs)+' ms');
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)))`);
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node-toolbar").length'),1,'selected frame did not own exactly one toolbar');
await evaluate(`(()=>{
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;
@@ -24,8 +26,10 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co
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();
})()`);
console.log('PERF copy requested');
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 internalsBatches=await evaluate('window.__fixtureInternalsBatches??[]');assert.ok(internalsBatches.some(size=>size>1),'mounted nodes did not share a renderer measurement batch');
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.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');
@@ -35,7 +39,7 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co
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};
report={scenario:'actual GraphPage deep copy of expanded partition',mode:'Next development webpack; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks',count,initialReadyMs,internals:{batches:internalsBatches.length,maxBatch:Math.max(0,...internalsBatches),totalRequests:internalsBatches.reduce((sum,size)=>sum+size,0)},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);
+5 -1
View File
@@ -105,6 +105,9 @@ let next, chrome, socket, logs = '';
try {
await cp(join(source, 'src'), join(directory, 'src'), { recursive: true });
await cp(join(source, 'public'), join(directory, 'public'), { recursive: true });
// Count delivered measurement batches in the isolated fixture only.
const internalsFile=join(directory,'src/lib/canvas-bindings/node-internals.ts'),internals=await readFile(internalsFile,'utf8');assert.ok(internals.includes('if(ids.size)update([...ids]);'));await writeFile(internalsFile,internals.replace('if(ids.size)update([...ids]);',`if(ids.size){const host=globalThis as typeof globalThis&{__fixtureInternalsBatches?:number[]};(host.__fixtureInternalsBatches??=[]).push(ids.size);update([...ids]);}`));
if(!performanceMode){
await copyFile(join(source,'tests/headless/renderer-extension.tsx'),join(directory,'src/components/canvas/renderer-fixture.tsx'));
await copyFile(join(source,'tests/headless/react-hooks-fixture.tsx'),join(directory,'src/components/canvas/hooks-fixture.tsx'));
@@ -434,7 +437,8 @@ try {
scopeStreamRoot=id(850);const scopedRoot=scopeStreamRoot,scopedChild=id(851),scopeFrame={...node('scope-frame',scopedRoot,450),componentId:'frame',geometry:{x:450,y:150,width:600,height:450,rotation:0}};
documents.set(scopedRoot,createDocument(scopedRoot,[{id:scopedRoot,parent:null},{id:scopedChild,parent:{canvasId:scopedRoot,entryElementId:scopeFrame.id}}],[node('scope-node',scopedRoot,80),scopeFrame,node('scope-child',scopedChild,20),node('scope-tail',scopedChild,320)]));versions.set(scopedRoot,1);versions.set(scopedChild,1);boards.push({id:scopedRoot,workspace_id:workspace,kind:'board',version:1,title:'Scope ACK board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${scopedRoot}`});await until(`!!document.querySelector('[data-headless-canvas][data-model-loading] .react-flow__node[data-id="scope-node"]')`,'scope fixture did not show its certified owner');
const scopePoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="scope-node"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+50,y:r.y+25};})()`);
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
await until(`(()=>{const layer=document.querySelector('.react-flow__node[data-id="scope-node"] [aria-label^="单击选中"]');if(!layer)return false;const r=layer.getBoundingClientRect();for(const x of [.25,.5,.75,.9])for(const y of [.25,.5,.75]){const point={x:r.x+r.width*x,y:r.y+r.height*y};if(layer.contains(document.elementFromPoint(point.x,point.y))){window.__scopeEditPoint=point;return true;}}return false;})()`,'scope text remained covered by another surface');const scopePoint=await evaluate('window.__scopeEditPoint');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="scope-node"] textarea')&&!document.activeElement.readOnly`,'certified scope text did not enter editing');await call('Input.insertText',{text:' ACK before tail'});const scopeText=await evaluate('document.activeElement.value');
for(let i=0;i<150&&documents.get(scopedRoot).elements.get('scope-node').data.text!==scopeText;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get('scope-node').data.text,scopeText,'certified scope edit waited for another owner tail');assert.equal(versions.get(scopedRoot),2);assert.equal(versions.get(scopedChild),1);assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`),false);
assert.equal(await evaluate(`document.querySelector('button[aria-label="添加文本卡片"]').disabled`),false);await click('添加文本卡片');