Skip to content

Commit

Permalink
[FEATURE] Add hotkey API to TYPO3 backend
Browse files Browse the repository at this point in the history
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
andreaskienast committed Nov 27, 2023
1 parent 3ffb5e0 commit 8da3585
Show file tree
Hide file tree
Showing 11 changed files with 400 additions and 20 deletions.
14 changes: 14 additions & 0 deletions Build/Sources/TypeScript/backend/form-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import Utility from '@typo3/backend/utility';
import { selector } from '@typo3/core/literals';
import '@typo3/backend/form-engine/element/extra/char-counter';
import type { PromiseControls } from '@typo3/backend/event/interaction-request-assignment';
import Hotkeys from '@typo3/backend/hotkeys';

interface OnFieldChangeItem {
name: string;
Expand Down Expand Up @@ -1272,6 +1273,12 @@ export default (function() {
};

FormEngine.saveDocument = function(): void {
const currentlyFocussed = document.activeElement;
if (currentlyFocussed instanceof HTMLInputElement || currentlyFocussed instanceof HTMLSelectElement || currentlyFocussed instanceof HTMLTextAreaElement) {
// Blur currently focussed :input element to trigger FormEngine's internal data normalization
currentlyFocussed.blur();
}

FormEngine.formElement.doSave.value = 1;
FormEngine.formElement.requestSubmit();
};
Expand All @@ -1291,6 +1298,13 @@ export default (function() {
FormEngine.Validation.initialize(FormEngine.formElement);
FormEngine.reinitialize();
$('#t3js-ui-block').remove();

Hotkeys.setScope('backend/form-engine');
Hotkeys.register([Hotkeys.normalizedCtrlModifierKey, 's'], (e: KeyboardEvent): void => {
e.preventDefault();

FormEngine.saveDocument();
}, { scope: 'backend/form-engine', allowOnEditables: true, bindElement: FormEngine.formElement._savedok });
});
};

Expand Down
216 changes: 216 additions & 0 deletions Build/Sources/TypeScript/backend/hotkeys.ts
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 Build/Sources/TypeScript/backend/hotkeys/hotkey-storage.ts
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;
Original file line number Diff line number Diff line change
@@ -1,25 +1,13 @@
import { BroadcastMessage } from '@typo3/backend/broadcast-message';
import BroadcastService from '@typo3/backend/broadcast-service';
import RegularEvent from '@typo3/core/event/regular-event';
import Modal from '../modal';

enum ModifierKeys {
META = 'Meta',
CTRL = 'Control'
}
import Hotkeys from '@typo3/backend/hotkeys';
import DocumentService from '@typo3/core/document-service';

class LiveSearchShortcut {
public constructor() {
// navigator.platform is deprecated, but https://developer.mozilla.org/en-US/docs/Web/API/User-Agent_Client_Hints_API is experimental for now
const expectedModifierKey = navigator.platform.toLowerCase().startsWith('mac') ? ModifierKeys.META : ModifierKeys.CTRL;

new RegularEvent('keydown', (e: KeyboardEvent): void => {
if (e.repeat) {
return;
}

const modifierKeyIsDown = expectedModifierKey === ModifierKeys.META && e.metaKey || expectedModifierKey === ModifierKeys.CTRL && e.ctrlKey;
if (modifierKeyIsDown && ['k', 'K'].includes(e.key)) {
DocumentService.ready().then((): void => {
Hotkeys.register([Hotkeys.normalizedCtrlModifierKey, 'k'], (e: KeyboardEvent): void => {
if (Modal.currentModal) {
// A modal window is already active, keep default behavior of browser
return;
Expand All @@ -33,8 +21,8 @@ class LiveSearchShortcut {
'trigger-open',
{}
));
}
}).bindTo(document);
}, { allowOnEditables: true /* @todo: bindElement cannot be used at the moment as the suitable element exists twice! */ });
});
}
}

Expand Down
2 changes: 2 additions & 0 deletions Build/types/TYPO3/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ declare namespace TYPO3 {
export let FORMEDITOR_APP: import('@typo3/form/backend/form-editor').FormEditor;
export let FORMMANAGER_APP: import('@typo3/form/backend/form-manager').FormManager;
export let FormEngine: typeof import('@typo3/backend/form-engine').default;
export let HotkeyStorage: typeof import('@typo3/backend/hotkeys/hotkey-storage').default;
export let Hotkeys: typeof import('@typo3/backend/hotkeys').default;
export let Icons: typeof import('@typo3/backend/icons').default;
export let InfoWindow: typeof import('@typo3/backend/info-window').default;
export let LoginRefresh: typeof import('@typo3/backend/login-refresh').default;
Expand Down
3 changes: 3 additions & 0 deletions typo3/sysext/backend/Classes/Controller/BackendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ public function mainAction(ServerRequestInterface $request): ResponseInterface
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/broadcast-service.js')->invoke('listen')
);
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/hotkeys.js')
);
// load the storage API and fill the UC into the PersistentStorage, so no additional AJAX call is needed
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/storage/persistent.js')
Expand Down

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions 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.

Loading

0 comments on commit 8da3585

Please sign in to comment.