Respect native input ownership before canvas keyboard cancellation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;},
|
||||
|
||||
@@ -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<CanvasNode>) {
|
||||
@@ -20,7 +21,12 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
|
||||
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="元素样式"
|
||||
onPointerDown={(event) => 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)) && <label title="文字或图标颜色" className={styles.colorPicker}><span style={{ background: style.color || "#37352f" }} /><input aria-label="文字或图标颜色" type="color" value={style.color || "#37352f"} onChange={(event) => patch({ color: event.target.value })} /></label>}
|
||||
{text && <>
|
||||
<select aria-label="字体" value={style.fontFamily || "sans"} onChange={(event) => patch({ fontFamily: event.target.value as TextStyle["fontFamily"] })}><option value="sans">正文</option><option value="serif">衬线</option><option value="mono">等宽</option></select>
|
||||
|
||||
@@ -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<CanvasNode> & { 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); };
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<CanvasKeyInput,'textEditing'|'overlay'>{
|
||||
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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user