42 lines
3.3 KiB
TypeScript
42 lines
3.3 KiB
TypeScript
/**
|
||
* 归一化键盘事件的消费顺序:文本编辑 → 浮层 → 手势 → 工具 → 快捷键。
|
||
* 输入来源由宿主传入,路由器只报告获胜层,不直接 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 父关系。
|
||
* closest 可查节点的祖先;两个标记独立累计,可同时为 true。DOM 端口异常原样传播,不在此兜底成未编辑。 */
|
||
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?.()??[])]){
|
||
// 使用结构能力判断,跳过 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};
|
||
}
|
||
/** 已 defaultPrevented 的事件先返回 unclaimed,即使同时是文本/浮层输入也不再次认领。
|
||
* composing/textEditing 返回 text,overlay 返回 overlay;这两层只是上下文判断,不调用下方 handler。
|
||
* gesture→tool→shortcut 同步短路,抛错直接传播;返回 claim 不会自行 preventDefault/stopPropagation。 */
|
||
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';
|
||
}
|