Merge pull request #1183 from hgiesel/keykey

Switch to event.key for keyboard sequences
This commit is contained in:
Damien Elmes 2021-05-24 10:53:24 +10:00 committed by GitHub
commit a361313622
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 134 additions and 117 deletions

View file

@ -3,13 +3,10 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="typescript"> <script lang="typescript">
import type { Modifier } from "lib/shortcuts";
import { onDestroy } from "svelte"; import { onDestroy } from "svelte";
import { registerShortcut, getPlatformString } from "lib/shortcuts"; import { registerShortcut, getPlatformString } from "lib/shortcuts";
export let shortcut: string; export let shortcut: string;
export let optionalModifiers: Modifier[] | undefined = [];
const shortcutLabel = getPlatformString(shortcut); const shortcutLabel = getPlatformString(shortcut);
@ -17,14 +14,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function createShortcut({ detail }: CustomEvent): void { function createShortcut({ detail }: CustomEvent): void {
const mounted: HTMLButtonElement = detail.button; const mounted: HTMLButtonElement = detail.button;
deregister = registerShortcut( deregister = registerShortcut((event: KeyboardEvent) => {
(event: KeyboardEvent) => { mounted.dispatchEvent(new MouseEvent("click", event));
mounted.dispatchEvent(new MouseEvent("click", event)); event.preventDefault();
event.preventDefault(); }, shortcut);
},
shortcut,
optionalModifiers
);
} }
onDestroy(() => deregister()); onDestroy(() => deregister());

View file

@ -51,5 +51,5 @@ code {
// override the default down arrow colour in <select> elements // override the default down arrow colour in <select> elements
.night-mode select { .night-mode select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23FFFFFF' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e") background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23FFFFFF' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
} }

View file

@ -41,11 +41,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
</script> </script>
<WithShortcut <WithShortcut shortcut={'Control+Alt?+Shift+C'} let:createShortcut let:shortcutLabel>
shortcut="Control+Shift+KeyC"
optionalModifiers={['Alt']}
let:createShortcut
let:shortcutLabel>
<IconButton <IconButton
tooltip={`${tr.editingClozeDeletion()} (${shortcutLabel})`} tooltip={`${tr.editingClozeDeletion()} (${shortcutLabel})`}
on:click={onCloze} on:click={onCloze}

View file

@ -36,7 +36,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroup {api}> <ButtonGroup {api}>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="F7" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'F7'} let:createShortcut let:shortcutLabel>
<IconButton <IconButton
class="forecolor" class="forecolor"
tooltip={appendInParentheses(tr.editingSetForegroundColor(), shortcutLabel)} tooltip={appendInParentheses(tr.editingSetForegroundColor(), shortcutLabel)}
@ -48,7 +48,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="F8" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'F8'} let:createShortcut let:shortcutLabel>
<ColorPicker <ColorPicker
tooltip={appendInParentheses(tr.editingChangeColor(), shortcutLabel)} tooltip={appendInParentheses(tr.editingChangeColor(), shortcutLabel)}
on:change={setWithCurrentColor} on:change={setWithCurrentColor}

View file

