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