Route canvas keyboard claims through text and gesture boundaries
This commit is contained in:
@@ -10,6 +10,7 @@ import { defaultCanvasRenderers } from "./canvas-renderers";
|
||||
import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types";
|
||||
import { equal } from "@/lib/canvas-core/values";
|
||||
import {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cache";
|
||||
import {routeCanvasKey} 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';
|
||||
@@ -148,7 +149,7 @@ 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) => { if (event.key === 'Escape' && !event.isComposing) cancel(); };
|
||||
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); };
|
||||
}, [engine, viewId]);
|
||||
@@ -210,13 +211,13 @@ 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 }); }}
|
||||
onPointerCancelCapture={() => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); drag.current = null; }}
|
||||
onKeyDown={event => {
|
||||
const input = (event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="dialog"],[role="menu"]');
|
||||
if (input || event.nativeEvent.isComposing) return;
|
||||
if (event.key === 'Escape') { engine.sessions.cancel('escape'); return; }
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z') { event.preventDefault(); report(event.shiftKey ? engine.editor.history.redo() : engine.editor.history.undo()); }
|
||||
else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); removeSelected(); }
|
||||
else if (event.key.toLowerCase() === 'h') engine.sessions.tool(viewId, 'pan');
|
||||
else if (event.key.toLowerCase() === 'v') engine.sessions.tool(viewId, 'select');
|
||||
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},{
|
||||
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;},
|
||||
});
|
||||
if(claim==='gesture'||claim==='tool'||claim==='shortcut'){event.preventDefault();event.stopPropagation();}
|
||||
}}>
|
||||
<ImagePreviews.Provider value={imagePreviews}>
|
||||
<CanvasRendererCommandsContext.Provider value={rendererCommands}>
|
||||
|
||||
@@ -48,7 +48,7 @@ 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") { cancelGesture(); 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); } };
|
||||
window.addEventListener("pointerdown", outside, true);
|
||||
window.addEventListener("keydown", escape);
|
||||
return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); };
|
||||
|
||||
@@ -10,3 +10,5 @@ export type * from './compat-types.ts';
|
||||
|
||||
export {createSelectorStore} from './selectors.ts';
|
||||
export type {ExternalSource} from './selectors.ts';
|
||||
export {routeCanvasKey} from './input-router.ts';
|
||||
export type {CanvasKeyInput,InputClaim,KeyHandler} from './input-router.ts';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Host normalizes DOM/native input; only the winning layer consumes an event. */
|
||||
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;
|
||||
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';
|
||||
if(input.overlay)return 'overlay';
|
||||
for(const layer of ['gesture','tool','shortcut'] as const)if(handlers[layer]?.(input))return layer;
|
||||
return 'unclaimed';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||
import {routeCanvasKey} 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');});
|
||||
@@ -161,6 +161,9 @@ try {
|
||||
assert.equal(calls.some(call => call.path.endsWith('/canvas')), false, 'canonical project read the legacy snapshot');
|
||||
assert.equal(await evaluate('!!document.querySelector("[aria-label=画布快捷操作]") && !document.querySelector("[aria-label=展开侧边栏]")'), true);
|
||||
const click = label => evaluate(`document.querySelector('[aria-label=${JSON.stringify(label)}]').click()`);
|
||||
assert.deepEqual(await evaluate(`(()=>{const canvas=document.querySelector('[data-headless-canvas]');const fire=(key,options={})=>{const e=new KeyboardEvent('keydown',{key,bubbles:true,cancelable:true,...options});canvas.dispatchEvent(e);return e.defaultPrevented;};return [fire('h',{ctrlKey:true}),fire('v',{metaKey:true}),fire('h',{isComposing:true}),fire('F9'),fire('h'),fire('v')];})()`),[false,false,false,false,true,true]);
|
||||
console.log('PASS canvas keyboard routing leaves modifiers, IME and unknown keys unclaimed and consumes plain tool shortcuts');
|
||||
|
||||
await click('添加文本卡片'); await until('document.querySelectorAll(".react-flow__node").length === 3', 'create did not render');
|
||||
for (let i = 0; i < 100 && documents.get(root).elements.size !== 4; i++) await pause(20);
|
||||
assert.equal(documents.get(root).elements.size, 4);
|
||||
|
||||
Reference in New Issue
Block a user