From 075bb794431896fa0b3ba74c371a33b8fbc293e2 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 9 Nov 2026 17:38:35 +0800 Subject: [PATCH] Route canvas keyboard claims through text and gesture boundaries --- src/components/canvas/headless-graph-canvas.tsx | 17 +++++++++-------- src/components/canvas/transformable-node.tsx | 2 +- src/lib/canvas-bindings/index.ts | 2 ++ src/lib/canvas-bindings/input-router.ts | 11 +++++++++++ tests/headless/input-router.test.mjs | 4 ++++ tests/headless/renderer.integration.mjs | 3 +++ 6 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 src/lib/canvas-bindings/input-router.ts create mode 100644 tests/headless/input-router.test.mjs diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 53beaf0..237df4b 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -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();} }}> diff --git a/src/components/canvas/transformable-node.tsx b/src/components/canvas/transformable-node.tsx index d3e6c8d..80c35a2 100644 --- a/src/components/canvas/transformable-node.tsx +++ b/src/components/canvas/transformable-node.tsx @@ -48,7 +48,7 @@ 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") { 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); }; diff --git a/src/lib/canvas-bindings/index.ts b/src/lib/canvas-bindings/index.ts index 9e6611f..2feb637 100644 --- a/src/lib/canvas-bindings/index.ts +++ b/src/lib/canvas-bindings/index.ts @@ -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'; diff --git a/src/lib/canvas-bindings/input-router.ts b/src/lib/canvas-bindings/input-router.ts new file mode 100644 index 0000000..615ad43 --- /dev/null +++ b/src/lib/canvas-bindings/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'; +} diff --git a/tests/headless/input-router.test.mjs b/tests/headless/input-router.test.mjs new file mode 100644 index 0000000..30f99da --- /dev/null +++ b/tests/headless/input-router.test.mjs @@ -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');}); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 4107fa5..fbfde6a 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -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);