@ -26,7 +26,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroup {api}> <ButtonGroup {api}>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="Control+KeyB" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+B'} let:createShortcut let:shortcutLabel>
<WithState <WithState
key="bold" key="bold"
update={() => document.queryCommandState('bold')} update={() => document.queryCommandState('bold')}
@ -47,7 +47,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="Control+KeyI" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+I'} let:createShortcut let:shortcutLabel>
<WithState <WithState
key="italic" key="italic"
update={() => document.queryCommandState('italic')} update={() => document.queryCommandState('italic')}
@ -68,7 +68,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="Control+KeyU" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+U'} let:createShortcut let:shortcutLabel>
<WithState <WithState
key="underline" key="underline"
update={() => document.queryCommandState('underline')} update={() => document.queryCommandState('underline')}
@ -89,10 +89,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut <WithShortcut shortcut={'Control+='} let:createShortcut let:shortcutLabel>
shortcut="Control+Shift+Equal"
let:createShortcut
let:shortcutLabel>
<WithState <WithState
key="superscript" key="superscript"
update={() => document.queryCommandState('superscript')} update={() => document.queryCommandState('superscript')}
@ -113,7 +110,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="Control+Equal" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+Shift+='} let:createShortcut let:shortcutLabel>
<WithState <WithState
key="subscript" key="subscript"
update={() => document.queryCommandState('subscript')} update={() => document.queryCommandState('subscript')}
@ -134,7 +131,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="Control+KeyR" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+R'} let:createShortcut let:shortcutLabel>
<IconButton <IconButton
tooltip={appendInParentheses(tr.editingRemoveFormatting(), shortcutLabel)} tooltip={appendInParentheses(tr.editingRemoveFormatting(), shortcutLabel)}
on:click={() => { on:click={() => {

View file

@ -25,7 +25,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="Control+KeyL" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+L'} let:createShortcut let:shortcutLabel>
<LabelButton <LabelButton
disables={false} disables={false}
tooltip={`${tr.editingCustomizeCardTemplates()} (${shortcutLabel})`} tooltip={`${tr.editingCustomizeCardTemplates()} (${shortcutLabel})`}

View file

@ -10,7 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import LabelButton from "components/LabelButton.svelte"; import LabelButton from "components/LabelButton.svelte";
</script> </script>
<WithShortcut shortcut="Control+Shift+KeyP" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+Shift+P'} let:createShortcut let:shortcutLabel>
<LabelButton <LabelButton
tooltip={tr.browsingPreviewSelectedCard({ val: shortcutLabel })} tooltip={tr.browsingPreviewSelectedCard({ val: shortcutLabel })}
disables={false} disables={false}

View file

@ -36,7 +36,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroup {api}> <ButtonGroup {api}>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="F3" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'F3'} let:createShortcut let:shortcutLabel>
<IconButton <IconButton
tooltip={appendInParentheses(tr.editingAttachPicturesaudiovideo(), shortcutLabel)} tooltip={appendInParentheses(tr.editingAttachPicturesaudiovideo(), shortcutLabel)}
on:click={onAttachment} on:click={onAttachment}
@ -47,7 +47,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="F5" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'F5'} let:createShortcut let:shortcutLabel>
<IconButton <IconButton
tooltip={appendInParentheses(tr.editingRecordAudio(), shortcutLabel)} tooltip={appendInParentheses(tr.editingRecordAudio(), shortcutLabel)}
on:click={onRecord} on:click={onRecord}
@ -69,7 +69,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<DropdownMenu id={menuId}> <DropdownMenu id={menuId}>
<WithShortcut <WithShortcut
shortcut="Control+KeyM, KeyM" shortcut={'Control+M, M'}
let:createShortcut let:createShortcut
let:shortcutLabel> let:shortcutLabel>
<DropdownItem <DropdownItem
@ -81,7 +81,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut> </WithShortcut>
<WithShortcut <WithShortcut
shortcut="Control+KeyM, KeyE" shortcut={'Control+M, E'}
let:createShortcut let:createShortcut
let:shortcutLabel> let:shortcutLabel>
<DropdownItem <DropdownItem
@ -93,7 +93,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut> </WithShortcut>
<WithShortcut <WithShortcut
shortcut="Control+KeyM, KeyC" shortcut={'Control+M, C'}
let:createShortcut let:createShortcut
let:shortcutLabel> let:shortcutLabel>
<DropdownItem <DropdownItem
@ -105,7 +105,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut> </WithShortcut>
<WithShortcut <WithShortcut
shortcut="Control+KeyT, KeyT" shortcut={'Control+T, T'}
let:createShortcut let:createShortcut
let:shortcutLabel> let:shortcutLabel>
<DropdownItem <DropdownItem
@ -117,7 +117,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut> </WithShortcut>
<WithShortcut <WithShortcut
shortcut="Control+KeyT, KeyE" shortcut={'Control+T, E'}
let:createShortcut let:createShortcut
let:shortcutLabel> let:shortcutLabel>
<DropdownItem <DropdownItem
@ -129,7 +129,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut> </WithShortcut>
<WithShortcut <WithShortcut
shortcut="Control+KeyT, KeyM" shortcut={'Control+T, M'}
let:createShortcut let:createShortcut
let:shortcutLabel> let:shortcutLabel>
<DropdownItem <DropdownItem
@ -144,10 +144,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut <WithShortcut shortcut={'Control+Shift+X'} let:createShortcut let:shortcutLabel>
shortcut="Control+Shift+KeyX"
let:createShortcut
let:shortcutLabel>
<IconButton <IconButton
tooltip={appendInParentheses(tr.editingHtmlEditor(), shortcutLabel)} tooltip={appendInParentheses(tr.editingHtmlEditor(), shortcutLabel)}
on:click={onHtmlEdit} on:click={onHtmlEdit}

View file

@ -66,8 +66,7 @@ function updateFocus(evt: FocusEvent) {
registerShortcut( registerShortcut(
() => document.addEventListener("focusin", updateFocus, { once: true }), () => document.addEventListener("focusin", updateFocus, { once: true }),
"Tab", "Shift?+Tab"
["Shift"]
); );
export function onKeyUp(evt: KeyboardEvent): void { export function onKeyUp(evt: KeyboardEvent): void {

View file

@ -4,8 +4,6 @@ import * as tr from "./i18n";
export type Modifier = "Control" | "Alt" | "Shift" | "Meta"; export type Modifier = "Control" | "Alt" | "Shift" | "Meta";
const modifiers: Modifier[] = ["Control", "Alt", "Shift", "Meta"];
function isApplePlatform(): boolean { function isApplePlatform(): boolean {
return ( return (
window.navigator.platform.startsWith("Mac") || window.navigator.platform.startsWith("Mac") ||
@ -18,10 +16,6 @@ const platformModifiers = isApplePlatform()
? ["Meta", "Alt", "Shift", "Control"] ? ["Meta", "Alt", "Shift", "Control"]
: ["Control", "Alt", "Shift", "OS"]; : ["Control", "Alt", "Shift", "OS"];
function splitKeyCombinationString(keyCombinationString: string): string[][] {
return keyCombinationString.split(", ").map((segment) => segment.split("+"));
}
function modifiersToPlatformString(modifiers: string[]): string { function modifiersToPlatformString(modifiers: string[]): string {
const displayModifiers = isApplePlatform() const displayModifiers = isApplePlatform()
? ["^", "⌥", "⇧", "⌘"] ? ["^", "⌥", "⇧", "⌘"]
@ -36,38 +30,50 @@ function modifiersToPlatformString(modifiers: string[]): string {
return result; return result;
} }
const alphabeticPrefix = "Key"; const keyCodeLookup = {
const numericPrefix = "Digit"; Backspace: 8,
const keyToCharacterMap = { Delete: 46,
Backslash: "\\", Tab: 9,
Backquote: "`", Enter: 13,
BracketLeft: "[", F1: 112,
BrackerRight: "]", F2: 113,
Quote: "'", F3: 114,
Semicolon: ";", F4: 115,
Minus: "-", F5: 116,
Equal: "=", F6: 117,
Comma: ",", F7: 118,
Period: ".", F8: 119,
Slash: "/", F9: 120,
F10: 121,
F11: 122,
F12: 123,
"=": 187,
"-": 189,
"[": 219,
"]": 221,
"\\": 220,
";": 186,
"'": 222,
",": 188,
".": 190,
"/": 191,
"`": 192,
}; };
function keyToPlatformString(key: string): string { function isRequiredModifier(modifier: string): boolean {
if (key.startsWith(alphabeticPrefix)) { return !modifier.endsWith("?");
return key.slice(alphabeticPrefix.length);
} else if (key.startsWith(numericPrefix)) {
return key.slice(numericPrefix.length);
} else if (Object.prototype.hasOwnProperty.call(keyToCharacterMap, key)) {
return keyToCharacterMap[key];
} else {
return key;
}
} }
function toPlatformString(modifiersAndKey: string[]): string { function splitKeyCombinationString(keyCombinationString: string): string[][] {
return `${modifiersToPlatformString( return keyCombinationString.split(", ").map((segment) => segment.split("+"));
modifiersAndKey.slice(0, -1) }
)}${keyToPlatformString(modifiersAndKey[modifiersAndKey.length - 1])}`;
function toPlatformString(keyCombination: string[]): string {
return (
modifiersToPlatformString(
keyCombination.slice(0, -1).filter(isRequiredModifier)
) + keyCombination[keyCombination.length - 1]
);
} }
export function getPlatformString(keyCombinationString: string): string { export function getPlatformString(keyCombinationString: string): string {
@ -76,78 +82,107 @@ export function getPlatformString(keyCombinationString: string): string {
.join(", "); .join(", ");
} }
function checkKey(event: KeyboardEvent, key: string): boolean { function checkKey(event: KeyboardEvent, key: number): boolean {
return event.code === key; return event.which === key;
} }
function checkModifiers( const allModifiers: Modifier[] = ["Control", "Alt", "Shift", "Meta"];
event: KeyboardEvent,
optionalModifiers: Modifier[], function partition<T>(predicate: (t: T) => boolean, items: T[]): [T[], T[]] {
activeModifiers: string[] const trueItems: T[] = [];
): boolean { const falseItems: T[] = [];
return modifiers.reduce(
(matches: boolean, modifier: string, currentIndex: number): boolean => items.forEach((t) => {
const target = predicate(t) ? trueItems : falseItems;
target.push(t);
});
return [trueItems, falseItems];
}
function removeTrailing(modifier: string): string {
return modifier.substring(0, modifier.length - 1);
}
function checkModifiers(event: KeyboardEvent, modifiers: string[]): boolean {
const [requiredModifiers, otherModifiers] = partition(
isRequiredModifier,
modifiers
);
const optionalModifiers = otherModifiers.map(removeTrailing);
return allModifiers.reduce(
(matches: boolean, currentModifier: string, currentIndex: number): boolean =>
matches && matches &&
(optionalModifiers.includes(modifier as Modifier) || (optionalModifiers.includes(currentModifier as Modifier) ||
event.getModifierState(platformModifiers[currentIndex]) === event.getModifierState(platformModifiers[currentIndex]) ===
activeModifiers.includes(modifier)), requiredModifiers.includes(currentModifier)),
true true
); );
} }
function check( const check = (keyCode: number, modifiers: string[]) => (
event: KeyboardEvent, event: KeyboardEvent
optionalModifiers: Modifier[], ): boolean => {
modifiersAndKey: string[] return checkKey(event, keyCode) && checkModifiers(event, modifiers);
): boolean { };
return (
checkKey(event, modifiersAndKey[modifiersAndKey.length - 1]) && function keyToCode(key: string): number {
checkModifiers(event, optionalModifiers, modifiersAndKey.slice(0, -1)) return keyCodeLookup[key] || key.toUpperCase().charCodeAt(0);
);
} }
const shortcutTimeoutMs = 400; function keyCombinationToCheck(
keyCombination: string[]
): (event: KeyboardEvent) => boolean {
const keyCode = keyToCode(keyCombination[keyCombination.length - 1]);
const modifiers = keyCombination.slice(0, -1);
return check(keyCode, modifiers);
}
const GENERAL_KEY = 0;
const NUMPAD_KEY = 3;
function innerShortcut( function innerShortcut(
lastEvent: KeyboardEvent, lastEvent: KeyboardEvent,
callback: (event: KeyboardEvent) => void, callback: (event: KeyboardEvent) => void,
optionalModifiers: Modifier[], ...checks: ((event: KeyboardEvent) => boolean)[]
...keyCombination: string[][]
): void { ): void {
let interval: number; let interval: number;
if (keyCombination.length === 0) { if (checks.length === 0) {
callback(lastEvent); callback(lastEvent);
} else { } else {
const [nextKey, ...restKeys] = keyCombination; const [nextCheck, ...restChecks] = checks;
const handler = (event: KeyboardEvent): void => { const handler = (event: KeyboardEvent): void => {
if (check(event, optionalModifiers, nextKey)) { if (nextCheck(event)) {
innerShortcut(event, callback, optionalModifiers, ...restKeys); innerShortcut(event, callback, ...restChecks);
clearTimeout(interval); clearTimeout(interval);
} else if (
event.location === GENERAL_KEY ||
event.location === NUMPAD_KEY
) {
// Any non-modifier key will cancel the shortcut sequence
document.removeEventListener("keydown", handler);
} }
}; };
interval = setTimeout(
(): void => document.removeEventListener("keydown", handler),
shortcutTimeoutMs
);
document.addEventListener("keydown", handler, { once: true }); document.addEventListener("keydown", handler, { once: true });
} }
} }
export function registerShortcut( export function registerShortcut(
callback: (event: KeyboardEvent) => void, callback: (event: KeyboardEvent) => void,
keyCombinationString: string, keyCombinationString: string
optionalModifiers: Modifier[] = []
): () => void { ): () => void {
const keyCombination = splitKeyCombinationString(keyCombinationString); const [check, ...restChecks] = splitKeyCombinationString(keyCombinationString).map(
const [firstKey, ...restKeys] = keyCombination; keyCombinationToCheck
);
const handler = (event: KeyboardEvent): void => { const handler = (event: KeyboardEvent): void => {
if (check(event, optionalModifiers, firstKey)) { if (check(event)) {
innerShortcut(event, callback, optionalModifiers, ...restKeys); innerShortcut(event, callback, ...restChecks);
} }
}; };