diff --git a/headless/README.md b/headless/README.md index 059aebf..8b103cd 100644 --- a/headless/README.md +++ b/headless/README.md @@ -463,3 +463,13 @@ current records. Frame titles, node titles, text/style/branch/transform controls use the scoped bridge. Legacy non-headless contexts retain their old interface. Text grouping now records incarnation and drops obsolete groups. This is separate from the public explicit-Target action API and gesture lease tokens. + + +`canvasKeyContext(nativeEvent)` classifies text and overlay ownership using the +composed path, including retargeted shadow inputs, native selects and registered +`data-canvas-overlay-view` placements. Pass it to `routeCanvasKey` with the native +IME state and the event's current `defaultPrevented` value. Window Escape fallback +runs in the bubble phase so target handlers can claim it first; it cancels only +this view's gesture. Style toolbars dismiss their own modes on unclaimed Escape, +while input controls retain priority. The helper needs no browser globals at +module load or when called with non-DOM ports. diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 2e89295..2c798e4 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -16,7 +16,7 @@ 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"; +import {routeCanvasKey,canvasKeyContext} from "@/lib/canvas-bindings/input-router"; import {hitProjectedCanvas} from "@/lib/canvas-bindings/hit-query"; import {compatConnector,measuredConnector} from "@/lib/canvas-bindings/connectors"; import {createLoadingRendererCatalog} from '@/lib/canvas-bindings/renderers'; @@ -173,9 +173,14 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0; useEffect(() => { const cancel = () => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('view_interrupted'); drag.current = null; }; - const escape = (event: KeyboardEvent) => { const state=engine.sessions.getSnapshot(),element=event.target instanceof Element?event.target:null;if(event.key==='Escape'&&!event.defaultPrevented&&!event.isComposing&&!element?.closest('input,textarea,[contenteditable]:not([contenteditable="false"]),[role="textbox"],[role="dialog"],[role="menu"]')&&(state.gesture?.viewId===viewId||state.connectionGesture?.viewId===viewId)){cancel();} }; - window.addEventListener('blur', cancel); window.addEventListener('keydown', escape, true); - return () => { window.removeEventListener('blur', cancel); window.removeEventListener('keydown', escape, true); }; + // Bubble after the target/React handlers: an input owner can claim Escape first. + const escape = (event: KeyboardEvent) => { + if(event.key!=='Escape')return; + const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{gesture:()=>{const state=engine.sessions.getSnapshot();if(state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;cancel();return true;}}); + if(claim==='gesture'){event.preventDefault();event.stopPropagation();} + }; + window.addEventListener('blur', cancel); window.addEventListener('keydown', escape); + return () => { window.removeEventListener('blur', cancel); window.removeEventListener('keydown', escape); }; }, [engine, viewId]); const modes = view.modes; // Native culling keeps the full graph in React Flow. Retain every mounted editor/lease @@ -235,8 +240,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi onPointerDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:event.pointerId}; }} onPointerCancelCapture={event => { const currentDrag=drag.current;if(!currentDrag||currentDrag.pointerId!==undefined&¤tDrag.pointerId!==event.pointerId)return;const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId&&state.gesture.token===currentDrag.token)engine.sessions.cancel('pointer_cancel');drag.current=null; }} onKeyDown={event => { - const element=event.target instanceof Element?event.target:null; - const claim=routeCanvasKey({key:event.key,composing:event.nativeEvent.isComposing,textEditing:!!element?.closest('input,textarea,[contenteditable]:not([contenteditable="false"]),[role="textbox"]'),overlay:!!element?.closest('[role="dialog"],[role="menu"]'),defaultPrevented:event.defaultPrevented,meta:event.metaKey,ctrl:event.ctrlKey,alt:event.altKey,shift:event.shiftKey},{ + const claim=routeCanvasKey({key:event.key,composing:event.nativeEvent.isComposing,...canvasKeyContext(event.nativeEvent),defaultPrevented:event.defaultPrevented,meta:event.metaKey,ctrl:event.ctrlKey,alt:event.altKey,shift:event.shiftKey},{ gesture:input=>{const state=engine.sessions.getSnapshot();if(input.key!=='Escape'||state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;engine.sessions.cancel('escape');drag.current=null;return true;}, tool:input=>{if(input.ctrl||input.meta||input.alt||input.shift)return false;const key=input.key.toLowerCase();if(key!=='h'&&key!=='v')return false;engine.sessions.tool(viewId,key==='h'?'pan':'select');return true;}, shortcut:input=>{if(input.alt)return false;if((input.meta||input.ctrl)&&input.key.toLowerCase()==='z'){report(input.shift?engine.editor.history.redo():engine.editor.history.undo());return true;}if(!input.meta&&!input.ctrl&&!input.shift&&(input.key==='Delete'||input.key==='Backspace')&¤t.current.view.selection.size){removeSelected();return true;}return false;}, diff --git a/src/components/canvas/node-style-toolbar.tsx b/src/components/canvas/node-style-toolbar.tsx index 9529f74..8cffeae 100644 --- a/src/components/canvas/node-style-toolbar.tsx +++ b/src/components/canvas/node-style-toolbar.tsx @@ -8,6 +8,7 @@ import type { TextStyle } from "@/lib/canvas-elements"; import { ResizeToggle } from "./transformable-node"; import {useCanvasOverlay} from "./canvas-overlays"; import {useScopedNodeEditing} from "./node-editing"; +import {canvasKeyContext} from "@/lib/canvas-bindings/input-router"; import styles from "./canvas.module.css"; export function NodeStyleToolbar({ id, data, selected }: NodeProps) { @@ -20,7 +21,12 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps) return {selected && event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { if (event.key !== "Escape") event.stopPropagation(); }}> + onKeyDown={(event) => { + if(event.key!=="Escape"){event.stopPropagation();return;} + if(event.defaultPrevented||event.nativeEvent.isComposing||canvasKeyContext(event.nativeEvent).textEditing)return; + // The registered overlay owns its mode dismissal; global shortcuts stay out. + editing.rotate(null);editing.resize(null);event.preventDefault();event.stopPropagation(); + }}> {!(["image", "plain-image"].includes(data.kind)) && } {text && <> diff --git a/src/components/canvas/transformable-node.tsx b/src/components/canvas/transformable-node.tsx index 7925404..93d9d89 100644 --- a/src/components/canvas/transformable-node.tsx +++ b/src/components/canvas/transformable-node.tsx @@ -11,6 +11,7 @@ import { NodeStyleToolbar } from "./node-style-toolbar"; import { MembershipBadge } from "./frame-feedback"; 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"; export {NodeEditing} from "./node-editing"; @@ -38,7 +39,11 @@ export function TransformableNode(props: NodeProps & { children: Rea const outside = (event: globalThis.PointerEvent) => { if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"], [data-resize-toggle="' + id + '"]')) { editing.rotate(null); editing.resize(null); } }; - const escape = (event: KeyboardEvent) => { if (event.key === "Escape" && !event.defaultPrevented && !event.isComposing && !(event.target instanceof Element && event.target.closest('input,textarea,[contenteditable]:not([contenteditable="false"]),[role="textbox"],[role="dialog"],[role="menu"]'))) { cancelGesture(); editing.rotate(null); editing.resize(null); } }; + const escape = (event: KeyboardEvent) => { + if(event.key!=="Escape")return; + const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{tool:()=>{cancelGesture();editing.rotate(null);editing.resize(null);return true;}}); + if(claim==='tool'){event.preventDefault();event.stopPropagation();} + }; window.addEventListener("pointerdown", outside, true); window.addEventListener("keydown", escape); return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); }; diff --git a/src/lib/canvas-bindings/index.ts b/src/lib/canvas-bindings/index.ts index b1353f3..7b7adcf 100644 --- a/src/lib/canvas-bindings/index.ts +++ b/src/lib/canvas-bindings/index.ts @@ -10,7 +10,7 @@ export type * from './compat-types.ts'; export {createSelectorStore} from './selectors.ts'; export type {ExternalSource} from './selectors.ts'; -export {routeCanvasKey} from './input-router.ts'; +export {routeCanvasKey,canvasKeyContext} from './input-router.ts'; export type {CanvasKeyInput,InputClaim,KeyHandler} from './input-router.ts'; export {createFrameQueue} from './frame-queue.ts'; export type {FrameClock} from './frame-queue.ts'; diff --git a/src/lib/canvas-bindings/input-router.ts b/src/lib/canvas-bindings/input-router.ts index 615ad43..88274da 100644 --- a/src/lib/canvas-bindings/input-router.ts +++ b/src/lib/canvas-bindings/input-router.ts @@ -2,6 +2,20 @@ export type CanvasKeyInput={readonly key:string;readonly composing?:boolean;readonly textEditing?:boolean;readonly overlay?:boolean;readonly defaultPrevented?:boolean;readonly ctrl?:boolean;readonly meta?:boolean;readonly alt?:boolean;readonly shift?:boolean}; export type InputClaim='text'|'overlay'|'gesture'|'tool'|'shortcut'|'unclaimed'; export type KeyHandler=(input:CanvasKeyInput)=>boolean; +const textTarget = 'input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role="textbox"]'; +const overlayTarget = '[data-canvas-overlay-view],[role="dialog"],[role="menu"],[role="listbox"],[role="combobox"]'; +/** Inspect the composed path as well as the retargeted host (portals/shadow DOM). + * This uses a structural DOM port so pure consumers need no browser globals. */ +export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:()=>readonly unknown[]}):Pick{ + let textEditing=false,overlay=false; + for(const target of [event.target,...(event.composedPath?.()??[])]){ + if(!target||typeof (target as {closest?:unknown}).closest!=='function')continue; + const element=target as {closest(selector:string):unknown}; + textEditing ||= !!element.closest(textTarget); + overlay ||= !!element.closest(overlayTarget); + } + return {textEditing,overlay}; +} export function routeCanvasKey(input:CanvasKeyInput,handlers:{gesture?:KeyHandler;tool?:KeyHandler;shortcut?:KeyHandler}):InputClaim{ if(input.defaultPrevented)return 'unclaimed'; if(input.composing||input.textEditing)return 'text'; diff --git a/tests/headless/input-router.test.mjs b/tests/headless/input-router.test.mjs index 30f99da..ee37e82 100644 --- a/tests/headless/input-router.test.mjs +++ b/tests/headless/input-router.test.mjs @@ -1,4 +1,17 @@ import {test} from 'node:test';import assert from 'node:assert/strict'; -import {routeCanvasKey} from '../../src/lib/canvas-bindings/input-router.ts'; +import {routeCanvasKey,canvasKeyContext} from '../../src/lib/canvas-bindings/input-router.ts'; test('text, IME, overlays and already consumed input never execute document shortcuts',()=>{for(const flags of [{textEditing:true},{composing:true},{overlay:true},{defaultPrevented:true}]){let calls=0;const result=routeCanvasKey({key:'Backspace',...flags},{gesture:()=>{calls++;return true;},shortcut:()=>{calls++;return true;}});assert.equal(calls,0);assert.ok(['text','overlay','unclaimed'].includes(result));}}); test('gesture then tool then shortcut claim once; declining handlers fall through',()=>{for(const winner of ['gesture','tool','shortcut']){const calls=[];const handlers=Object.fromEntries(['gesture','tool','shortcut'].map(layer=>[layer,()=>{calls.push(layer);return layer===winner;}]));assert.equal(routeCanvasKey({key:'Escape'},handlers),winner);assert.deepEqual(calls,['gesture','tool','shortcut'].slice(0,['gesture','tool','shortcut'].indexOf(winner)+1));}assert.equal(routeCanvasKey({key:'x'},{}),'unclaimed');}); + +test('DOM input context recognizes registered overlays and native selects without browser globals',()=>{ + const select={closest:selector=>selector.split(',').includes('select')?select:null}; + const overlay={closest:selector=>selector.includes('[data-canvas-overlay-view]')?overlay:null}; + assert.deepEqual(canvasKeyContext({target:select}),{textEditing:true,overlay:false}); + assert.deepEqual(canvasKeyContext({target:overlay}),{textEditing:false,overlay:true}); + assert.deepEqual(canvasKeyContext({target:null,composedPath:()=>[null,{},42]}),{textEditing:false,overlay:false}); +}); +test('retargeted shadow events retain their inner text owner and never reach a gesture',()=>{ + const input={closest:selector=>selector.split(',').includes('textarea')?input:null},host={closest:()=>null}; + const context=canvasKeyContext({target:host,composedPath:()=>[input,host]});let canceled=false; + assert.equal(routeCanvasKey({key:'Escape',...context},{gesture:()=>{canceled=true;return true;}}),'text');assert.equal(canceled,false); +}); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index a62e72c..b19dc93 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -198,6 +198,10 @@ try { for (let i=0;i<100&&documents.get(root).elements.get('b').geometry.width!==300;i++)await pause(20); assert.equal(documents.get(root).elements.get('b').geometry.width,300,JSON.stringify({geometry:documents.get(root).elements.get('b').geometry,inputs:await evaluate('window.inputEvents'),text:await evaluate('document.body.innerText')}));assert.equal(documents.get(root).elements.get('b').geometry.height,220); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel+1); + await click('八向拉伸模式');await until(`document.querySelector('[data-resize-toggle="b"]')?.getAttribute('aria-pressed')==='true'`,'resize mode did not open'); + await evaluate(`document.querySelector('[data-node-style="b"] select').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`);await pause(50);assert.equal(await evaluate(`document.querySelector('[data-resize-toggle="b"]').getAttribute('aria-pressed')`),'true','native selector Escape dismissed the canvas mode'); + await evaluate(`(()=>{const button=document.querySelector('[data-resize-toggle="b"]');button.addEventListener('keydown',event=>event.preventDefault(),{once:true});button.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));})()`);await pause(50);assert.equal(await evaluate(`document.querySelector('[data-resize-toggle="b"]').getAttribute('aria-pressed')`),'true','toolbar ignored a child control that had claimed Escape'); + await evaluate(`document.querySelector('[data-resize-toggle="b"]').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`);await until(`document.querySelector('[data-resize-toggle="b"]')?.getAttribute('aria-pressed')==='false'`,'toolbar did not dismiss its own resize mode'); const textPoint = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]').getBoundingClientRect();return {x:r.x+60,y:r.y+30}})()`); await call('Input.dispatchMouseEvent', { type: 'mousePressed', ...textPoint, button: 'left', buttons: 1, clickCount: 2 }); await call('Input.dispatchMouseEvent', { type: 'mouseReleased', ...textPoint, button: 'left', buttons: 0, clickCount: 2 }); @@ -347,6 +351,7 @@ try { 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'); + assert.equal(await evaluate(`(()=>{const overlay=document.querySelector('[aria-label="分区名称"]').closest('[data-canvas-overlay-target]'),button=overlay.querySelector('button'),event=new KeyboardEvent('keydown',{key:'h',bubbles:true,cancelable:true});button.dispatchEvent(event);return event.defaultPrevented;})()`),false,'portalled toolbar button key was claimed by the canvas tool'); 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'); @@ -375,6 +380,9 @@ try { await click('撤销');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==3;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,3);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data.raw,{retained:true});await click('重做');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,4);await call('Page.reload');await until(`document.querySelector('[data-id="meter-one"] [data-renderer-fixture="meter"]')?.textContent.includes('Metric 4')&&!!document.querySelector('[data-renderer-fixture="metric-edge"]')`,'registered renderer edits did not recover'); console.log('PASS Host-installed custom model/node/edge definitions render without core type switches; command-backed edits preserve raw fields and recover through undo/redo/reload'); assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,before=engine.getSnapshot();window.__fixtureUnbound['meter-one'].rename('meter-one','unbound callback');return engine.getSnapshot()===before;})()`),true,'headless legacy editing without a rendered target was allowed'); + assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,view=[...engine.sessions.getSnapshot().views.values()][0],record=engine.getSnapshot().document.elements.get('meter-one'),button=document.createElement('button');document.querySelector('[data-headless-canvas]').append(button);button.addEventListener('keydown',event=>event.preventDefault());engine.sessions.begin(view.id,[{id:record.id,incarnation:record.incarnation}]);const token=engine.sessions.getSnapshot().gesture?.token;button.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));const retained=!!token&&engine.sessions.getSnapshot().gesture?.token===token;button.remove();engine.sessions.cancel('fixture_cleanup');return retained;})()`),true,'capture-phase Escape canceled a gesture before the target could claim it'); + assert.deepEqual(await evaluate(`(()=>{const engine=window.__fixtureEngine,view=[...engine.sessions.getSnapshot().views.values()][0],record=engine.getSnapshot().document.elements.get('meter-one'),canvas=document.querySelector('[data-headless-canvas]'),host=document.createElement('div');canvas.append(host);const shadow=host.attachShadow({mode:'open'}),textarea=document.createElement('textarea');shadow.append(textarea);const select=document.createElement('select');canvas.append(select);const fire=element=>{const event=new KeyboardEvent('keydown',{key:'h',composed:true,bubbles:true,cancelable:true});element.dispatchEvent(event);return event.defaultPrevented;};const protectedKeys=[fire(textarea),fire(select)];host.remove();select.remove();engine.sessions.begin(view.id,[{id:record.id,incarnation:record.incarnation}]);const outside=document.createElement('button');document.body.append(outside);const event=new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true});outside.dispatchEvent(event);outside.remove();return{protectedKeys,tool:engine.sessions.getSnapshot().views.get(view.id).tool,canceled:!engine.sessions.getSnapshot().gesture,claimed:event.defaultPrevented};})()`),{protectedKeys:[false,false],tool:'select',canceled:true,claimed:true}); + console.log('PASS keyboard targets claim Escape before canvas cancellation; native selects and shadow text retain their input, while unclaimed outside Escape cancels the owned gesture'); const staleEdit=await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],target=engine.getSnapshot().document.elements.get('meter-one');window.__fixtureStaleLegacy=old;engine.editor.dispatch({type:'element.remove',target:{id:target.id,incarnation:target.incarnation}});engine.editor.history.undo();const restored=engine.getSnapshot();old.rename('meter-one','stale callback');return{unchanged:engine.getSnapshot()===restored,incarnation:restored.document.elements.get('meter-one').incarnation};})()`);assert.equal(staleEdit.incarnation,2);assert.equal(staleEdit.unchanged,true,'old rendered edit callback modified a restored incarnation'); console.log('PASS old rendered edit callbacks cannot modify a restored incarnation'); await until(`window.__fixtureLegacy['meter-one']!==window.__fixtureStaleLegacy&&window.__fixtureEngine.getSnapshot().document.elements.get('meter-one').incarnation===2`,'restored renderer did not bind a fresh edit target');await evaluate(`window.__fixtureLegacy['meter-one'].rename('meter-one','fresh callback')`);for(let i=0;i<200&&documents.get(extensionRoot).elements.get('meter-one')?.data.title!=='fresh callback';i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.title,'fresh callback');