feat(09-23): implement scoped hotkey system with commands and excitation-driven modes

This commit is contained in:
unanmed 2026-10-22 18:33:53 +08:00
parent 7409a47037
commit 77f5b3aa19
9 changed files with 2080 additions and 0 deletions

View File

@ -0,0 +1,188 @@
---
phase: 09-minimum-browser-runnable
plan: 23
subsystem: input
tags: [hotkey, keyboard, excitation, typescript, vitest]
# Dependency graph
requires:
- phase: 09-minimum-browser-runnable
provides: 09-21 / 09-22 的构建与渲染端边界(新实现不接线 user 代码,仅提供 packages/system 内可用对象)
provides:
- "packages/system/src/hotkey/command.ts:HotkeyCommand(IHotkeyCommand 实现)+ isAuxiliaryKey"
- "packages/system/src/hotkey/system.ts:HotkeySystem(IHotkeySystem + IExcitable<number> 实现)"
- "hotkey/index.ts 新增 command / system 导出;packages/system 新增 @motajs/animate workspace 依赖"
- "logger.json warn 201–205 文案"
- "command.test.ts / system.test.ts 共 60 条用例"
affects: [packages-user/client-base 的 IClientHotkey 接线(后续计划), 键盘输入相关后续阶段]
# Actuals(与计划 estimate 同一把尺:chars/4 的已实现 diff,非 harness token 数)
actuals:
tokens: 15236
tasks: 2
commits: 0
plan_head_before: 7409a470370882a2eb18cceb638f22f6452a42e4
# Tech tracking
tech-stack:
added:
- "@motajs/animate(workspace:*,packages/system 依赖,用于 IExcitable / IExcitation)"
patterns:
- "组合匹配:KeyUp 走「辅助键 + 至多一个常规键」规则,KeyDown 类模式走 holding 子集规则"
- "时序去定时器:全部时间来自 excited(payload),待触发项在 flushPending 中按 payload 判定"
- "延时触发不保存 context 快照:触发时用当前 holding 重新 createContext"
- "作用域栈只让栈顶生效;enterScope / leaveScope / up 时统一 dropPending"
key-files:
created:
- packages/system/src/hotkey/command.ts
- packages/system/src/hotkey/system.ts
- packages/system/src/hotkey/__test__/command.test.ts
- packages/system/src/hotkey/__test__/system.test.ts
modified:
- packages/system/src/hotkey/index.ts
- packages/system/package.json
- pnpm-lock.yaml
- packages/common/src/logger.json
key-decisions:
- "Task 0 汇报关卡由用户回复「可以执行」闭环;OI-1 / OI-2 / OI-3 / OI-4 / OI-5 按计划提议默认执行(命名未改动)"
- "type.ts 核验后零改动(IHotkeySystem 已无 emit 成员);hotkey.ts / keyboard.ts / keyCodes.ts 零改动"
- "IExcitable<number> 只加在 HotkeySystem 类上,IHotkeySystem 接口不新增 excited 成员"
- "Meta 映射只在 listen 内做本地 91 / 92 / 224 → KeyCode.Meta,keyCodes.ts 不改"
- "delayed pending 不保存 context,flushPending 用触发时刻的 holding 重建"
- "logger.json 只追加 warn 201–205 五行(逐字 OI-5),200 / 1001 及其它条目未动"
- "master 计划 estimate.tokens=95000 为未校准估算(confidence: low);实际 diff 约 15236 tokens"
patterns-established:
- "addCommand 重复 id:warn 204 + 返回已有对象,不覆盖"
- "block 只按当前作用域内已实现且实际触发的指令记录,匹配但无实现不阻断"
- "KeyUp 非法组合只在 setKeyCombination(当前 KeyUp 模式)与 keyUp() 两个时机各发一次 203"
requirements-completed: []
coverage:
- id: D1
description: "HotkeyCommand 实现按键组合管理、四个模式方法与 203 告警"
verification:
- kind: unit
ref: "packages/system/src/hotkey/__test__/command.test.ts (12 tests)"
status: pass
human_judgment: false
- id: D2
description: "HotkeySystem 实现指令注册表、作用域栈、KeyUp / KeyDown 派发与优先级 / block"
verification:
- kind: unit
ref: "packages/system/src/hotkey/__test__/system.test.ts (KeyUp / 作用域 / 优先级用例)"
status: pass
human_judgment: false
- id: D3
description: "激励驱动的 timeout / interval / dt 时序与待触发生命周期"
verification:
- kind: unit
ref: "packages/system/src/hotkey/__test__/system.test.ts (Once / Continuous / Throttle 用例)"
status: pass
human_judgment: false
- id: D4
description: "listen 映射(含 Meta 91/92/224)、repeat 跳过、preventDefault 与解绑"
verification:
- kind: unit
ref: "packages/system/src/hotkey/__test__/system.test.ts (listen 用例)"
status: pass
human_judgment: false
# Metrics
duration: 12min
completed: 2026-10-22
status: complete
---
# Phase 09 Plan 23: 新按键系统实现 Summary
**HotkeyCommand 与 HotkeySystem 落地:KeyUp / Once / Continuous / Throttle 四模式、作用域栈、优先级与 block、激励驱动的 timeout / interval / dt,以及 201–205 告警与 Meta 映射**
## Performance
- **Duration:** 约 12 min(含 Task 1 tracer + Task 2 扩展与本机门禁)
- **Started:** 2026-10-22T18:15:00+08:00
- **Completed:** 2026-10-22T18:27:00+08:00
- **Tasks:** 2(Task 0 为 blocking-human 关卡,已由用户「可以执行」闭环)
- **Files modified:** 8(4 新建实现/测试 + 4 修改)
## Accomplishments
- `HotkeyCommand`:组合数组深拷贝、`setKeys` 扩缩容、`setKeyCombination` / `getKeyCombination` / `iterateKeyCombinations`、四个模式方法;KeyUp 非法组合在两个时机各发一次 203。
- `HotkeySystem`:注册表(204 / 202 / 201)、作用域栈(201 / 205、仅栈顶生效)、`holding` 与上下文推导、KeyUp 四条规则、KeyDown 子集匹配、优先级降序与 block、`down` / `up` 布尔返回。
- 时序完全由 `excited(payload)` 驱动:`timeout` / `interval` / `dt` 无任何 `setTimeout` / `Date.now`;延时项在松键、作用域退出、模式切换时丢弃,并在触发时刻重算 context。
- `listen` 本地映射 91 / 92 / 224 → `KeyCode.Meta`、跳过 `repeat`、按返回值 `preventDefault`、`listen(null)` 解绑、同元素重复监听为空操作。
- `logger.json` warn 201–205 逐字落地;`packages/system` 声明 `@motajs/animate` 且 lockfile 由 pnpm 更新。
- 测试 60 条全绿(command 12 + system 48),每个 `it` 前均有说明注释。
## Files Created/Modified
- `packages/system/src/hotkey/command.ts`(新建)— `HotkeyCommand` 与 `isAuxiliaryKey`
- `packages/system/src/hotkey/system.ts`(新建)— `HotkeySystem` 与内部作用域 / 待触发结构
- `packages/system/src/hotkey/index.ts`(修改)— 追加 `./command` 与 `./system` 导出
- `packages/system/package.json`(修改)— 增加 `"@motajs/animate": "workspace:*"`
- `pnpm-lock.yaml`(修改)— `packages/system` 导入器新增 animate 链接(仅由 pnpm 生成)
- `packages/common/src/logger.json`(修改)— warn 段 `"200"` 与 `"1001"` 之间追加 201–205
- `packages/system/src/hotkey/__test__/command.test.ts`(新建)— 12 条指令对象用例
- `packages/system/src/hotkey/__test__/system.test.ts`(新建)— 48 条系统用例
## Decisions Made
- 按计划的 OI 提议默认执行:测试路径 `__test__/command.test.ts` / `system.test.ts`、辅助函数名 `isAuxiliaryKey`、内部命名清单(`IHotkeyScope` / `IHotkeyMatch` / `IHotkeyPending`、`HotkeyHandler` / `HotkeyListener`、私有 `command` / `combinations` 等)均未改动。
- OI-4 四条按提议落地:松键取消 KeyDown 类待触发项、同一事件同一指令取最小索引触发一次、空栈无参 `leaveScope()` 发 205、延时项 context 按触发时刻重算。
- OI-5 五条文案逐字写入 `logger.json`,既有条目(含 200 / 1001)一字未动。
- `type.ts` 核验后确认 `IHotkeySystem` 无 `emit` 成员,保持零改动;`IExcitable<number>` 仅加在类上。
- 旧实现 `hotkey.ts` / `keyboard.ts` 与 `keyCodes.ts` 零改动,由 LEGACY / SCOPE 闸门验证。
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] 测试伪事件的类型定义无法直接继承 KeyboardEvent**
- **Found during:** Task 1(类型闸门)
- **Issue:** `interface FakeKeyEvent extends KeyboardEvent` 把 `preventDefault` 收窄为 `Mock`,导致 TS2430,并让 `dispatch` 的参数不兼容。
- **Fix:** 将 `FakeKeyEvent` 改为仅含 `keyCode` / `repeat` / `preventDefault` 的独立接口,`FakeElement.dispatch` 在调用监听器时单次断言为 `KeyboardEvent`。
- **Files modified:** `packages/system/src/hotkey/__test__/system.test.ts`
- **Verification:** 类型闸门与 vitest 全绿。
- **Committed in:** 未提交(按执行指令保留在工作树)
---
**Total deviations:** 1 auto-fixed(Rule 1)
**Impact on plan:** 仅为测试伪事件类型修正,未改变实现语义与对外接口。
## Verification
- `cmd /c "pnpm exec vitest run .../command.test.ts .../system.test.ts"` → 2 files / 60 tests passed。
- `cmd /c "pnpm exec eslint <5 个 TS 文件>"`(Task 1)与 `eslint system.ts system.test.ts`(Task 2)→ 无输出、exit 0。
- STRUCTURE GATE → `OK structure: classes/exports/dependency/logger-201-205/tests`。
- MODE GATE → `OK mode gate: three KeyDown modes wired, excitation-driven timing, 48 system test cases`。
- TYPECHECK GATE(Task 1 / Task 2)→ hotkey 新文件无错误;`vue-tsc` exit 2,仅 `hotkey.ts` / `keyboard.ts` 各 1 条既有 legacy 诊断(基线 2 条)。
- LEGACY GATE → `hotkey.ts` / `keyboard.ts` / `type.ts` 相对 HEAD 零改动。
- SCOPE GATE(Task 1 后与 Task 2 末尾)→ 所有改动均命中 `files_modified`(9 项,用户既有 WIP 豁免)。
- prettier 仅作用于 5 个 TS 文件;`logger.json` / `package.json` / `pnpm-lock.yaml` 未传给 prettier。
## Known Boundaries
- 未接线 `packages-user/client-base/src/hotkey` 的 `IClientHotkey`(本计划只提供实现)。
- 未端到端运行 `build:game` / `build:packages`(渲染端仍有用户 WIP 的 stale import,沿用 09-21 / 09-22 记录)。
- `hotkey.ts` / `keyboard.ts` 的 2 条 legacy 类型错误保持原状,未修复。
- 未提供存档序列化(`type.ts` 第 4 行 TODO 保留)。
- 未新增 vitest 配置;`listen` 使用 Node 环境下的最小 fake 元素。
- **估算说明:** 计划 `estimate.tokens=95000` 为未校准估算(`confidence: low`);本计划实际 diff 约 15236 tokens(chars/4),未按估算反推调整。
## Issues Encountered
- 无阻塞问题;仅有一次测试伪事件类型不兼容,已按 Rule 1 修正。
## Next Phase Readiness
- 新按键系统实现与测试已就绪,可从 `hotkey/index.ts` 取用 `HotkeyCommand` / `HotkeySystem`。
- 后续需要单独计划把 `IClientHotkey.system` 接到 `HotkeySystem` 实例,并补浏览器端集成验证。
---
*Phase: 09-minimum-browser-runnable*
*Completed: 2026-10-22*

