]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/services/shortcuts.ts
05bdb5dccd3a752d46fefce5aa90e1e8090ba0bb
[bookstack] / resources / js / wysiwyg / services / shortcuts.ts
1 import {$getSelection, COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, LexicalEditor} from "lexical";
2 import {
3     cycleSelectionCalloutFormats,
4     formatCodeBlock, insertOrUpdateLink,
5     toggleSelectionAsBlockquote,
6     toggleSelectionAsHeading, toggleSelectionAsList,
7     toggleSelectionAsParagraph
8 } from "../utils/formats";
9 import {HeadingTagType} from "@lexical/rich-text";
10 import {EditorUiContext} from "../ui/framework/core";
11 import {$getNodeFromSelection} from "../utils/selection";
12 import {$isLinkNode, LinkNode} from "@lexical/link";
13 import {$showLinkForm} from "../ui/defaults/forms/objects";
14 import {showLinkSelector} from "../utils/links";
15
16 function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean {
17     toggleSelectionAsHeading(editor, tag);
18     return true;
19 }
20
21 function wrapFormatAction(formatAction: (editor: LexicalEditor) => any): ShortcutAction {
22     return (editor: LexicalEditor) => {
23         formatAction(editor);
24         return true;
25     };
26 }
27
28 function toggleInlineCode(editor: LexicalEditor): boolean {
29     editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
30     return true;
31 }
32
33 type ShortcutAction = (editor: LexicalEditor, context: EditorUiContext) => boolean;
34
35 /**
36  * List of action functions by their shortcut combo.
37  * We use "meta" as an abstraction for ctrl/cmd depending on platform.
38  */
39 const actionsByKeys: Record<string, ShortcutAction> = {
40     'meta+s': () => {
41         window.$events.emit('editor-save-draft');
42         return true;
43     },
44     'meta+enter': () => {
45         window.$events.emit('editor-save-page');
46         return true;
47     },
48     'meta+1': (editor) => headerHandler(editor, 'h1'),
49     'meta+2': (editor) => headerHandler(editor, 'h2'),
50     'meta+3': (editor) => headerHandler(editor, 'h3'),
51     'meta+4': (editor) => headerHandler(editor, 'h4'),
52     'meta+5': wrapFormatAction(toggleSelectionAsParagraph),
53     'meta+d': wrapFormatAction(toggleSelectionAsParagraph),
54     'meta+6': wrapFormatAction(toggleSelectionAsBlockquote),
55     'meta+q': wrapFormatAction(toggleSelectionAsBlockquote),
56     'meta+7': wrapFormatAction(formatCodeBlock),
57     'meta+e': wrapFormatAction(formatCodeBlock),
58     'meta+8': toggleInlineCode,
59     'meta+shift+e': toggleInlineCode,
60     'meta+9': wrapFormatAction(cycleSelectionCalloutFormats),
61
62     'meta+o': wrapFormatAction((e) => toggleSelectionAsList(e, 'number')),
63     'meta+p': wrapFormatAction((e) => toggleSelectionAsList(e, 'bullet')),
64     'meta+k': (editor, context) => {
65         editor.getEditorState().read(() => {
66             const selectedLink = $getNodeFromSelection($getSelection(), $isLinkNode) as LinkNode | null;
67             $showLinkForm(selectedLink, context);
68         });
69         return true;
70     },
71     'meta+shift+k': (editor, context) => {
72         showLinkSelector(entity => {
73             insertOrUpdateLink(editor, {
74                 text: entity.name,
75                 title: entity.link,
76                 target: '',
77                 url: entity.link,
78             });
79         });
80         return true;
81     },
82 };
83
84 function createKeyDownListener(context: EditorUiContext): (e: KeyboardEvent) => void {
85     return (event: KeyboardEvent) => {
86         const combo = keyboardEventToKeyComboString(event);
87         // console.log(`pressed: ${combo}`);
88         if (actionsByKeys[combo]) {
89             const handled = actionsByKeys[combo](context.editor, context);
90             if (handled) {
91                 event.stopPropagation();
92                 event.preventDefault();
93             }
94         }
95     };
96 }
97
98 function keyboardEventToKeyComboString(event: KeyboardEvent): string {
99     const metaKeyPressed = isMac() ? event.metaKey : event.ctrlKey;
100
101     const parts = [
102         metaKeyPressed ? 'meta' : '',
103         event.shiftKey ? 'shift' : '',
104         event.key,
105     ];
106
107     return parts.filter(Boolean).join('+').toLowerCase();
108 }
109
110 function isMac(): boolean {
111     return window.navigator.userAgent.includes('Mac OS X');
112 }
113
114 function overrideDefaultCommands(editor: LexicalEditor) {
115     // Prevent default ctrl+enter command
116     editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
117         if (isMac()) {
118             return event?.metaKey || false;
119         }
120         return event?.ctrlKey || false;
121     }, COMMAND_PRIORITY_HIGH);
122 }
123
124 export function registerShortcuts(context: EditorUiContext) {
125     const listener = createKeyDownListener(context);
126     overrideDefaultCommands(context.editor);
127
128     return context.editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => {
129         // add the listener to the current root element
130         rootElement?.addEventListener('keydown', listener);
131         // remove the listener from the old root element
132         prevRootElement?.removeEventListener('keydown', listener);
133     });
134 }