Route canvas keyboard claims through text and gesture boundaries

This commit is contained in:
2026-11-09 17:38:35 +08:00
parent 575b5a9256
commit 075bb79443
6 changed files with 30 additions and 9 deletions
@@ -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')&&current.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}>
+1 -1
View File
@@ -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); };