refactor: 按键系统

This commit is contained in:
AncTe committed 2026-10-22 22:25:59 +08:00
1 parent d5b2a820d8
commit 564f53d82a
15 files changed
+569 -419

No files matched your search

+52 -43
View File
@@ -24,20 +24,20 @@ export class HotkeyCommand implements IHotkeyCommand {
dt: number;
length: number;
/** 指令标识符,用于告警信息 */
private readonly command: string;
/** 按键组合列表,长度与 {@link length} 保持一致 */
private combinations: KeyCode[][];
private keys: KeyCode[][] = [];
constructor(command: string, init: IHotkeyCommandInit) {
constructor(
readonly command: string,
init: IHotkeyCommandInit
) {
this.command = command;
this.priority = init.priority;
this.block = init.block;
this.block = init.block ?? false;
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) =>
@@ -46,69 +46,78 @@ export class HotkeyCommand implements IHotkeyCommand {
}
setKeys(count: number): void {
if (!Number.isInteger(count) || count < 0) return;
while (this.combinations.length < count) {
this.combinations.push([]);
if (!Number.isInteger(count)) {
logger.warn(207, 'float');
return;
}
if (this.combinations.length > count) {
this.combinations.length = count;
if (count <= 0) {
logger.warn(207, 'negative integer');
return;
}
this.length = this.combinations.length;
if (count > this.keys.length) {
while (count > this.keys.length) {
this.keys.push([]);
}
} else if (count < this.keys.length) {
while (count < this.keys.length) {
this.keys.pop();
}
}
this.length = this.keys.length;
}
setKeyCombination(index: number, keys: KeyCode[]): void {
if (
!Number.isInteger(index) ||
index < 0 ||
index >= this.combinations.length
) {
if (!Number.isInteger(index)) {
logger.warn(206, 'float');
return;
}
const copy = keys.slice();
this.combinations[index] = copy;
if (index < 0) {
logger.warn(206, 'negetive integer');
return;
}
if (index >= this.keys.length) {
logger.warn(206, 'integer but exceeds combination count');
return;
}
this.keys[index] = keys.slice();
if (this.mode === HotkeyEmitMode.KeyUp) {
const regular = copy.filter(v => !isAuxiliaryKey(v));
const regular = keys.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() : [];
getKeyCombination(index: number): readonly KeyCode[] {
return this.keys[index]?.slice() ?? [];
}
*iterateKeyCombinations(): Iterable<[index: number, key: KeyCode[]]> {
for (let i = 0; i < this.combinations.length; i++) {
yield [i, this.combinations[i].slice()];
*iterateKeyCombinations(): Iterable<
[index: number, key: readonly KeyCode[]]
> {
for (let i = 0; i < this.keys.length; i++) {
yield [i, this.keys[i].slice()];
}
}
keyUp(timeout?: number): void {
keyUp(timeout: number = 0): 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);
this.timeout = timeout;
}
once(timeout?: number): void {
once(timeout: number = 0): void {
this.mode = HotkeyEmitMode.Once;
if (timeout !== undefined) this.timeout = timeout;
this.timeout = timeout;
}
continuous(interval?: number, timeout?: number): void {
continuous(
interval: number = 0,
dt: number = 0,
timeout: number = 0
): 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.interval = interval;
this.dt = dt;
if (interval !== undefined) this.interval = interval;
if (timeout !== undefined) this.timeout = timeout;
this.timeout = timeout;
}
}
+297 -326
View File
@@ -1,10 +1,9 @@
import { IExcitable, IExcitableController, IExcitation } from '@motajs/animate';
import { logger } from '@motajs/common';
import { IExcitable, IExcitation } from '@motajs/animate';
import { logger, matchArray } from '@motajs/common';
import { HotkeyCommand, isAuxiliaryKey } from './command';
import { EVENT_KEY_CODE_MAP, KeyCode } from './keyCodes';
import {
HotkeyEmitMode,
HotkeyEmitType,
IHotkeyCommand,
IHotkeyCommandInit,
IHotkeyEmitContext,
@@ -12,99 +11,95 @@ import {
IHotkeySystem
} from './type';
type HotkeyHandler = (ctx: IHotkeyEmitInfo) => void;
type HotkeyListener = (event: KeyboardEvent) => void;
// TODO: 优先级阻断
interface IHotkeyScope {
/** 作用域唯一标识符 */
symbol: symbol;
readonly symbol: symbol;
/** 作用域内已实现的按键指令 */
readonly implements: Map<IHotkeyCommand, (info: IHotkeyEmitInfo) => void>;
/** 作用域条件,为 `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;
interface IHotkeyCommandPending {
/** 按键指令对象 */
readonly command: IHotkeyCommand;
/** 按键组合被按下的时刻 */
start: number;
/** 按键组合的索引 */
keyIndex: number;
/** 触发此指令的按键组合 */
keys: readonly KeyCode[];
/** 按键触发上下文 */
context: IHotkeyEmitContext;
/** 当前已经触发了几次 */
emitted: number;
/** 上一次的触发时刻 */
lastEmit: number;
}
export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
listening: HTMLElement | null = null;
holding: KeyCode[] = [];
/** 指令注册表 */
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 excitation: IExcitation<number> | null = null;
/** 获取当前栈顶作用域 */
/** 当前正在按下的按键组成的集合 */
private holdingSet: Set<KeyCode> = new Set();
/** 如果按键指令都是按下时触发,那么当前的按键组合下,有可能触发哪些按键 */
private holdingCmds: Map<IHotkeyCommand, IHotkeyCommandPending> = new Map();
/** 需要等待 `timeout` 进行一次触发的指令集合 */
private waitTimeout: Set<IHotkeyCommandPending> = new Set();
/** 键盘按下监听器 */
private keyDownListener: (ev: KeyboardEvent) => void;
/** 键盘抬起监听器 */
private keyUpListener: (ev: KeyboardEvent) => void;
constructor() {
const keyDown = (event: KeyboardEvent) => {
const key = this.resolveKeyCode(event.keyCode);
if (this.holding.includes(key)) return;
if (key === KeyCode.Unknown) return;
const emitted = this.down(key);
if (emitted) event.preventDefault();
};
const keyUp = (event: KeyboardEvent) => {
const key = this.resolveKeyCode(event.keyCode);
if (key === KeyCode.Unknown) return;
const emitted = this.up(key);
if (emitted) event.preventDefault();
};
this.keyDownListener = keyDown;
this.keyUpListener = keyUp;
}
/**
* 将浏览器事件的按键码映射为按键枚举,部分浏览器的 Meta 键需要本地映射
* @param eventKeyCode 浏览器事件的按键码
*/
private resolveKeyCode(eventKeyCode: number): KeyCode {
if (
eventKeyCode === 91 ||
eventKeyCode === 92 ||
eventKeyCode === 224
) {
return KeyCode.Meta;
}
const key = EVENT_KEY_CODE_MAP[eventKeyCode];
return key ?? KeyCode.Unknown;
}
/**
* 获取当前栈顶作用域
*/
private getScope(): IHotkeyScope | null {
return this.scopes.at(-1) ?? null;
}
@@ -114,253 +109,73 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
* @param type 触发方式
* @param key 触发此指令的按键
*/
private createContext(
type: HotkeyEmitType,
key: KeyCode
): IHotkeyEmitContext {
const holding = this.holding.slice();
private createContext(): IHotkeyEmitContext {
return {
type,
key,
holding,
ctrl: holding.includes(KeyCode.Ctrl),
shift: holding.includes(KeyCode.Shift),
alt: holding.includes(KeyCode.Alt),
meta: holding.includes(KeyCode.Meta)
holding: this.holding,
ctrl: this.holdingSet.has(KeyCode.Ctrl),
shift: this.holdingSet.has(KeyCode.Shift),
alt: this.holdingSet.has(KeyCode.Alt),
meta: this.holdingSet.has(KeyCode.Meta)
};
}
//#endregion
//#region 组合匹配
/**
* 判断按键组合是否与按键抬起事件匹配
* @param keys 按键组合
* @param keys 当前的按键组合
* @param key 抬起的按键
*/
private matchKeyUp(keys: KeyCode[], key: KeyCode): boolean {
private matchKeyUp(keys: readonly 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;
// 抬起按键仅允许一个非辅助按键
const [main, auxiliary] = matchArray(keys, v => isAuxiliaryKey(v));
if (main.length > 1) return false;
// 如果只有一个按键,那么可以触发,哪怕它是辅助按键
if (main.length === 0) {
return auxiliary.length === 1 && auxiliary[0] === key;
}
if (regular[0] !== key) return false;
return keys
.filter(v => isAuxiliaryKey(v))
.every(v => this.holding.includes(v));
// 否则需要判断触发的按键
if (main[0] !== key) return false;
return auxiliary.every(v => this.holdingSet.has(v));
}
/**
* 判断按键组合是否被当前按下的按键所满足
* 判断一个按键组合当前是否满足
* @param keys 按键组合
*/
private matchKeyDown(keys: KeyCode[]): boolean {
if (keys.length === 0) return false;
return keys.every(v => this.holding.includes(v));
private matchKeys(keys: readonly KeyCode[]) {
return keys.every(v => this.holdingSet.has(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();
this.scopes.push({ symbol, condition: null, implements: new Map() });
this.holdingCmds.clear();
this.waitTimeout.clear();
return symbol;
}
leaveScope(sym?: symbol): void {
if (sym === undefined) {
if (!sym) {
if (this.scopes.length === 0) {
logger.warn(205);
return;
}
this.scopes.pop();
this.dropPending();
return;
this.holdingCmds.clear();
this.waitTimeout.clear();
}
const index = this.scopes.findIndex(v => v.symbol === sym);
if (index < 0) {
logger.warn(205);
return;
}
this.scopes.length = index + 1;
this.dropPending();
this.scopes.splice(index + 1);
this.holdingCmds.clear();
this.waitTimeout.clear();
}
when(func: () => boolean): this {
@@ -373,17 +188,18 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
return this;
}
implement(command: string, func: HotkeyHandler): this {
implement(command: string, func: (ctx: IHotkeyEmitInfo) => void): this {
const scope = this.getScope();
if (!scope) {
logger.warn(201);
return this;
}
if (!this.commands.has(command)) {
const obj = this.commands.get(command);
if (!obj) {
logger.warn(202, command);
return this;
}
scope.handlers.set(command, func);
scope.implements.set(obj, func);
return this;
}
@@ -407,80 +223,235 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
}
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);
const obj = this.commands.get(command);
if (!obj) return;
this.commands.delete(command);
this.scopes.forEach(scope => scope.implements.delete(obj));
this.holdingCmds.delete(obj);
const toDeleteWait = new Set<IHotkeyCommandPending>();
this.waitTimeout.forEach(v => {
if (v.command === obj) {
toDeleteWait.add(v);
}
});
toDeleteWait.forEach(v => {
this.waitTimeout.delete(v);
});
}
*iterateCommands(): Iterable<IHotkeyCommand> {
yield* this.commands.values();
iterateCommands(): Iterable<IHotkeyCommand> {
return this.commands.values();
}
//#endregion
//#region 按下与抬起
/**
* 触发指定按键指令
* @param command 按键指令对象
* @param pending 按键指令的 pending 信息
*/
private emit(pending: IHotkeyCommandPending) {
const command = pending.command;
const scope = this.getScope();
if (!scope) return;
const impl = scope.implements.get(command);
if (!impl) return;
const info: IHotkeyEmitInfo = {
mode: command.mode,
command,
index: pending.keyIndex,
keys: pending.keys,
context: pending.context
};
impl(info);
pending.emitted++;
pending.lastEmit = this.excitation?.payload() ?? 0;
}
/**
* 获取首个能够触发按键指令的按键组合
* @param command 按键指令
*/
private findMatchKeys(
command: IHotkeyCommand
): [index: number, readonly KeyCode[]] | [] {
for (const [index, keys] of command.iterateKeyCombinations()) {
const match = keys.every(v => this.holdingSet.has(v));
if (match) return [index, keys];
}
return [];
}
/**
* 添加可能触发的按键指令
* @param command 按键指令对象
* @param index 触发的按键索引
* @param keys 按键组合
*/
private addPending(
command: IHotkeyCommand,
index: number,
keys: readonly KeyCode[]
) {
const pending: IHotkeyCommandPending = {
command,
start: this.excitation?.payload() ?? 0,
keyIndex: index,
keys,
context: this.createContext(),
lastEmit: -Infinity,
emitted: 0
};
this.holdingCmds.set(command, pending);
if (command.timeout <= 0 && command.mode !== HotkeyEmitMode.KeyUp) {
// 如果没有 timeout,那么需要立即触发一次
this.emit(pending);
}
}
down(key: KeyCode): boolean {
if (this.holding.includes(key)) return false;
this.holding.push(key);
return this.dispatch(HotkeyEmitType.KeyDown, key);
if (this.holdingSet.has(key)) {
// 如果有按键可能触发,那么返回 true 来 preventDefault
return this.holdingCmds.size > 0 || this.waitTimeout.size > 0;
} else {
this.holding.push(key);
this.holdingSet.add(key);
}
for (const command of this.commands.values()) {
const match = this.findMatchKeys(command);
if (match.length === 0) continue;
const [index, keys] = match;
const pending = this.holdingCmds.get(command);
if (pending) {
// 如果之前有过这个按键指令,那么直接修改现有状态
pending.keyIndex = index;
pending.keys = keys;
pending.context = this.createContext();
} else {
// 如果之前没有这个按键指令,那么加入执行列表
this.addPending(command, index, keys);
continue;
}
}
// 如果有按键可能触发,那么返回 true 来 preventDefault
return this.holdingCmds.size > 0 || this.waitTimeout.size > 0;
}
up(key: KeyCode): boolean {
if (!this.holdingSet.has(key)) return false;
this.holdingSet.delete(key);
const index = this.holding.indexOf(key);
if (index < 0) return false;
this.holding.splice(index, 1);
this.dropPending();
return this.dispatch(HotkeyEmitType.KeyUp, key);
// 需要快照
for (const [command, pending] of new Map(this.holdingCmds)) {
// 对于 KeyUp,如果有可以触发的,那么按规则触发
if (command.mode === HotkeyEmitMode.KeyUp) {
const emit = this.matchKeyUp(pending.keys, key);
if (emit) {
this.emit(pending);
this.holdingCmds.delete(command);
}
} else {
// 对于其他按键,如果一个按下组合按键不再满足,那么需要删除或加入 timeout
if (!this.matchKeys(pending.keys)) {
// 松开一个按键,那么并不一定会导致按下指令不再触发
const newMatch = this.findMatchKeys(command);
if (newMatch.length === 0) {
// 没有新的匹配,分两种情况
if (pending.emitted === 0) {
// 还没触发过,加入 timeout
this.waitTimeout.add(pending);
} else {
// 已经触发过了,直接删除
this.holdingCmds.delete(command);
}
} else {
// 如果有新的匹配,那么直接修改状态
const [index, keys] = newMatch;
pending.context = this.createContext();
pending.keyIndex = index;
pending.keys = keys;
}
}
}
}
// 如果有按键可能触发,那么返回 true 来 preventDefault
return this.holdingCmds.size > 0 || this.waitTimeout.size > 0;
}
//#endregion
//#region 激励
excitedBy(excitation: IExcitation<number>): void {
if (this.controller) {
this.controller.revoke();
excitedBy(excitation: IExcitation<number> | null): void {
if (this.excitation) {
this.excitation.remove(this);
}
this.excitation = excitation;
if (excitation) {
excitation.add(this);
}
this.controller = excitation.add(this);
}
excited(payload: number): void {
this.now = payload;
this.flushPending();
// 先判定 timeout,需要快照
for (const pending of new Set(this.waitTimeout)) {
const dt = payload - pending.start;
if (dt >= pending.command.timeout) {
this.emit(pending);
this.waitTimeout.delete(pending);
}
}
// 再看 holding,这个不会删,所以不需要快照
for (const [command, pending] of this.holdingCmds) {
// 抬起不会在这里触发
if (command.mode === HotkeyEmitMode.KeyUp) continue;
// 首次触发
const dt = payload - pending.start;
const dt2 = payload - pending.lastEmit;
if (pending.emitted === 0 && dt < command.timeout) {
this.emit(pending);
}
if (command.mode === HotkeyEmitMode.Continuous) {
if (pending.emitted === 1) {
// 先看第二次触发
if (dt2 >= command.interval) {
this.emit(pending);
}
} else {
// 其他的要看 dt 了
if (dt2 >= command.dt) {
this.emit(pending);
}
}
}
}
}
//#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);
}
this.listening = target;
}
//#endregion
+43 -42
View File
@@ -1,14 +1,10 @@
import { IExcitation } from '@motajs/animate';
import { KeyCode } from './keyCodes';
// TODO: 适配存档,可能需要把 ISaveableContent 挪到 @motajs/common,或让 packages-user 自己负责存档
export const enum HotkeyEmitType {
/** 由按键按下触发 */
KeyDown,
/** 由按键抬起触发 */
KeyUp
}
// 需要注意的是,所有的 “最小触发间隔” 指的都是系统能保证两次按键的触发间隔不小于此值,并不能保证等于此值,
// 更不能保证连续触发 n 次后,总体经过的时间为 n 乘以此间隔。
// 例如,如果触发了 10 次,触发间隔为 100ms,那么经过的时间一定大于等于 1000ms,绝大多数情况下都会大于 1000ms。
// 因此,不要拿这个触发间隔作为精确计时器,需要精确计时器时使用传递给系统的激励源的 `payload` 接口获取。
export const enum HotkeyEmitMode {
/** 按键抬起时触发 */
@@ -16,35 +12,42 @@ export const enum HotkeyEmitMode {
/** 按下时只会触发一次 */
Once,
/** 按下后依据激励源进行触发 */
Continuous,
/** 按下后节流触发 */
Throttle
Continuous
}
export interface IHotkeyEmitContext {
/** 按键触发方式 */
readonly type: HotkeyEmitType;
/** 触发此指令的按键 */
readonly key: KeyCode;
/** 当前所有正在按下的按键,按照按下的顺序排列,注意如果是抬起事件触发的,那么这里面并不会包含触发事件的那个按键 */
readonly holding: KeyCode[];
/** 当前是否按下了 Ctrl 按键 */
/**
* 指令触发时,当前所有正在按下的按键,按照按下的顺序排列。如果是抬起事件触发的,那么这里面并不会包含触发事件的那个主要按键。
* 注意该成员的含义不是当前时刻的按下的按键,对于连续触发以及 `timeout`,依然会包含触发此指令的时刻按下的按键。
* 也就是说,它并不是当前时刻按下的按键,不要用它获取当前时刻正在按下的按键。参考时间线:
*
* ```txt
* ----------|---------------|---------->
* | timeout |
* 指令触发时刻 指令延时触发
* 记录的是这个 而不是这个
* ```
*/
readonly holding: readonly KeyCode[];
/** 指令触发时是否按下了 Ctrl 按键 */
readonly ctrl: boolean;
/** 当前是否按下了 Shift 按键 */
/** 指令触发时是否按下了 Shift 按键 */
readonly shift: boolean;
/** 当前是否按下了 Alt 按键 */
/** 指令触发时是否按下了 Alt 按键 */
readonly alt: boolean;
/** 当前是否按下了 Windows(Windows) / Command(MacOS) 按键 */
/** 指令触发时是否按下了 Windows(Windows) / Command(MacOS) 按键 */
readonly meta: boolean;
}
export interface IHotkeyEmitInfo {
/** 按键指令标识符 */
readonly command: string;
/** 按键触发方式 */
readonly mode: HotkeyEmitMode;
/** 按键指令对象 */
readonly command: IHotkeyCommand;
/** 触发此按键指令的按键组合对应索引 */
readonly index: number;
/** 触发此按键指令的按键组合 */
readonly keys: KeyCode[];
/** 触发此按键指令的按键组合。如果有两组按键组合能触发,那么仅包含触发较晚的那一个 */
readonly keys: readonly KeyCode[];
/** 按键触发上下文 */
readonly context: IHotkeyEmitContext;
}
@@ -52,10 +55,10 @@ export interface IHotkeyEmitInfo {
export interface IHotkeyCommandInit {
/** 按键指令优先级 */
readonly priority: number;
/** 是否阻断优先级精确比此按键指令更低的指令的执行,同优先级的会全部执行,并不会阻断 */
readonly block: boolean;
/** 默认按键组合,可设置多组 */
readonly defaults: KeyCode[][];
/** 是否阻断优先级精确比此按键指令更低的指令的执行,同优先级的会全部执行,并不会阻断,默认不阻断 */
readonly block?: boolean;
/** 按键触发方式,默认为抬起时触发 */
readonly mode?: HotkeyEmitMode;
/** 从指定按键组合被触发,至指令首次被真正执行期间,最少经过的时间 */
@@ -67,6 +70,8 @@ export interface IHotkeyCommandInit {
}
export interface IHotkeyCommand {
/** 按键指令的标识符 */
readonly command: string;
/** 按键指令优先级,越高越先执行,如果同优先级,则无法保证顺序 */
readonly priority: number;
/** 触发后,是否阻断优先级比当前优先级更低的按键的触发 */
@@ -79,7 +84,7 @@ export interface IHotkeyCommand {
readonly interval: number;
/** 按下后距离第一次触发的最小时间间隔 */
readonly timeout: number;
/** Throttle 模式下,两次触发之间的最小时间间隔 */
/** 连续触发模式下,两次触发之间的最小时间间隔 */
readonly dt: number;
/**
@@ -93,18 +98,20 @@ export interface IHotkeyCommand {
* @param index 按键组合索引
* @param keys 按键组合
*/
setKeyCombination(index: number, keys: KeyCode[]): void;
setKeyCombination(index: number, keys: readonly KeyCode[]): void;
/**
* 获取指定索引的按键组合,如果组合不存在返回空数组
* @param index 按键组合索引
*/
getKeyCombination(index: number): KeyCode[];
getKeyCombination(index: number): readonly KeyCode[];
/**
* 迭代所有的按键组合
*/
iterateKeyCombinations(): Iterable<[index: number, key: KeyCode[]]>;
iterateKeyCombinations(): Iterable<
[index: number, key: readonly KeyCode[]]
>;
/**
* 抬起后,在经过 `timeout` 时间后触发一次
@@ -122,29 +129,23 @@ export interface IHotkeyCommand {
* 当按下后,在经过 `timeout` 时间后进行一次触发,之后在松开前,会按照激励源的激励触发
* @param interval 第一次触发距离第二次触发的最小时间间隔,设置后,按下时会立刻进行一次触发,
* 而第二次触发需要等待 `interval` 毫秒才会触发。默认为 0
* @param dt 节流间隔,默认为 0,即每次激励源的激励都会触发
* @param timeout 按下后距离首次触发的时间间隔,单位号码,默认为 0
*/
continuous(interval?: number, timeout?: number): void;
/**
* 当按下后,在经过 `timeout` 时间后进行一次触发,之后在松开前,会按照激励源的激励触发,两次触发的触发间隔不小于节流间隔
* @param dt 节流间隔
* @param interval 第一次触发距离第二次触发的最小时间间隔,设置后,按下时会立刻进行一次触发,
* 而第二次触发需要等待 `interval` 毫秒才会触发。默认为 0
* @param timeout 按下后距离首次触发的时间间隔,单位号码,默认为 0
*/
throttle(dt: number, interval?: number, timeout?: number): void;
continuous(interval?: number, dt?: number, timeout?: number): void;
}
export interface IHotkeySystem {
/** 当前正在监听的元素 */
readonly listening: HTMLElement | null;
/** 当前正在按下的按键,按照按下的顺序排序 */
readonly holding: readonly KeyCode[];
/**
* 设置当前按键系统由指定激励源所激励,用于连续触发按下后持续触发等触发场景
* @param excitation 激励源
*/
excitedBy(excitation: IExcitation<number>): void;
excitedBy(excitation: IExcitation<number> | null): void;
/**
* 进入新的按键作用域