mirror of
https://github.com/motajs/template.git
synced 2026-10-24 16:40:17 +08:00
feat(09-23): implement scoped hotkey system with commands and excitation-driven modes
This commit is contained in:
parent
7409a47037
commit
77f5b3aa19
188
.planning/phases/09-minimum-browser-runnable/09-23-SUMMARY.md
Normal file
188
.planning/phases/09-minimum-browser-runnable/09-23-SUMMARY.md
Normal 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*
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@motajs/system",
|
||||
"dependencies": {
|
||||
"@motajs/animate": "workspace:*",
|
||||
"@motajs/common": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
221
packages/system/src/hotkey/__test__/command.test.ts
Normal file
221
packages/system/src/hotkey/__test__/command.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1059
packages/system/src/hotkey/__test__/system.test.ts
Normal file
1059
packages/system/src/hotkey/__test__/system.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
114
packages/system/src/hotkey/command.ts
Normal file
114
packages/system/src/hotkey/command.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
export * from './command';
|
||||
export * from './hotkey';
|
||||
export * from './keyboard';
|
||||
export * from './keyCodes';
|
||||
export * from './system';
|
||||
export * from './type';
|
||||
|
||||
487
packages/system/src/hotkey/system.ts
Normal file
487
packages/system/src/hotkey/system.ts
Normal 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
|
||||
}
|
||||
@ -660,6 +660,9 @@ importers:
|
||||
|
||||
packages/system:
|
||||
dependencies:
|
||||
'@motajs/animate':
|
||||
specifier: workspace:*
|
||||
version: link:../animate
|
||||
'@motajs/common':
|
||||
specifier: workspace:*
|
||||
version: link:../common
|
||||
|
||||
Loading…
Reference in New Issue
Block a user