]> BookStack Code Mirror - bookstack/blob - resources/js/components/shortcuts.js
Started implementation of UI shortcuts system
[bookstack] / resources / js / components / shortcuts.js
1 /**
2  * The default mapping of unique id to shortcut key.
3  * @type {Object<string, string>}
4  */
5 const defaultMap = {
6     "edit": "e",
7     "global_search": "/",
8 };
9
10 function reverseMap(map) {
11     const reversed = {};
12     for (const [key, value] of Object.entries(map)) {
13         reversed[value] = key;
14     }
15     return reversed;
16 }
17
18 /**
19  * @extends {Component}
20  */
21 class Shortcuts {
22
23     setup() {
24         this.container = this.$el;
25         this.mapById = defaultMap;
26         this.mapByShortcut = reverseMap(this.mapById);
27
28         this.hintsShowing = false;
29         // TODO - Allow custom key maps
30         // TODO - Allow turning off shortcuts
31         // TODO - Roll out to interface elements
32         // TODO - Hide hints on focus, scroll, click
33
34         this.setupListeners();
35     }
36
37     setupListeners() {
38         window.addEventListener('keydown', event => {
39
40             if (event.target.closest('input, select, textarea')) {
41                 return;
42             }
43
44             const shortcutId = this.mapByShortcut[event.key];
45             if (shortcutId) {
46                 const wasHandled = this.runShortcut(shortcutId);
47                 if (wasHandled) {
48                     event.preventDefault();
49                 }
50             }
51         });
52
53         window.addEventListener('keydown', event => {
54             if (event.key === '?') {
55                 this.hintsShowing ? this.hideHints() : this.showHints();
56                 this.hintsShowing = !this.hintsShowing;
57             }
58         });
59     }
60
61     /**
62      * Run the given shortcut, and return a boolean to indicate if the event
63      * was successfully handled by a shortcut action.
64      * @param {String} id
65      * @return {boolean}
66      */
67     runShortcut(id) {
68         const el = this.container.querySelector(`[data-shortcut="${id}"]`);
69         console.info('Shortcut run', el);
70         if (!el) {
71             return false;
72         }
73
74         if (el.matches('input, textarea, select')) {
75             el.focus();
76             return true;
77         }
78
79         if (el.matches('a, button')) {
80             el.click();
81             return true;
82         }
83
84         console.error(`Shortcut attempted to be ran for element type that does not have handling setup`, el);
85
86         return false;
87     }
88
89     showHints() {
90         const shortcutEls = this.container.querySelectorAll('[data-shortcut]');
91         for (const shortcutEl of shortcutEls) {
92             const id = shortcutEl.getAttribute('data-shortcut');
93             const key = this.mapById[id];
94             this.showHintLabel(shortcutEl, key);
95         }
96     }
97
98     showHintLabel(targetEl, key) {
99         const targetBounds = targetEl.getBoundingClientRect();
100         const label = document.createElement('div');
101         label.classList.add('shortcut-hint');
102         label.textContent = key;
103         this.container.append(label);
104
105         const labelBounds = label.getBoundingClientRect();
106
107         label.style.insetInlineStart = `${((targetBounds.x + targetBounds.width) - (labelBounds.width + 12))}px`;
108         label.style.insetBlockStart = `${(targetBounds.y + (targetBounds.height - labelBounds.height) / 2)}px`;
109     }
110
111     hideHints() {
112         const hints = this.container.querySelectorAll('.shortcut-hint');
113         for (const hint of hints) {
114             hint.remove();
115         }
116     }
117 }
118
119 export default Shortcuts;