/** * 归一化键盘事件的消费顺序:文本编辑 → 浮层 → 手势 → 工具 → 快捷键。 * 输入来源由宿主传入,路由器只报告获胜层,不直接 preventDefault、执行命令或访问浏览器全局。 */ /** Host normalizes DOM/native input; only the winning layer consumes an event. */ /** 将 DOM/native 键盘上下文压成只读值;composing 保护输入法组合,modifier 供具体处理器解释。 */ 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}; /** 只有一个层获得消费权;unclaimed 表示宿主不应据此拦截事件。 */ export type InputClaim='text'|'overlay'|'gesture'|'tool'|'shortcut'|'unclaimed'; /** 返回 true 即获得事件;后续低优先级处理器不会再运行。 */ export type KeyHandler=(input:CanvasKeyInput)=>boolean; // 标准文本控件、可编辑内容和 textbox 角色均视为正在输入,不让画布快捷键抢走按键。 const textTarget = 'input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role="textbox"]'; // 画布浮层标记和交互 ARIA 角色优先于全局画布命令。 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. */ /** 检查 target 和 composedPath 上所有可 closest 的节点,识别 shadow DOM 重定向后的输入上下文。 * portal 需要自身携带浮层标记/角色;这里只检查实际路径,不推断不存在于 DOM 路径的 React 父关系。 */ export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:()=>readonly unknown[]}):Pick{ let textEditing=false,overlay=false; for(const target of [event.target,...(event.composedPath?.()??[])]){ // 使用结构能力判断,跳过 Window、文本节点等无 closest 的路径项,无需 instanceof Element。 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'; if(input.overlay)return 'overlay'; for(const layer of ['gesture','tool','shortcut'] as const)if(handlers[layer]?.(input))return layer; return 'unclaimed'; }