]> BookStack Code Mirror - bookstack/blob - resources/js/components/shortcuts.js
JS: Fixed ESLint issues
[bookstack] / resources / js / components / shortcuts.js
1 import {Component} from './component';
2
3 function reverseMap(map) {
4     const reversed = {};
5     for (const [key, value] of Object.entries(map)) {
6         reversed[value] = key;
7     }
8     return reversed;
9 }
10
11 export class Shortcuts extends Component {
12
13     setup() {
14         this.container = this.$el;
15         this.mapById = JSON.parse(this.$opts.keyMap);
16         this.mapByShortcut = reverseMap(this.mapById);
17
18         this.hintsShowing = false;
19
20         this.hideHints = this.hideHints.bind(this);
21         this.hintAbortController = null;
22
23         this.setupListeners();
24     }
25
26     setupListeners() {
27         window.addEventListener('keydown', event => {
28             if (event.target.closest('input, select, textarea, .cm-editor')) {
29                 return;
30             }
31
32             this.handleShortcutPress(event);
33         });
34
35         window.addEventListener('keydown', event => {
36             if (event.key === '?') {
37                 if (this.hintsShowing) {
38                     this.hideHints();
39                 } else {
40                     this.showHints();
41                 }
42             }
43         });
44     }
45
46     /**
47      * @param {KeyboardEvent} event
48      */
49     handleShortcutPress(event) {
50         const keys = [
51             event.ctrlKey ? 'Ctrl' : '',
52             event.metaKey ? 'Cmd' : '',
53             event.key,
54         ];
55
56         const combo = keys.filter(s => Boolean(s)).join(' + ');
57
58         const shortcutId = this.mapByShortcut[combo];
59         if (shortcutId) {
60             const wasHandled = this.runShortcut(shortcutId);
61             if (wasHandled) {
62                 event.preventDefault();
63             }
64         }
65     }
66
67     /**
68      * Run the given shortcut, and return a boolean to indicate if the event
69      * was successfully handled by a shortcut action.
70      * @param {String} id
71      * @return {boolean}
72      */
73     runShortcut(id) {
74         const el = this.container.querySelector(`[data-shortcut="${id}"]`);
75         if (!el) {
76             return false;
77         }
78
79         if (el.matches('input, textarea, select')) {
80             el.focus();
81             return true;
82         }
83
84         if (el.matches('a, button')) {
85             el.click();
86             return true;
87         }
88
89         if (el.matches('div[tabindex]')) {
90             el.click();
91             el.focus();
92             return true;
93         }
94
95         console.error('Shortcut attempted to be ran for element type that does not have handling setup', el);
96
97         return false;
98     }
99
100     showHints() {
101         const wrapper = document.createElement('div');
102         wrapper.classList.add('shortcut-container');
103         this.container.append(wrapper);
104
105         const shortcutEls = this.container.querySelectorAll('[data-shortcut]');
106         const displayedIds = new Set();
107         for (const shortcutEl of shortcutEls) {
108             const id = shortcutEl.getAttribute('data-shortcut');
109             if (displayedIds.has(id)) {
110                 continue;
111             }
112
113             const key = this.mapById[id];
114             this.showHintLabel(shortcutEl, key, wrapper);
115             displayedIds.add(id);
116         }
117
118         this.hintAbortController = new AbortController();
119         const signal = this.hintAbortController.signal;
120         window.addEventListener('scroll', this.hideHints, {signal});
121         window.addEventListener('focus', this.hideHints, {signal});
122         window.addEventListener('blur', this.hideHints, {signal});
123         window.addEventListener('click', this.hideHints, {signal});
124
125         this.hintsShowing = true;
126     }
127
128     /**
129      * @param {Element} targetEl
130      * @param {String} key
131      * @param {Element} wrapper
132      */
133     showHintLabel(targetEl, key, wrapper) {
134         const targetBounds = targetEl.getBoundingClientRect();
135
136         const label = document.createElement('div');
137         label.classList.add('shortcut-hint');
138         label.textContent = key;
139
140         const linkage = document.createElement('div');
141         linkage.classList.add('shortcut-linkage');
142         linkage.style.left = `${targetBounds.x}px`;
143         linkage.style.top = `${targetBounds.y}px`;
144         linkage.style.width = `${targetBounds.width}px`;
145         linkage.style.height = `${targetBounds.height}px`;
146
147         wrapper.append(label, linkage);
148
149         const labelBounds = label.getBoundingClientRect();
150
151         label.style.insetInlineStart = `${((targetBounds.x + targetBounds.width) - (labelBounds.width + 6))}px`;
152         label.style.insetBlockStart = `${(targetBounds.y + (targetBounds.height - labelBounds.height) / 2)}px`;
153     }
154
155     hideHints() {
156         const wrapper = this.container.querySelector('.shortcut-container');
157         wrapper.remove();
158         this.hintAbortController?.abort();
159         this.hintsShowing = false;
160     }
161
162 }