diff --git a/.planning/phases/09-minimum-browser-runnable/09-23-SUMMARY.md b/.planning/phases/09-minimum-browser-runnable/09-23-SUMMARY.md new file mode 100644 index 0000000..0c747cd --- /dev/null +++ b/.planning/phases/09-minimum-browser-runnable/09-23-SUMMARY.md @@ -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 实现)" + - "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 只加在 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` 仅加在类上。 +- 旧实现 `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* diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index c5e93bc..9f1dc9e 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -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." } } diff --git a/packages/system/package.json b/packages/system/package.json index ddd6bce..18d38a6 100644 --- a/packages/system/package.json +++ b/packages/system/package.json @@ -1,6 +1,7 @@ { "name": "@motajs/system", "dependencies": { + "@motajs/animate": "workspace:*", "@motajs/common": "workspace:*" } } diff --git a/packages/system/src/hotkey/__test__/command.test.ts b/packages/system/src/hotkey/__test__/command.test.ts new file mode 100644 index 0000000..f7b16d1 --- /dev/null +++ b/packages/system/src/hotkey/__test__/command.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/system/src/hotkey/__test__/system.test.ts b/packages/system/src/hotkey/__test__/system.test.ts new file mode 100644 index 0000000..2185993 --- /dev/null +++ b/packages/system/src/hotkey/__test__/system.test.ts @@ -0,0 +1,1059 @@ +// 测试按键系统的注册表、作用域、KeyUp 与 KeyDown 派发、时序与监听行为 +import { IExcitable, IExcitation } from '@motajs/animate'; +import { logger } from '@motajs/common'; +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { KeyCode } from '../keyCodes'; +import { HotkeySystem } from '../system'; +import { HotkeyEmitMode, HotkeyEmitType, 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 { + return { + priority: 0, + block: false, + defaults: [], + ...overrides + }; +} + +// 最小化的假元素,记录注册的监听器并支持手动派发事件 +class FakeElement { + readonly listeners: Map void)[]> = + new Map(); + + addEventListener( + type: string, + listener: (event: KeyboardEvent) => void + ): void { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + + removeEventListener( + type: string, + listener: (event: KeyboardEvent) => void + ): void { + const list = this.listeners.get(type); + if (!list) return; + this.listeners.set( + type, + list.filter(v => v !== listener) + ); + } + + dispatch(type: string, event: FakeKeyEvent): void { + const list = this.listeners.get(type) ?? []; + list.forEach(v => v(event as unknown as KeyboardEvent)); + } + + count(): number { + let total = 0; + this.listeners.forEach(v => (total += v.length)); + return total; + } +} + +interface FakeKeyEvent { + keyCode: number; + repeat: boolean; + preventDefault: ReturnType; +} + +// 构造一个只包含测试所需字段的伪键盘事件 +function createKeyEvent( + keyCode: number, + repeat: boolean = false +): FakeKeyEvent { + return { + keyCode, + repeat, + preventDefault: vi.fn() + }; +} + +interface FakeExcitation extends IExcitation { + controllers: { revoke: ReturnType }[]; +} + +// 构造一个可手动激励的假激励源,并记录创建的控制器 +function createExcitation(): FakeExcitation { + const excitables = new Set>(); + const controllers: { revoke: ReturnType }[] = []; + return { + controllers, + payload: () => 0, + excite: payload => { + excitables.forEach(v => v.excited(payload)); + }, + add: object => { + excitables.add(object); + const controller = { + excitable: object, + revoke: vi.fn(() => { + excitables.delete(object); + }), + excite: (payload: number) => { + object.excited(payload); + } + }; + controllers.push(controller); + return controller; + }, + remove: object => excitables.delete(object), + destroy: () => { + excitables.clear(); + } + }; +} + +describe('HotkeySystem', () => { + // 没有作用域时 when 发出告警 201 并返回自身 + it('warns 201 when setting a condition without a scope', () => { + const system = new HotkeySystem(); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + expect(system.when(() => true)).toBe(system); + expect(warn).toHaveBeenCalledWith(201); + }); + + // 没有作用域时 implement 发出告警 201 并返回自身 + it('warns 201 when implementing without a scope', () => { + const system = new HotkeySystem(); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + expect(system.implement('fire', () => {})).toBe(system); + expect(warn).toHaveBeenCalledWith(201); + }); + + // implement 未注册的指令发出告警 202 + it('warns 202 when implementing a nonexistent command', () => { + const system = new HotkeySystem(); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + system.enterScope(); + + expect(system.implement('missing', () => {})).toBe(system); + expect(warn).toHaveBeenCalledWith(202, 'missing'); + }); + + // 重复注册同名指令发出告警 204 并返回已有对象 + it('warns 204 for duplicate commands and returns the existing one', () => { + const system = new HotkeySystem(); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const init = createInit({ defaults: [[KeyCode.KeyS]] }); + const first = system.addCommand('save', init); + + const second = system.addCommand('save', init); + + expect(second).toBe(first); + expect(warn).toHaveBeenCalledWith(204, 'save'); + expect([...system.iterateCommands()].length).toBe(1); + }); + + // getCommand 对未注册的标识符返回 null + it('returns null for an unregistered command', () => { + const system = new HotkeySystem(); + + expect(system.getCommand('missing')).toBeNull(); + }); + + // iterateCommands 遍历所有已注册的指令 + it('iterates every registered command', () => { + const system = new HotkeySystem(); + const first = system.addCommand('save', createInit()); + const second = system.addCommand('load', createInit()); + + expect([...system.iterateCommands()]).toEqual([first, second]); + }); + + // deleteCommand 移除指令并清理作用域中的实现 + it('deletes a command and cleans its handler', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + const command = system.addCommand( + 'fire', + createInit({ defaults: [[KeyCode.KeyA]] }) + ); + system.enterScope(); + system.implement('fire', handler); + + expect(system.getCommand('fire')).toBe(command); + system.deleteCommand('fire'); + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(system.getCommand('fire')).toBeNull(); + expect(handler).not.toHaveBeenCalled(); + }); + + // enterScope 返回新作用域的标识符 + it('returns a symbol from enterScope', () => { + const system = new HotkeySystem(); + + expect(typeof system.enterScope()).toBe('symbol'); + }); + + // leaveScope 无参退出栈顶作用域并恢复上一级 + it('leaves the top scope and restores the parent', () => { + const system = new HotkeySystem(); + const parentHandler = vi.fn(); + const childHandler = vi.fn(); + system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); + system.enterScope(); + system.implement('fire', parentHandler); + system.enterScope(); + system.implement('fire', childHandler); + + system.leaveScope(); + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(childHandler).not.toHaveBeenCalled(); + expect(parentHandler).toHaveBeenCalledTimes(1); + }); + + // leaveScope(sym) 保留指定作用域并删除其上方的作用域 + it('leaves back to a specific scope and drops scopes above it', () => { + const system = new HotkeySystem(); + const firstHandler = vi.fn(); + const secondHandler = vi.fn(); + const thirdHandler = vi.fn(); + system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); + const first = system.enterScope(); + system.implement('fire', firstHandler); + system.enterScope(); + system.implement('fire', secondHandler); + system.enterScope(); + system.implement('fire', thirdHandler); + + system.leaveScope(first); + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(firstHandler).toHaveBeenCalledTimes(1); + expect(secondHandler).not.toHaveBeenCalled(); + expect(thirdHandler).not.toHaveBeenCalled(); + }); + + // leaveScope 指定不存在的作用域发出告警 205 + it('warns 205 when leaving an unknown scope', () => { + const system = new HotkeySystem(); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + system.enterScope(); + + system.leaveScope(Symbol()); + + expect(warn).toHaveBeenCalledWith(205); + }); + + // 空栈调用无参 leaveScope 发出告警 205 + it('warns 205 when leaving with an empty scope stack', () => { + const system = new HotkeySystem(); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + system.leaveScope(); + + expect(warn).toHaveBeenCalledWith(205); + }); + + // 子作用域激活时父作用域的实现不生效,退出后恢复 + it('uses only the top scope handlers and restores the parent', () => { + const system = new HotkeySystem(); + const parentHandler = vi.fn(); + const childHandler = vi.fn(); + system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); + system.enterScope(); + system.implement('fire', parentHandler); + system.enterScope(); + system.implement('fire', childHandler); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(childHandler).toHaveBeenCalledTimes(1); + expect(parentHandler).not.toHaveBeenCalled(); + }); + + // 栈顶作用域条件为 false 时按下与抬起都不触发实现 + it('does not trigger when the top scope condition is false', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); + system.enterScope(); + system.implement('fire', handler); + system.when(() => false); + + expect(system.down(KeyCode.KeyA)).toBe(false); + expect(system.up(KeyCode.KeyA)).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + + // KeyUp 组合在辅助键仍按住时触发 + it('triggers a KeyUp combination while auxiliary keys are held', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + system.addCommand( + 'save', + createInit({ defaults: [[KeyCode.Ctrl, KeyCode.KeyS]] }) + ); + system.enterScope(); + system.implement('save', handler); + + system.down(KeyCode.Ctrl); + system.down(KeyCode.KeyS); + system.up(KeyCode.KeyS); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + // KeyUp 组合在辅助键已松开后不触发 + it('does not trigger when an auxiliary key was released first', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + system.addCommand( + 'save', + createInit({ defaults: [[KeyCode.Ctrl, KeyCode.KeyS]] }) + ); + system.enterScope(); + system.implement('save', handler); + + system.down(KeyCode.Ctrl); + system.down(KeyCode.KeyS); + system.up(KeyCode.Ctrl); + system.up(KeyCode.KeyS); + + expect(handler).not.toHaveBeenCalled(); + }); + + // 仅含一个辅助键的组合在该键抬起时触发 + it('triggers a single auxiliary key combination on release', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + system.addCommand('ctrl', createInit({ defaults: [[KeyCode.Ctrl]] })); + system.enterScope(); + system.implement('ctrl', handler); + + system.down(KeyCode.Ctrl); + system.up(KeyCode.Ctrl); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + // 仅含两个辅助键的组合不会触发 + it('does not trigger a combination of two auxiliary keys', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + system.addCommand( + 'both', + createInit({ defaults: [[KeyCode.Ctrl, KeyCode.Shift]] }) + ); + system.enterScope(); + system.implement('both', handler); + + system.down(KeyCode.Ctrl); + system.down(KeyCode.Shift); + system.up(KeyCode.Shift); + system.up(KeyCode.Ctrl); + + expect(handler).not.toHaveBeenCalled(); + }); + + // 包含两个常规键的 KeyUp 组合告警后永不触发 + it('never triggers a KeyUp combination with two regular keys', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + system.addCommand( + 'invalid', + createInit({ defaults: [[KeyCode.KeyA, KeyCode.KeyB]] }) + ); + system.enterScope(); + system.implement('invalid', handler); + + system.down(KeyCode.KeyA); + system.down(KeyCode.KeyB); + system.up(KeyCode.KeyB); + system.up(KeyCode.KeyA); + + expect(handler).not.toHaveBeenCalled(); + }); + + // 空组合在 KeyUp 模式下不触发且不产生告警 + it('does not trigger or warn for an empty combination', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const system = new HotkeySystem(); + const handler = vi.fn(); + system.addCommand('empty', createInit({ defaults: [[]] })); + system.enterScope(); + system.implement('empty', handler); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(handler).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + }); + + // KeyUp 上下文不包含被抬起的按键且辅助键状态正确 + it('builds the KeyUp context without the released key', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + system.addCommand( + 'save', + createInit({ defaults: [[KeyCode.Ctrl, KeyCode.KeyS]] }) + ); + system.enterScope(); + system.implement('save', handler); + + system.down(KeyCode.Ctrl); + system.down(KeyCode.KeyS); + system.up(KeyCode.KeyS); + + const info = handler.mock.calls[0][0]; + expect(info.command).toBe('save'); + expect(info.index).toBe(0); + expect(info.keys).toEqual([KeyCode.Ctrl, KeyCode.KeyS]); + expect(info.context.type).toBe(HotkeyEmitType.KeyUp); + expect(info.context.key).toBe(KeyCode.KeyS); + expect(info.context.holding).toEqual([KeyCode.Ctrl]); + expect(info.context.ctrl).toBe(true); + expect(info.context.shift).toBe(false); + expect(info.context.alt).toBe(false); + expect(info.context.meta).toBe(false); + }); + + // 同一优先级下的所有已实现指令都会触发 + it('triggers every implemented command at the same priority', () => { + const system = new HotkeySystem(); + const first = vi.fn(); + const second = vi.fn(); + system.addCommand( + 'first', + createInit({ priority: 5, defaults: [[KeyCode.KeyA]] }) + ); + system.addCommand( + 'second', + createInit({ priority: 5, defaults: [[KeyCode.KeyA]] }) + ); + system.enterScope(); + system.implement('first', first); + system.implement('second', second); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + // 优先级更高的指令先执行 + it('runs higher priority commands first', () => { + const system = new HotkeySystem(); + const order: string[] = []; + system.addCommand( + 'low', + createInit({ priority: 1, defaults: [[KeyCode.KeyA]] }) + ); + system.addCommand( + 'high', + createInit({ priority: 10, defaults: [[KeyCode.KeyA]] }) + ); + system.enterScope(); + system.implement('low', () => order.push('low')); + system.implement('high', () => order.push('high')); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(order).toEqual(['high', 'low']); + }); + + // block 指令阻断严格更低优先级的指令 + it('blocks strictly lower priority commands', () => { + const system = new HotkeySystem(); + const high = vi.fn(); + const low = vi.fn(); + system.addCommand( + 'high', + createInit({ + priority: 10, + block: true, + defaults: [[KeyCode.KeyA]] + }) + ); + system.addCommand( + 'low', + createInit({ priority: 5, defaults: [[KeyCode.KeyA]] }) + ); + system.enterScope(); + system.implement('high', high); + system.implement('low', low); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(high).toHaveBeenCalledTimes(1); + expect(low).not.toHaveBeenCalled(); + }); + + // block 不阻断同优先级指令 + it('does not block commands at the same priority', () => { + const system = new HotkeySystem(); + const blocking = vi.fn(); + const same = vi.fn(); + system.addCommand( + 'blocking', + createInit({ priority: 5, block: true, defaults: [[KeyCode.KeyA]] }) + ); + system.addCommand( + 'same', + createInit({ priority: 5, defaults: [[KeyCode.KeyA]] }) + ); + system.enterScope(); + system.implement('blocking', blocking); + system.implement('same', same); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(blocking).toHaveBeenCalledTimes(1); + expect(same).toHaveBeenCalledTimes(1); + }); + + // 匹配但未实现的指令不会阻断更低优先级 + it('does not let an unimplemented command block lower priorities', () => { + const system = new HotkeySystem(); + const low = vi.fn(); + system.addCommand( + 'high', + createInit({ + priority: 10, + block: true, + defaults: [[KeyCode.KeyA]] + }) + ); + system.addCommand( + 'low', + createInit({ priority: 5, defaults: [[KeyCode.KeyA]] }) + ); + system.enterScope(); + system.implement('low', low); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + + expect(low).toHaveBeenCalledTimes(1); + }); + + // down 与 up 返回是否触发了已实现的指令 + it('returns whether a release triggered an implemented command', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); + system.enterScope(); + system.implement('fire', handler); + + expect(system.down(KeyCode.KeyA)).toBe(false); + expect(system.up(KeyCode.KeyA)).toBe(true); + }); + + // up 对未处于按下状态的按键返回 false + it('returns false when releasing a key that is not held', () => { + const system = new HotkeySystem(); + + expect(system.up(KeyCode.KeyA)).toBe(false); + }); + + // listen 将按下与抬起映射到按键,只在触发时阻止默认行为 + it('listens to keydown and keyup and prevents default on triggers', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + const target = new FakeElement(); + system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); + system.enterScope(); + system.implement('fire', handler); + system.listen(target as unknown as HTMLElement); + + const downEvent = createKeyEvent(65); + const upEvent = createKeyEvent(65); + target.dispatch('keydown', downEvent); + target.dispatch('keyup', upEvent); + + expect(handler).toHaveBeenCalledTimes(1); + expect(downEvent.preventDefault).not.toHaveBeenCalled(); + expect(upEvent.preventDefault).toHaveBeenCalledTimes(1); + }); + + // listen 忽略 repeat 事件的按下 + it('ignores repeated keydown events', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + const target = new FakeElement(); + system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); + system.enterScope(); + system.implement('fire', handler); + system.listen(target as unknown as HTMLElement); + + target.dispatch('keydown', createKeyEvent(65, true)); + target.dispatch('keyup', createKeyEvent(65)); + expect(handler).not.toHaveBeenCalled(); + + target.dispatch('keydown', createKeyEvent(65)); + target.dispatch('keyup', createKeyEvent(65)); + expect(handler).toHaveBeenCalledTimes(1); + }); + + // listen 将 91 / 92 / 224 映射为 Meta 并触发 Meta 组合 + it('maps key codes 91, 92 and 224 to Meta', () => { + for (const code of [91, 92, 224]) { + const system = new HotkeySystem(); + const handler = vi.fn(); + const target = new FakeElement(); + system.addCommand( + 'meta', + createInit({ defaults: [[KeyCode.Meta]] }) + ); + system.enterScope(); + system.implement('meta', handler); + system.listen(target as unknown as HTMLElement); + + target.dispatch('keydown', createKeyEvent(code)); + target.dispatch('keyup', createKeyEvent(code)); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].context.key).toBe(KeyCode.Meta); + } + }); + + // listen(null) 解绑全部监听,后续事件不再触发 + it('unbinds listeners when listening to null', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + const target = new FakeElement(); + system.addCommand('fire', createInit({ defaults: [[KeyCode.KeyA]] })); + system.enterScope(); + system.implement('fire', handler); + + system.listen(target as unknown as HTMLElement); + expect(target.count()).toBe(2); + system.listen(null); + expect(target.count()).toBe(0); + target.dispatch('keydown', createKeyEvent(65)); + target.dispatch('keyup', createKeyEvent(65)); + + expect(handler).not.toHaveBeenCalled(); + expect(system.listening).toBeNull(); + }); + + // 重复监听同一元素不会重复注册监听器 + it('does not register duplicate listeners for the same element', () => { + const system = new HotkeySystem(); + const target = new FakeElement(); + + system.listen(target as unknown as HTMLElement); + system.listen(target as unknown as HTMLElement); + + expect(target.count()).toBe(2); + expect(system.listening).toBe(target); + }); + + // Once 模式下按下立即触发一次,重复按下不触发,抬起后可再次触发 + it('triggers Once commands once per press', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + system.addCommand( + 'once', + createInit({ + mode: HotkeyEmitMode.Once, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('once', handler); + + expect(system.down(KeyCode.KeyA)).toBe(true); + expect(handler).toHaveBeenCalledTimes(1); + expect(system.down(KeyCode.KeyA)).toBe(false); + expect(handler).toHaveBeenCalledTimes(1); + + system.up(KeyCode.KeyA); + system.down(KeyCode.KeyA); + expect(handler).toHaveBeenCalledTimes(2); + }); + + // KeyDown 组合需被 holding 满足,未写入组合的辅助键不构成限制 + it('matches KeyDown combinations as a subset of held keys', () => { + const system = new HotkeySystem(); + const comboHandler = vi.fn(); + const plainHandler = vi.fn(); + system.addCommand( + 'combo', + createInit({ + mode: HotkeyEmitMode.Once, + defaults: [[KeyCode.Ctrl, KeyCode.KeyS]] + }) + ); + system.addCommand( + 'plain', + createInit({ + mode: HotkeyEmitMode.Once, + defaults: [[KeyCode.KeyS]] + }) + ); + system.enterScope(); + system.implement('combo', comboHandler); + system.implement('plain', plainHandler); + + system.down(KeyCode.Ctrl); + expect(comboHandler).not.toHaveBeenCalled(); + expect(plainHandler).not.toHaveBeenCalled(); + + system.down(KeyCode.KeyS); + expect(comboHandler).toHaveBeenCalledTimes(1); + expect(plainHandler).toHaveBeenCalledTimes(1); + }); + + // Continuous 在 timeout 为 0 时当刻触发,之后每次激励都触发 + it('triggers Continuous commands immediately and on excitation', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + system.addCommand( + 'continuous', + createInit({ + mode: HotkeyEmitMode.Continuous, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('continuous', handler); + system.excitedBy(excitation); + + system.down(KeyCode.KeyA); + expect(handler).toHaveBeenCalledTimes(1); + + excitation.excite(10); + expect(handler).toHaveBeenCalledTimes(2); + excitation.excite(20); + expect(handler).toHaveBeenCalledTimes(3); + }); + + // Continuous 的 timeout 到期前不触发,之后按 interval 触发第二次 + it('waits for the Continuous timeout and then the interval', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + system.addCommand( + 'continuous', + createInit({ + mode: HotkeyEmitMode.Continuous, + timeout: 100, + interval: 50, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('continuous', handler); + system.excitedBy(excitation); + + expect(system.down(KeyCode.KeyA)).toBe(true); + expect(handler).not.toHaveBeenCalled(); + + excitation.excite(99); + expect(handler).not.toHaveBeenCalled(); + + excitation.excite(100); + expect(handler).toHaveBeenCalledTimes(1); + + excitation.excite(120); + expect(handler).toHaveBeenCalledTimes(1); + + excitation.excite(150); + expect(handler).toHaveBeenCalledTimes(2); + + excitation.excite(160); + expect(handler).toHaveBeenCalledTimes(3); + }); + + // Throttle 第二次触发需经过 interval,之后需经过 dt + it('applies interval and dt to Throttle commands', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + system.addCommand( + 'throttle', + createInit({ + mode: HotkeyEmitMode.Throttle, + interval: 50, + dt: 200, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('throttle', handler); + system.excitedBy(excitation); + + system.down(KeyCode.KeyA); + expect(handler).toHaveBeenCalledTimes(1); + + excitation.excite(40); + expect(handler).toHaveBeenCalledTimes(1); + + excitation.excite(50); + expect(handler).toHaveBeenCalledTimes(2); + + excitation.excite(100); + expect(handler).toHaveBeenCalledTimes(2); + + excitation.excite(250); + expect(handler).toHaveBeenCalledTimes(3); + }); + + // 松开按键后 Continuous 的待触发项被丢弃,不再随激励触发 + it('stops Continuous commands after the key is released', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + system.addCommand( + 'continuous', + createInit({ + mode: HotkeyEmitMode.Continuous, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('continuous', handler); + system.excitedBy(excitation); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + excitation.excite(10); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + // Once 的 timeout 未到就松键时不会触发 + it('cancels a delayed Once command when the key is released', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + system.addCommand( + 'once', + createInit({ + mode: HotkeyEmitMode.Once, + timeout: 100, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('once', handler); + system.excitedBy(excitation); + + system.down(KeyCode.KeyA); + system.up(KeyCode.KeyA); + excitation.excite(200); + + expect(handler).not.toHaveBeenCalled(); + }); + + // excitedBy 切换激励源时先撤销旧控制器 + it('revokes the previous excitation controller when switching sources', () => { + const system = new HotkeySystem(); + const first = createExcitation(); + const second = createExcitation(); + system.excitedBy(first); + + system.excitedBy(second); + + expect(first.controllers[0].revoke).toHaveBeenCalledTimes(1); + expect(second.controllers.length).toBe(1); + }); + + // 未绑定激励源时延时触发的指令不会执行 + it('never fires delayed commands without an excitation source', () => { + const system = new HotkeySystem(); + const handler = vi.fn(); + system.addCommand( + 'once', + createInit({ + mode: HotkeyEmitMode.Once, + timeout: 100, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('once', handler); + + system.down(KeyCode.KeyA); + + expect(handler).not.toHaveBeenCalled(); + }); + + // 指令模式切换后旧的待触发项被丢弃 + it('drops pending items when the command mode changes', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + const command = system.addCommand( + 'once', + createInit({ + mode: HotkeyEmitMode.Once, + timeout: 100, + defaults: [[KeyCode.KeyA]] + }) + ); + system.enterScope(); + system.implement('once', handler); + system.excitedBy(excitation); + system.down(KeyCode.KeyA); + + command.keyUp(); + excitation.excite(200); + + expect(handler).not.toHaveBeenCalled(); + }); + + // 入队场景下 KeyDown 的 down 与 KeyUp 的 up 仍返回真 + it('returns true when a command is enqueued', () => { + const downSystem = new HotkeySystem(); + downSystem.addCommand( + 'once', + createInit({ + mode: HotkeyEmitMode.Once, + timeout: 100, + defaults: [[KeyCode.KeyA]] + }) + ); + downSystem.enterScope(); + downSystem.implement('once', () => {}); + + const upSystem = new HotkeySystem(); + upSystem.addCommand( + 'up', + createInit({ timeout: 100, defaults: [[KeyCode.KeyB]] }) + ); + upSystem.enterScope(); + upSystem.implement('up', () => {}); + + expect(downSystem.down(KeyCode.KeyA)).toBe(true); + expect(upSystem.down(KeyCode.KeyB)).toBe(false); + expect(upSystem.up(KeyCode.KeyB)).toBe(true); + }); + + // 延时触发时使用触发时刻的 holding 重建上下文 + it('rebuilds the delayed context from the current holding', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + system.addCommand( + 'continuous', + createInit({ + mode: HotkeyEmitMode.Continuous, + timeout: 100, + defaults: [[KeyCode.KeyS]] + }) + ); + system.enterScope(); + system.implement('continuous', handler); + system.excitedBy(excitation); + + system.down(KeyCode.KeyS); + system.down(KeyCode.Digit1); + excitation.excite(100); + + expect(handler).toHaveBeenCalled(); + expect(handler.mock.calls[0][0].context.holding).toEqual([ + KeyCode.KeyS, + KeyCode.Digit1 + ]); + }); + + // 延时触发的 KeyUp 上下文不包含已经抬起的按键 + it('rebuilds the delayed KeyUp context without the released key', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + system.addCommand( + 'save', + createInit({ timeout: 100, defaults: [[KeyCode.KeyS]] }) + ); + system.enterScope(); + system.implement('save', handler); + system.excitedBy(excitation); + + system.down(KeyCode.KeyS); + system.up(KeyCode.KeyS); + expect(handler).not.toHaveBeenCalled(); + + system.down(KeyCode.Digit1); + excitation.excite(100); + + const info = handler.mock.calls[0][0]; + expect(info.context.key).toBe(KeyCode.KeyS); + expect(info.context.holding).toEqual([KeyCode.Digit1]); + }); + + // listen 在 KeyDown 模式下阻止默认行为,并把 Meta 映射进上下文 + it('prevents default on KeyDown triggers and sets meta context', () => { + for (const code of [91, 92, 224]) { + const system = new HotkeySystem(); + const handler = vi.fn(); + const target = new FakeElement(); + system.addCommand( + 'meta', + createInit({ + mode: HotkeyEmitMode.Once, + defaults: [[KeyCode.Meta]] + }) + ); + system.enterScope(); + system.implement('meta', handler); + system.listen(target as unknown as HTMLElement); + + const event = createKeyEvent(code); + target.dispatch('keydown', event); + + expect(handler).toHaveBeenCalledTimes(1); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].context.meta).toBe(true); + } + }); + + // 退出作用域后其中入队的待触发项被丢弃 + it('drops pending items when their scope is left', () => { + const system = new HotkeySystem(); + const excitation = createExcitation(); + const handler = vi.fn(); + system.addCommand( + 'once', + createInit({ + mode: HotkeyEmitMode.Once, + timeout: 100, + defaults: [[KeyCode.KeyA]] + }) + ); + system.excitedBy(excitation); + system.enterScope(); + system.implement('once', handler); + system.down(KeyCode.KeyA); + + system.leaveScope(); + excitation.excite(200); + + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/system/src/hotkey/command.ts b/packages/system/src/hotkey/command.ts new file mode 100644 index 0000000..6be7e4b --- /dev/null +++ b/packages/system/src/hotkey/command.ts @@ -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; + } +} diff --git a/packages/system/src/hotkey/index.ts b/packages/system/src/hotkey/index.ts index bb1b172..ed59e90 100644 --- a/packages/system/src/hotkey/index.ts +++ b/packages/system/src/hotkey/index.ts @@ -1,4 +1,6 @@ +export * from './command'; export * from './hotkey'; export * from './keyboard'; export * from './keyCodes'; +export * from './system'; export * from './type'; diff --git a/packages/system/src/hotkey/system.ts b/packages/system/src/hotkey/system.ts new file mode 100644 index 0000000..8224be0 --- /dev/null +++ b/packages/system/src/hotkey/system.ts @@ -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; +} + +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 { + listening: HTMLElement | null = null; + + /** 指令注册表 */ + private commands: Map = new Map(); + /** 作用域栈 */ + private scopes: IHotkeyScope[] = []; + /** 当前按下的按键,按按下顺序排列 */ + private holding: KeyCode[] = []; + /** 待触发项 */ + private pending: IHotkeyPending[] = []; + /** 最近一次激励负载 */ + private now: number = 0; + /** 当前激励源控制器 */ + private controller: IExcitableController | 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 { + 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): 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 +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf52c8f..20bf9fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -660,6 +660,9 @@ importers: packages/system: dependencies: + '@motajs/animate': + specifier: workspace:* + version: link:../animate '@motajs/common': specifier: workspace:* version: link:../common