View File

@ -291,6 +291,11 @@
"198": "Duplicate shop id '$1', old shop will be overwritten.",
"199": "A shop state instance and a hero state instance binding is needed before buy or sell items.",
"200": "Cannot buy or sell item since target item '$1' does not exist.",
"201": "Cannot set hotkey condition or implement command since no scope is active.",
"202": "Cannot implement nonexistent hotkey command '$1'.",
"203": "Hotkey command '$1' has an invalid KeyUp combination: at most one non-auxiliary key is allowed.",
"204": "Duplicate hotkey command '$1', the existing command will be used.",
"205": "Cannot leave nonexistent hotkey scope.",
"1001": "Event(setBlock): Unknown tile '$1' when setting block."
}
}

View File

@ -1,6 +1,7 @@
{
"name": "@motajs/system",
"dependencies": {
"@motajs/animate": "workspace:*",
"@motajs/common": "workspace:*"
}
}

View File

@ -0,0 +1,221 @@
// 测试按键指令对象的组合管理、模式切换与非法组合告警
import { logger } from '@motajs/common';
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
import { HotkeyCommand, isAuxiliaryKey } from '../command';
import { KeyCode } from '../keyCodes';
import { HotkeyEmitMode, IHotkeyCommandInit } from '../type';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(() => {
vi.unstubAllGlobals();
});
// 构造一套按键指令初始化参数,未指定的部分使用缺省值
function createInit(
overrides: Partial<IHotkeyCommandInit> = {}
): IHotkeyCommandInit {
return {
priority: 0,
block: false,
defaults: [],
...overrides
};
}
describe('HotkeyCommand', () => {
// 辅助键判定覆盖 Ctrl / Shift / Alt / Meta 四个按键
it('recognizes auxiliary keys', () => {
expect(isAuxiliaryKey(KeyCode.Ctrl)).toBe(true);
expect(isAuxiliaryKey(KeyCode.Shift)).toBe(true);
expect(isAuxiliaryKey(KeyCode.Alt)).toBe(true);
expect(isAuxiliaryKey(KeyCode.Meta)).toBe(true);
});
// 常规按键不会被判定为辅助键
it('rejects regular keys as auxiliary', () => {
expect(isAuxiliaryKey(KeyCode.KeyA)).toBe(false);
expect(isAuxiliaryKey(KeyCode.Digit1)).toBe(false);
expect(isAuxiliaryKey(KeyCode.Space)).toBe(false);
});
// 构造器应用缺省值并将默认组合拷贝进指令
it('applies defaults and copies the default combinations', () => {
const defaults = [[KeyCode.Ctrl, KeyCode.KeyS]];
const command = new HotkeyCommand('save', createInit({ defaults }));
expect(command.mode).toBe(HotkeyEmitMode.KeyUp);
expect(command.timeout).toBe(0);
expect(command.interval).toBe(0);
expect(command.dt).toBe(0);
expect(command.length).toBe(1);
expect(command.getKeyCombination(0)).toEqual([
KeyCode.Ctrl,
KeyCode.KeyS
]);
defaults[0].push(KeyCode.KeyA);
expect(command.getKeyCombination(0)).toEqual([
KeyCode.Ctrl,
KeyCode.KeyS
]);
});
// getKeyCombination 返回副本,修改不会影响指令内部组合
it('returns a copy from getKeyCombination', () => {
const command = new HotkeyCommand(
'save',
createInit({ defaults: [[KeyCode.KeyS]] })
);
const keys = command.getKeyCombination(0);
keys.push(KeyCode.KeyA);
expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]);
});
// setKeys 扩容补空组合、缩容截断并同步 length
it('resizes combinations with setKeys', () => {
const command = new HotkeyCommand('save', createInit());
command.setKeys(2);
command.setKeyCombination(0, [KeyCode.KeyS]);
command.setKeyCombination(1, [KeyCode.KeyD]);
expect(command.length).toBe(2);
command.setKeys(3);
expect(command.length).toBe(3);
expect(command.getKeyCombination(2)).toEqual([]);
command.setKeys(1);
expect(command.length).toBe(1);
expect(command.getKeyCombination(1)).toEqual([]);
});
// setKeys 忽略负值与非整数
it('ignores invalid counts in setKeys', () => {
const command = new HotkeyCommand(
'save',
createInit({ defaults: [[KeyCode.KeyS]] })
);
command.setKeys(-1);
command.setKeys(1.5);
expect(command.length).toBe(1);
expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]);
});
// setKeyCombination 忽略越界索引
it('ignores out-of-range combination indexes', () => {
const command = new HotkeyCommand(
'save',
createInit({ defaults: [[KeyCode.KeyS]] })
);
command.setKeyCombination(3, [KeyCode.KeyA]);
command.setKeyCombination(-1, [KeyCode.KeyD]);
expect(command.length).toBe(1);
expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]);
expect(command.getKeyCombination(3)).toEqual([]);
});
// iterateKeyCombinations 按索引升序产出并返回副本
it('iterates combinations in order with copies', () => {
const command = new HotkeyCommand(
'save',
createInit({
defaults: [[KeyCode.KeyS], [KeyCode.Ctrl, KeyCode.KeyD]]
})
);
const entries = [...command.iterateKeyCombinations()];
expect(entries.map(v => v[0])).toEqual([0, 1]);
expect(entries[1][1]).toEqual([KeyCode.Ctrl, KeyCode.KeyD]);
entries[0][1].push(KeyCode.KeyA);
expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]);
});
// KeyUp 模式下两个常规键的组合会发一次 203 且组合仍可读取
it('warns 203 once for an invalid KeyUp combination', () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const command = new HotkeyCommand(
'save',
createInit({ defaults: [[KeyCode.KeyA, KeyCode.KeyB]] })
);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(203, 'save');
expect(command.getKeyCombination(0)).toEqual([
KeyCode.KeyA,
KeyCode.KeyB
]);
});
// KeyUp 模式下单个常规键加辅助键不会告警
it('accepts one regular key with auxiliary keys', () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
new HotkeyCommand(
'save',
createInit({ defaults: [[KeyCode.Ctrl, KeyCode.KeyS]] })
);
new HotkeyCommand(
'save',
createInit({ defaults: [[KeyCode.Ctrl, KeyCode.Shift]] })
);
expect(warn).not.toHaveBeenCalled();
});
// keyUp() 切换到 KeyUp 并对已有非法组合只发一次 203
it('switches to KeyUp and warns once for invalid combinations', () => {
const command = new HotkeyCommand(
'save',
createInit({
mode: HotkeyEmitMode.Once,
defaults: [
[KeyCode.KeyA, KeyCode.KeyB],
[KeyCode.KeyC, KeyCode.KeyD]
]
})
);
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
command.keyUp(120);
expect(command.mode).toBe(HotkeyEmitMode.KeyUp);
expect(command.timeout).toBe(120);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(203, 'save');
});
// 四个模式方法设置模式与时间参数并保留未传入的值
it('updates mode and timing parameters', () => {
const command = new HotkeyCommand(
'save',
createInit({ defaults: [[KeyCode.KeyS]] })
);
command.continuous(30, 60);
expect(command.mode).toBe(HotkeyEmitMode.Continuous);
expect(command.interval).toBe(30);
expect(command.timeout).toBe(60);
command.throttle(10);
expect(command.mode).toBe(HotkeyEmitMode.Throttle);
expect(command.dt).toBe(10);
expect(command.interval).toBe(30);
expect(command.timeout).toBe(60);
command.once(15);
expect(command.mode).toBe(HotkeyEmitMode.Once);
expect(command.timeout).toBe(15);
command.keyUp(5);
expect(command.mode).toBe(HotkeyEmitMode.KeyUp);
expect(command.timeout).toBe(5);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,114 @@
import { logger } from '@motajs/common';
import { KeyCode } from './keyCodes';
import { HotkeyEmitMode, IHotkeyCommand, IHotkeyCommandInit } from './type';
/**
* 判断按键是否为辅助按键,即 Ctrl / Shift / Alt / Meta 之一
* @param key 按键码
*/
export function isAuxiliaryKey(key: KeyCode): boolean {
return (
key === KeyCode.Ctrl ||
key === KeyCode.Shift ||
key === KeyCode.Alt ||
key === KeyCode.Meta
);
}
export class HotkeyCommand implements IHotkeyCommand {
readonly priority: number;
readonly block: boolean;
mode: HotkeyEmitMode;
interval: number;
timeout: number;
dt: number;
length: number;
/** 指令标识符,用于告警信息 */
private readonly command: string;
/** 按键组合列表,长度与 {@link length} 保持一致 */
private combinations: KeyCode[][];
constructor(command: string, init: IHotkeyCommandInit) {
this.command = command;
this.priority = init.priority;
this.block = init.block;
this.mode = init.mode ?? HotkeyEmitMode.KeyUp;
this.timeout = init.timeout ?? 0;
this.interval = init.interval ?? 0;
this.dt = init.dt ?? 0;
this.combinations = [];
this.length = 0;
this.setKeys(init.defaults.length);
init.defaults.forEach((keys, index) =>
this.setKeyCombination(index, keys)
);
}
setKeys(count: number): void {
if (!Number.isInteger(count) || count < 0) return;
while (this.combinations.length < count) {
this.combinations.push([]);
}
if (this.combinations.length > count) {
this.combinations.length = count;
}
this.length = this.combinations.length;
}
setKeyCombination(index: number, keys: KeyCode[]): void {
if (
!Number.isInteger(index) ||
index < 0 ||
index >= this.combinations.length
) {
return;
}
const copy = keys.slice();
this.combinations[index] = copy;
if (this.mode === HotkeyEmitMode.KeyUp) {
const regular = copy.filter(v => !isAuxiliaryKey(v));
if (regular.length >= 2) {
logger.warn(203, this.command);
}
}
}
getKeyCombination(index: number): KeyCode[] {
const keys = this.combinations[index];
return keys ? keys.slice() : [];
}
*iterateKeyCombinations(): Iterable<[index: number, key: KeyCode[]]> {
for (let i = 0; i < this.combinations.length; i++) {
yield [i, this.combinations[i].slice()];
}
}
keyUp(timeout?: number): void {
this.mode = HotkeyEmitMode.KeyUp;
if (timeout !== undefined) this.timeout = timeout;
const invalid = this.combinations.some(
keys => keys.filter(v => !isAuxiliaryKey(v)).length >= 2
);
if (invalid) logger.warn(203, this.command);
}
once(timeout?: number): void {
this.mode = HotkeyEmitMode.Once;
if (timeout !== undefined) this.timeout = timeout;
}
continuous(interval?: number, timeout?: number): void {
this.mode = HotkeyEmitMode.Continuous;
if (interval !== undefined) this.interval = interval;
if (timeout !== undefined) this.timeout = timeout;
}
throttle(dt: number, interval?: number, timeout?: number): void {
this.mode = HotkeyEmitMode.Throttle;
this.dt = dt;
if (interval !== undefined) this.interval = interval;
if (timeout !== undefined) this.timeout = timeout;
}
}

View File

@ -1,4 +1,6 @@
export * from './command';
export * from './hotkey';
export * from './keyboard';
export * from './keyCodes';
export * from './system';
export * from './type';

View File

@ -0,0 +1,487 @@
import { IExcitable, IExcitableController, IExcitation } from '@motajs/animate';
import { logger } from '@motajs/common';
import { HotkeyCommand, isAuxiliaryKey } from './command';
import { EVENT_KEY_CODE_MAP, KeyCode } from './keyCodes';
import {
HotkeyEmitMode,
HotkeyEmitType,
IHotkeyCommand,
IHotkeyCommandInit,
IHotkeyEmitContext,
IHotkeyEmitInfo,
IHotkeySystem
} from './type';
type HotkeyHandler = (ctx: IHotkeyEmitInfo) => void;
type HotkeyListener = (event: KeyboardEvent) => void;
interface IHotkeyScope {
/** 作用域唯一标识符 */
symbol: symbol;
/** 作用域条件,为 `null` 时视为始终满足 */
condition: (() => boolean) | null;
/** 作用域内已实现的按键指令 */
handlers: Map<string, HotkeyHandler>;
}
interface IHotkeyMatch {
/** 指令标识符 */
id: string;
/** 指令对象 */
command: IHotkeyCommand;
/** 命中的按键组合索引 */
index: number;
/** 命中的按键组合 */
keys: KeyCode[];
/** 指令实现 */
handler: HotkeyHandler;
/** 关联的待触发项 */
entry?: IHotkeyPending;
}
interface IHotkeyPending {
/** 指令标识符 */
id: string;
/** 指令对象 */
command: IHotkeyCommand;
/** 命中的按键组合索引 */
index: number;
/** 命中的按键组合 */
keys: KeyCode[];
/** 指令实现 */
handler: HotkeyHandler;
/** 触发方式 */
type: HotkeyEmitType;
/** 触发此指令的按键 */
key: KeyCode;
/** 入队时的作用域 */
scope: symbol;
/** 入队时的触发方式快照 */
mode: HotkeyEmitMode;
/** 入队时的首次触发延迟快照 */
timeout: number;
/** 入队时的首次与第二次触发最小间隔快照 */
interval: number;
/** 入队时的节流间隔快照 */
dt: number;
/** 入队时刻 */
time: number;
/** 上一次真正触发的时刻 */
last: number;
/** 已触发的次数 */
fired: number;
}
export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
listening: HTMLElement | null = null;
/** 指令注册表 */
private commands: Map<string, IHotkeyCommand> = new Map();
/** 作用域栈 */
private scopes: IHotkeyScope[] = [];
/** 当前按下的按键,按按下顺序排列 */
private holding: KeyCode[] = [];
/** 待触发项 */
private pending: IHotkeyPending[] = [];
/** 最近一次激励负载 */
private now: number = 0;
/** 当前激励源控制器 */
private controller: IExcitableController<number> | null = null;
/** 键盘按下处理器 */
private keyDownListener: HotkeyListener = event => {
if (event.repeat) return;
const key = this.resolveKeyCode(event.keyCode);
if (key === null) return;
if (this.down(key)) event.preventDefault();
};
/** 键盘抬起处理器 */
private keyUpListener: HotkeyListener = event => {
const key = this.resolveKeyCode(event.keyCode);
if (key === null) return;
if (this.up(key)) event.preventDefault();
};
//#region 基础
/** 获取当前栈顶作用域 */
private getScope(): IHotkeyScope | null {
return this.scopes.at(-1) ?? null;
}
/**
* 根据当前的按键状态构造触发上下文
* @param type 触发方式
* @param key 触发此指令的按键
*/
private createContext(
type: HotkeyEmitType,
key: KeyCode
): IHotkeyEmitContext {
const holding = this.holding.slice();
return {
type,
key,
holding,
ctrl: holding.includes(KeyCode.Ctrl),
shift: holding.includes(KeyCode.Shift),
alt: holding.includes(KeyCode.Alt),
meta: holding.includes(KeyCode.Meta)
};
}
//#endregion
//#region 组合匹配
/**
* 判断按键组合是否与按键抬起事件匹配
* @param keys 按键组合
* @param key 抬起的按键
*/
private matchKeyUp(keys: KeyCode[], key: KeyCode): boolean {
if (keys.length === 0) return false;
const regular = keys.filter(v => !isAuxiliaryKey(v));
if (regular.length > 1) return false;
if (regular.length === 0) {
return keys.length === 1 && keys[0] === key;
}
if (regular[0] !== key) return false;
return keys
.filter(v => isAuxiliaryKey(v))
.every(v => this.holding.includes(v));
}
/**
* 判断按键组合是否被当前按下的按键所满足
* @param keys 按键组合
*/
private matchKeyDown(keys: KeyCode[]): boolean {
if (keys.length === 0) return false;
return keys.every(v => this.holding.includes(v));
}
//#endregion
//#region 待触发
/**
* 判断一个待触发项是否已经到期
* @param item 待触发项
* @param payload 当前激励负载
*/
private isPendingDue(item: IHotkeyPending, payload: number): boolean {
if (item.fired === 0) {
return payload - item.time >= item.timeout;
}
if (item.fired === 1) {
return payload - item.last >= item.interval;
}
if (item.mode === HotkeyEmitMode.Throttle) {
return payload - item.last >= item.dt;
}
return true;
}
/** 触发所有已经到期的待触发项 */
private flushPending(): void {
this.dropPending();
const due: IHotkeyPending[] = [];
const rest: IHotkeyPending[] = [];
for (const item of this.pending) {
if (this.isPendingDue(item, this.now)) {
due.push(item);
} else {
rest.push(item);
}
}
this.pending = rest;
due.sort((a, b) => b.command.priority - a.command.priority);
let blocked = -Infinity;
for (const item of due) {
const priority = item.command.priority;
if (priority < blocked) continue;
item.handler({
command: item.id,
index: item.index,
keys: item.keys.slice(),
context: this.createContext(item.type, item.key)
});
item.last = this.now;
item.fired += 1;
if (item.command.block) blocked = priority;
if (
item.mode === HotkeyEmitMode.Once ||
item.mode === HotkeyEmitMode.KeyUp
) {
continue;
}
this.pending.push(item);
}
}
/** 丢弃已经失效的待触发项 */
private dropPending(): void {
const scope = this.getScope();
const symbol = scope ? scope.symbol : null;
this.pending = this.pending.filter(item => {
if (item.scope !== symbol) return false;
if (item.command.mode !== item.mode) return false;
if (
item.mode !== HotkeyEmitMode.KeyUp &&
!this.matchKeyDown(item.keys)
) {
return false;
}
return true;
});
}
//#endregion
//#region 派发
/**
* 执行一批已经按优先级降序排列的匹配项
* @param matches 匹配项
* @param type 触发方式
* @param key 触发此指令的按键
*/
private runMatches(
matches: IHotkeyMatch[],
type: HotkeyEmitType,
key: KeyCode
): boolean {
const scope = this.getScope();
if (!scope) return false;
let blocked = -Infinity;
let triggered = false;
for (const item of matches) {
const priority = item.command.priority;
if (priority < blocked) break;
const mode = item.command.mode;
const timeout = item.command.timeout;
const immediate = timeout <= 0;
if (immediate) {
item.handler({
command: item.id,
index: item.index,
keys: item.keys,
context: this.createContext(type, key)
});
}
triggered = true;
const continuous =
mode === HotkeyEmitMode.Continuous ||
mode === HotkeyEmitMode.Throttle;
if (!immediate || continuous) {
this.pending.push({
id: item.id,
command: item.command,
index: item.index,
keys: item.keys.slice(),
handler: item.handler,
type,
key,
scope: scope.symbol,
mode,
timeout,
interval: item.command.interval,
dt: item.command.dt,
time: this.now,
last: immediate ? this.now : 0,
fired: immediate ? 1 : 0
});
}
if (item.command.block) blocked = priority;
}
return triggered;
}
/**
* 派发一次按键事件
* @param type 触发方式
* @param key 触发此指令的按键
*/
private dispatch(type: HotkeyEmitType, key: KeyCode): boolean {
const scope = this.getScope();
if (!scope) return false;
if (scope.condition !== null && !scope.condition()) return false;
const matches: IHotkeyMatch[] = [];
for (const [id, handler] of scope.handlers) {
const command = this.commands.get(id);
if (!command) continue;
const keyDown = command.mode !== HotkeyEmitMode.KeyUp;
if (keyDown !== (type === HotkeyEmitType.KeyDown)) continue;
let index = -1;
let keys: KeyCode[] = [];
for (const [i, combination] of command.iterateKeyCombinations()) {
const hit =
type === HotkeyEmitType.KeyUp
? this.matchKeyUp(combination, key)
: this.matchKeyDown(combination);
if (hit) {
index = i;
keys = combination;
break;
}
}
if (index < 0) continue;
matches.push({ id, command, index, keys, handler });
}
matches.sort((a, b) => b.command.priority - a.command.priority);
return this.runMatches(matches, type, key);
}
//#endregion
//#region 作用域
enterScope(): symbol {
const symbol = Symbol();
this.scopes.push({ symbol, condition: null, handlers: new Map() });
this.dropPending();
return symbol;
}
leaveScope(sym?: symbol): void {
if (sym === undefined) {
if (this.scopes.length === 0) {
logger.warn(205);
return;
}
this.scopes.pop();
this.dropPending();
return;
}
const index = this.scopes.findIndex(v => v.symbol === sym);
if (index < 0) {
logger.warn(205);
return;
}
this.scopes.length = index + 1;
this.dropPending();
}
when(func: () => boolean): this {
const scope = this.getScope();
if (!scope) {
logger.warn(201);
return this;
}
scope.condition = func;
return this;
}
implement(command: string, func: HotkeyHandler): this {
const scope = this.getScope();
if (!scope) {
logger.warn(201);
return this;
}
if (!this.commands.has(command)) {
logger.warn(202, command);
return this;
}
scope.handlers.set(command, func);
return this;
}
//#endregion
//#region 指令注册表
getCommand(command: string): IHotkeyCommand | null {
return this.commands.get(command) ?? null;
}
addCommand(command: string, init: IHotkeyCommandInit): IHotkeyCommand {
const existing = this.commands.get(command);
if (existing) {
logger.warn(204, command);
return existing;
}
const instance = new HotkeyCommand(command, init);
this.commands.set(command, instance);
return instance;
}
deleteCommand(command: string): void {
if (!this.commands.delete(command)) return;
this.scopes.forEach(scope => scope.handlers.delete(command));
this.pending = this.pending.filter(item => item.id !== command);
}
*iterateCommands(): Iterable<IHotkeyCommand> {
yield* this.commands.values();
}
//#endregion
//#region 按下与抬起
down(key: KeyCode): boolean {
if (this.holding.includes(key)) return false;
this.holding.push(key);
return this.dispatch(HotkeyEmitType.KeyDown, key);
}
up(key: KeyCode): boolean {
const index = this.holding.indexOf(key);
if (index < 0) return false;
this.holding.splice(index, 1);
this.dropPending();
return this.dispatch(HotkeyEmitType.KeyUp, key);
}
//#endregion
//#region 激励
excitedBy(excitation: IExcitation<number>): void {
if (this.controller) {
this.controller.revoke();
}
this.controller = excitation.add(this);
}
excited(payload: number): void {
this.now = payload;
this.flushPending();
}
//#endregion
//#region 监听
/**
* 将浏览器事件的按键码映射为按键枚举,部分浏览器的 Meta 键需要本地映射
* @param eventKeyCode 浏览器事件的按键码
*/
private resolveKeyCode(eventKeyCode: number): KeyCode | null {
if (
eventKeyCode === 91 ||
eventKeyCode === 92 ||
eventKeyCode === 224
) {
return KeyCode.Meta;
}
const key = EVENT_KEY_CODE_MAP[eventKeyCode];
return key ? key : null;
}
listen(target: HTMLElement | null): void {
if (this.listening === target) return;
if (this.listening) {
this.listening.removeEventListener('keydown', this.keyDownListener);
this.listening.removeEventListener('keyup', this.keyUpListener);
}
this.listening = target;
if (target) {
target.addEventListener('keydown', this.keyDownListener);
target.addEventListener('keyup', this.keyUpListener);
}
}
//#endregion
}

View File

@ -660,6 +660,9 @@ importers:
packages/system:
dependencies:
'@motajs/animate':
specifier: workspace:*
version: link:../animate
'@motajs/common':
specifier: workspace:*
version: link:../common