fix/refactor(frontend): hotkeyの改修 (#14157)
* improve(frontend): hotkeyの改修 (#234) (cherry picked from commit 678be147f4db709dadf25d007cc2e679e98a370e) * Change path, add missing script Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com> * fix * fix * add missing keycodes * fix * update changelog --------- Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com>
This commit is contained in:
@@ -3,93 +3,132 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import keyCode from './keycode.js';
|
||||
//#region types
|
||||
export type Keymap = Record<string, CallbackFunction | CallbackObject>;
|
||||
|
||||
type Callback = (ev: KeyboardEvent) => void;
|
||||
type CallbackFunction = (ev: KeyboardEvent) => unknown;
|
||||
|
||||
type Keymap = Record<string, Callback>;
|
||||
type CallbackObject = {
|
||||
callback: CallbackFunction;
|
||||
allowRepeat?: boolean;
|
||||
};
|
||||
|
||||
type Pattern = {
|
||||
which: string[];
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
ctrl: boolean;
|
||||
alt: boolean;
|
||||
shift: boolean;
|
||||
};
|
||||
|
||||
type Action = {
|
||||
patterns: Pattern[];
|
||||
callback: Callback;
|
||||
allowRepeat: boolean;
|
||||
callback: CallbackFunction;
|
||||
options: Required<Omit<CallbackObject, 'callback'>>;
|
||||
};
|
||||
//#endregion
|
||||
|
||||
//#region consts
|
||||
const KEY_ALIASES = {
|
||||
'esc': 'Escape',
|
||||
'enter': ['Enter', 'NumpadEnter'],
|
||||
'space': [' ', 'Spacebar'],
|
||||
'up': 'ArrowUp',
|
||||
'down': 'ArrowDown',
|
||||
'left': 'ArrowLeft',
|
||||
'right': 'ArrowRight',
|
||||
'plus': ['+', ';'],
|
||||
};
|
||||
|
||||
const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => {
|
||||
const result = {
|
||||
patterns: [],
|
||||
callback,
|
||||
allowRepeat: true,
|
||||
} as Action;
|
||||
const MODIFIER_KEYS = ['ctrl', 'alt', 'shift'];
|
||||
|
||||
if (patterns.match(/^\(.*\)$/) !== null) {
|
||||
result.allowRepeat = false;
|
||||
patterns = patterns.slice(1, -1);
|
||||
}
|
||||
|
||||
result.patterns = patterns.split('|').map(part => {
|
||||
const pattern = {
|
||||
which: [],
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
} as Pattern;
|
||||
|
||||
const keys = part.trim().split('+').map(x => x.trim().toLowerCase());
|
||||
for (const key of keys) {
|
||||
switch (key) {
|
||||
case 'ctrl': pattern.ctrl = true; break;
|
||||
case 'alt': pattern.alt = true; break;
|
||||
case 'shift': pattern.shift = true; break;
|
||||
default: pattern.which = keyCode(key).map(k => k.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
return pattern;
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const ignoreElements = ['input', 'textarea'];
|
||||
|
||||
function match(ev: KeyboardEvent, patterns: Action['patterns']): boolean {
|
||||
const key = ev.key.toLowerCase();
|
||||
return patterns.some(pattern => pattern.which.includes(key) &&
|
||||
pattern.ctrl === ev.ctrlKey &&
|
||||
pattern.shift === ev.shiftKey &&
|
||||
pattern.alt === ev.altKey &&
|
||||
!ev.metaKey,
|
||||
);
|
||||
}
|
||||
const IGNORE_ELEMENTS = ['input', 'textarea'];
|
||||
//#endregion
|
||||
|
||||
//#region impl
|
||||
export const makeHotkey = (keymap: Keymap) => {
|
||||
const actions = parseKeymap(keymap);
|
||||
|
||||
return (ev: KeyboardEvent) => {
|
||||
if (document.activeElement) {
|
||||
if (ignoreElements.some(el => document.activeElement!.matches(el))) return;
|
||||
if (document.activeElement.attributes['contenteditable']) return;
|
||||
if ('pswp' in window && window.pswp != null) return;
|
||||
if (document.activeElement != null) {
|
||||
if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return;
|
||||
if ((document.activeElement as HTMLElement).isContentEditable) return;
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
const matched = match(ev, action.patterns);
|
||||
|
||||
if (matched) {
|
||||
if (!action.allowRepeat && ev.repeat) return;
|
||||
|
||||
for (const { patterns, callback, options } of actions) {
|
||||
if (matchPatterns(ev, patterns, options)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
action.callback(ev);
|
||||
break;
|
||||
callback(ev);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const parseKeymap = (keymap: Keymap) => {
|
||||
return Object.entries(keymap).map(([rawPatterns, rawCallback]) => {
|
||||
const patterns = parsePatterns(rawPatterns);
|
||||
const callback = parseCallback(rawCallback);
|
||||
const options = parseOptions(rawCallback);
|
||||
return { patterns, callback, options } as const satisfies Action;
|
||||
});
|
||||
};
|
||||
|
||||
const parsePatterns = (rawPatterns: keyof Keymap) => {
|
||||
return rawPatterns.split('|').map(part => {
|
||||
const keys = part.split('+').map(trimLower);
|
||||
const which = parseKeyCode(keys.findLast(x => !MODIFIER_KEYS.includes(x)));
|
||||
const ctrl = keys.includes('ctrl');
|
||||
const alt = keys.includes('alt');
|
||||
const shift = keys.includes('shift');
|
||||
return { which, ctrl, alt, shift } as const satisfies Pattern;
|
||||
});
|
||||
};
|
||||
|
||||
const parseCallback = (rawCallback: Keymap[keyof Keymap]) => {
|
||||
if (typeof rawCallback === 'object') {
|
||||
return rawCallback.callback;
|
||||
}
|
||||
return rawCallback;
|
||||
};
|
||||
|
||||
const parseOptions = (rawCallback: Keymap[keyof Keymap]) => {
|
||||
const defaultOptions = {
|
||||
allowRepeat: false,
|
||||
} as const satisfies Action['options'];
|
||||
if (typeof rawCallback === 'object') {
|
||||
const { callback, ...rawOptions } = rawCallback;
|
||||
const options = { ...defaultOptions, ...rawOptions };
|
||||
return { ...options } as const satisfies Action['options'];
|
||||
}
|
||||
return { ...defaultOptions } as const satisfies Action['options'];
|
||||
};
|
||||
|
||||
const matchPatterns = (ev: KeyboardEvent, patterns: Action['patterns'], options: Action['options']) => {
|
||||
if (ev.repeat && !options.allowRepeat) return false;
|
||||
const key = ev.key.toLowerCase();
|
||||
return patterns.some(({ which, ctrl, shift, alt }) => {
|
||||
if (!which.includes(key)) return false;
|
||||
if (ctrl !== (ev.ctrlKey || ev.metaKey)) return false;
|
||||
if (alt !== ev.altKey) return false;
|
||||
if (shift !== ev.shiftKey) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const parseKeyCode = (input?: string | null) => {
|
||||
if (input == null) return [];
|
||||
const raw = getValueByKey(KEY_ALIASES, input);
|
||||
if (raw == null) return [input];
|
||||
if (typeof raw === 'string') return [trimLower(raw)];
|
||||
return raw.map(trimLower);
|
||||
};
|
||||
|
||||
const getValueByKey = <
|
||||
T extends Record<keyof any, unknown>,
|
||||
K extends keyof T | keyof any,
|
||||
R extends K extends keyof T ? T[K] : T[keyof T] | undefined,
|
||||
>(obj: T, key: K) => {
|
||||
return obj[key] as R;
|
||||
};
|
||||
|
||||
const trimLower = (str: string) => str.trim().toLowerCase();
|
||||
//#endregion
|
||||
|
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export default (input: string): string[] => {
|
||||
if (Object.keys(aliases).some(a => a.toLowerCase() === input.toLowerCase())) {
|
||||
const codes = aliases[input];
|
||||
return Array.isArray(codes) ? codes : [codes];
|
||||
} else {
|
||||
return [input];
|
||||
}
|
||||
};
|
||||
|
||||
export const aliases = {
|
||||
'esc': 'Escape',
|
||||
'enter': ['Enter', 'NumpadEnter'],
|
||||
'space': [' ', 'Spacebar'],
|
||||
'up': 'ArrowUp',
|
||||
'down': 'ArrowDown',
|
||||
'left': 'ArrowLeft',
|
||||
'right': 'ArrowRight',
|
||||
'plus': ['NumpadAdd', 'Semicolon'],
|
||||
};
|
Reference in New Issue
Block a user