forked from TYPO3/typo3
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[FEATURE] Add hotkey API to TYPO3 backend
TYPO3 provides a new API to let developers easily add keyboard shortcuts to execute certain actions. The following shortcuts are already in place: * Ctrl/Cmd + K: Open LiveSearch * Ctrl/Cmd + S: Save open FormEngine record The API allows to register hotkeys in scoped maps, where the scope can be changed during runtime. However, hotkeys registered in the `all` scope are always executed, if found, to avoid overriding global shortcuts. Resolves: #101507 Releases: main Change-Id: I201c063595a8274569691bbcaa243c858557ea07 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/80266 Tested-by: core-ci <[email protected]> Reviewed-by: Benni Mack <[email protected]> Reviewed-by: Christian Kuhn <[email protected]> Tested-by: Andreas Kienast <[email protected]> Tested-by: Christian Kuhn <[email protected]> Tested-by: Benni Mack <[email protected]> Reviewed-by: Andreas Kienast <[email protected]>
- Loading branch information
1 parent
3ffb5e0
commit 8da3585
Showing
11 changed files
with
400 additions
and
20 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,216 @@ | ||
/* | ||
* This file is part of the TYPO3 CMS project. | ||
* | ||
* It is free software; you can redistribute it and/or modify it under | ||
* the terms of the GNU General Public License, either version 2 | ||
* of the License, or any later version. | ||
* | ||
* For the full copyright and license information, please read the | ||
* LICENSE.txt file that was distributed with this source code. | ||
* | ||
* The TYPO3 project - inspiring people to share! | ||
*/ | ||
|
||
import HotkeyStorage, { ScopedHotkeyMap, HotkeyStruct, Options, HotkeySetup } from '@typo3/backend/hotkeys/hotkey-storage'; | ||
import RegularEvent from '@typo3/core/event/regular-event'; | ||
|
||
export enum ModifierKeys { | ||
META = 'meta', | ||
CTRL = 'control', | ||
SHIFT = 'shift', | ||
ALT = 'alt', | ||
} | ||
|
||
type Hotkey = string[]; | ||
|
||
/** | ||
* Module: @typo3/backend/hotkeys | ||
* | ||
* Provides API to register hotkeys (aka shortcuts) in the TYPO3 backend. It is possible to register hotkeys in | ||
* different scopes, that can also be switched during runtime. Extensions should always specify their scope when | ||
* registering hotkeys. | ||
* | ||
* Due to how the TYPO3 backend currently works, registered hotkeys are limited to the same document the API is used in. | ||
*/ | ||
class Hotkeys { | ||
// navigator.platform is deprecated, but https://developer.mozilla.org/en-US/docs/Web/API/User-Agent_Client_Hints_API is experimental for now | ||
public readonly normalizedCtrlModifierKey = navigator.platform.toLowerCase().startsWith('mac') ? ModifierKeys.META : ModifierKeys.CTRL; | ||
private readonly scopedHotkeyMap: ScopedHotkeyMap | ||
private readonly defaultOptions: Options = { | ||
scope: 'all', | ||
allowOnEditables: false, | ||
allowRepeat: false, | ||
bindElement: undefined | ||
} | ||
|
||
public constructor() { | ||
this.scopedHotkeyMap = HotkeyStorage.getScopedHotkeyMap(); | ||
this.setScope('all'); | ||
this.registerEventHandler(); | ||
} | ||
|
||
public setScope(scope: string): void { | ||
HotkeyStorage.activeScope = scope; | ||
} | ||
|
||
public getScope(): string { | ||
return HotkeyStorage.activeScope; | ||
} | ||
|
||
public register(hotkey: Hotkey, handler: (e: KeyboardEvent) => void, options: Partial<Options> = {}): void { | ||
if (hotkey.filter((hotkeyPart: string) => !Object.values<string>(ModifierKeys).includes(hotkeyPart)).length === 0) { | ||
throw new Error('Attempted to register hotkey "' + hotkey.join('+') + '" without a non-modifier key.'); | ||
} | ||
|
||
// Normalize trigger | ||
hotkey = hotkey.map((h: string) => h.toLowerCase()); | ||
|
||
const mergedConfiguration: Options = { ...this.defaultOptions, ...options }; | ||
if (!this.scopedHotkeyMap.has(mergedConfiguration.scope)) { | ||
this.scopedHotkeyMap.set(mergedConfiguration.scope, new Map()); | ||
} | ||
|
||
let ariaKeyShortcut = this.composeAriaKeyShortcut(hotkey); | ||
const hotkeyMap = this.scopedHotkeyMap.get(mergedConfiguration.scope); | ||
const hotkeyStruct = this.createHotkeyStructFromTrigger(hotkey); | ||
const encodedHotkeyStruct = JSON.stringify(hotkeyStruct); | ||
|
||
if (hotkeyMap.has(encodedHotkeyStruct)) { | ||
const setup = hotkeyMap.get(encodedHotkeyStruct); | ||
|
||
// Hotkey already exists, remove potentially set `aria-keyshortcuts` for this hotkey | ||
setup.options.bindElement?.removeAttribute('aria-keyshortcuts'); | ||
// Delete existing hotkey. If the existing hotkey was registered in a different browser scope, the callback is lost | ||
hotkeyMap.delete(encodedHotkeyStruct); | ||
} | ||
hotkeyMap.set(encodedHotkeyStruct, { struct: hotkeyStruct, handler, options: mergedConfiguration }); | ||
|
||
if (mergedConfiguration.bindElement instanceof Element) { | ||
const existingAriaAttribute = mergedConfiguration.bindElement.getAttribute('aria-keyshortcuts'); | ||
if (existingAriaAttribute !== null && !existingAriaAttribute.includes(ariaKeyShortcut)) { | ||
// Element already has `aria-keyshortcuts`, append composed shortcut | ||
ariaKeyShortcut = existingAriaAttribute + ' ' + ariaKeyShortcut; | ||
} | ||
mergedConfiguration.bindElement.setAttribute('aria-keyshortcuts', ariaKeyShortcut); | ||
} | ||
} | ||
|
||
private registerEventHandler(): void { | ||
new RegularEvent('keydown', (e: KeyboardEvent): void => { | ||
const hotkeySetup = this.findHotkeySetup(e); | ||
if (hotkeySetup === null) { | ||
return; | ||
} | ||
|
||
if (e.repeat && !hotkeySetup.options.allowRepeat) { | ||
return; | ||
} | ||
|
||
if (!hotkeySetup.options.allowOnEditables) { | ||
const target = e.target as HTMLElement; | ||
if (target.isContentEditable || (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) && !(e.target as HTMLInputElement|HTMLTextAreaElement).readOnly)) { | ||
return; | ||
} | ||
} | ||
|
||
hotkeySetup.handler(e); | ||
}).bindTo(document); | ||
} | ||
|
||
private findHotkeySetup(e: KeyboardEvent): HotkeySetup|undefined { | ||
// We always consider the global "all" scope first to avoid overriding global hotkeys | ||
const scopes: string[] = [...new Set(['all', HotkeyStorage.activeScope])]; | ||
const hotkeyStruct = this.createHotkeyStructFromEvent(e); | ||
const encodedHotkeyStruct = JSON.stringify(hotkeyStruct); | ||
|
||
for (const scope of scopes) { | ||
const hotkeyMap = this.scopedHotkeyMap.get(scope); | ||
if (hotkeyMap.has(encodedHotkeyStruct)) { | ||
return hotkeyMap.get(encodedHotkeyStruct); | ||
} | ||
} | ||
|
||
return null; | ||
} | ||
|
||
private createHotkeyStructFromTrigger(hotkey: Hotkey): HotkeyStruct { | ||
const nonModifierCodes = hotkey.filter((hotkeyPart: string) => !Object.values<string>(ModifierKeys).includes(hotkeyPart)); | ||
if (nonModifierCodes.length > 1) { | ||
throw new Error('Cannot register hotkey with more than one non-modifier key, "' + nonModifierCodes.join('+') + '" given.'); | ||
} | ||
|
||
return { | ||
modifiers: { | ||
meta: hotkey.includes(ModifierKeys.META), | ||
ctrl: hotkey.includes(ModifierKeys.CTRL), | ||
shift: hotkey.includes(ModifierKeys.SHIFT), | ||
alt: hotkey.includes(ModifierKeys.ALT), | ||
}, | ||
key: nonModifierCodes[0].toLowerCase(), | ||
}; | ||
} | ||
|
||
private createHotkeyStructFromEvent(e: KeyboardEvent): HotkeyStruct { | ||
return { | ||
modifiers: { | ||
meta: e.metaKey, | ||
ctrl: e.ctrlKey, | ||
shift: e.shiftKey, | ||
alt: e.altKey, | ||
}, | ||
key: e.key.toLowerCase(), | ||
}; | ||
} | ||
|
||
/** | ||
* Composes a string for use with `aria-keyshortcuts` | ||
* @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-keyshortcuts | ||
*/ | ||
private composeAriaKeyShortcut(hotkey: Hotkey): string { | ||
const parts: string[] = []; | ||
|
||
for (let key of hotkey) { | ||
if (key === '+') { | ||
key = 'plus'; | ||
} else { | ||
key = key.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';'); | ||
} | ||
|
||
parts.push(key); | ||
} | ||
|
||
// The standard requires to have modifier keys to be at first | ||
parts.sort((a: string, b: string): number => { | ||
const aIsModifierKey = Object.values<string>(ModifierKeys).includes(a); | ||
const bIsModifierKey = Object.values<string>(ModifierKeys).includes(b); | ||
|
||
if (aIsModifierKey && !bIsModifierKey) { | ||
return -1; | ||
} | ||
|
||
if (!aIsModifierKey && bIsModifierKey) { | ||
return 1; | ||
} | ||
|
||
if (aIsModifierKey && bIsModifierKey) { | ||
return -1; | ||
} | ||
|
||
return 0; | ||
}); | ||
|
||
return parts.join('+'); | ||
} | ||
} | ||
|
||
// Helper to always get the same instance within a frame | ||
// @todo: have the module in `top` scope, while being able to register the `keydown` event in each frame | ||
let hotkeysInstance: Hotkeys; | ||
if (!TYPO3.Hotkeys) { | ||
hotkeysInstance = new Hotkeys(); | ||
TYPO3.Hotkeys = hotkeysInstance; | ||
} else { | ||
hotkeysInstance = TYPO3.Hotkeys; | ||
} | ||
|
||
export default hotkeysInstance; |
61 changes: 61 additions & 0 deletions
61
Build/Sources/TypeScript/backend/hotkeys/hotkey-storage.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
/* | ||
* This file is part of the TYPO3 CMS project. | ||
* | ||
* It is free software; you can redistribute it and/or modify it under | ||
* the terms of the GNU General Public License, either version 2 | ||
* of the License, or any later version. | ||
* | ||
* For the full copyright and license information, please read the | ||
* LICENSE.txt file that was distributed with this source code. | ||
* | ||
* The TYPO3 project - inspiring people to share! | ||
*/ | ||
|
||
export type HotkeyStruct = { | ||
modifiers: { | ||
meta: boolean, | ||
ctrl: boolean, | ||
shift: boolean, | ||
alt: boolean, | ||
}, | ||
key: string | ||
}; | ||
export type HotkeyHandler = (e: KeyboardEvent) => void; | ||
export type Options = { | ||
scope: string, | ||
allowOnEditables: boolean, | ||
allowRepeat: boolean, | ||
bindElement: Element|undefined | ||
} | ||
export type HotkeySetup = { | ||
struct: HotkeyStruct; | ||
handler: HotkeyHandler; | ||
options: Options; | ||
}; | ||
type HotkeyMap = Map<string, HotkeySetup>; | ||
export type ScopedHotkeyMap = Map<string, HotkeyMap>; | ||
|
||
/** | ||
* Storage helper for the hotkeys module to keep registered hotkeys anywhere in the backend scaffold available | ||
*/ | ||
class HotkeyStorage { | ||
public constructor( | ||
private readonly scopedHotkeyMap: ScopedHotkeyMap = new Map(), | ||
public activeScope: string = 'all' | ||
) { | ||
} | ||
|
||
public getScopedHotkeyMap(): ScopedHotkeyMap { | ||
return this.scopedHotkeyMap; | ||
} | ||
} | ||
|
||
let hotkeysStorageInstance: HotkeyStorage; | ||
if (!top.TYPO3.HotkeyStorage) { | ||
hotkeysStorageInstance = new HotkeyStorage(); | ||
top.TYPO3.HotkeyStorage = hotkeysStorageInstance; | ||
} else { | ||
hotkeysStorageInstance = top.TYPO3.HotkeyStorage; | ||
} | ||
|
||
export default hotkeysStorageInstance; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
2 changes: 1 addition & 1 deletion
2
typo3/sysext/backend/Resources/Public/JavaScript/form-engine.js
Large diffs are not rendered by default.
Oops, something went wrong.
13 changes: 13 additions & 0 deletions
13
typo3/sysext/backend/Resources/Public/JavaScript/hotkeys.js
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.