]> BookStack Code Mirror - bookstack/blob - resources/js/components/shortcuts.js
Improved shortcut overlay with related action highlighting
[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     // Header actions
7     "home": "1",
8     "shelves_view": "2",
9     "books_view": "3",
10     "settings_view": "4",
11     "favorites_view": "5",
12     "profile_view": "6",
13     "global_search": "/",
14     "logout": "0",
15
16     // Generic actions
17     "edit": "e",
18     "new": "n",
19     "copy": "c",
20     "delete": "d",
21     "favorite": "f",
22     "export": "x",
23     "sort": "s",
24     "permissions": "p",
25     "move": "m",
26     "revisions": "r",
27
28     // Navigation
29     "next": "ArrowRight",
30     "prev": "ArrowLeft",
31 };
32
33 function reverseMap(map) {
34     const reversed = {};
35     for (const [key, value] of Object.entries(map)) {
36         reversed[value] = key;
37     }
38     return reversed;
39 }
40
41 /**
42  * @extends {Component}
43  */
44 class Shortcuts {
45
46     setup() {
47         this.container = this.$el;
48         this.mapById = defaultMap;
49         this.mapByShortcut = reverseMap(this.mapById);
50
51         this.hintsShowing = false;
52
53         this.hideHints = this.hideHints.bind(this);
54         // TODO - Allow custom key maps
55         // TODO - Allow turning off shortcuts
56
57         this.setupListeners();
58     }
59
60     setupListeners() {
61         window.addEventListener('keydown', event => {
62
63             if (event.target.closest('input, select, textarea')) {
64                 return;
65             }
66
67             const shortcutId = this.mapByShortcut[event.key];
68             if (shortcutId) {
69                 const wasHandled = this.runShortcut(shortcutId);
70                 if (wasHandled) {
71                     event.preventDefault();
72                 }
73             }
74         });
75
76         window.addEventListener('keydown', event => {
77             if (event.key === '?') {
78                 this.hintsShowing ? this.hideHints() : this.showHints();
79             }
80         });
81     }
82
83     /**
84      * Run the given shortcut, and return a boolean to indicate if the event
85      * was successfully handled by a shortcut action.
86      * @param {String} id
87      * @return {boolean}
88      */
89     runShortcut(id) {
90         const el = this.container.querySelector(`[data-shortcut="${id}"]`);
91         console.info('Shortcut run', el);
92         if (!el) {
93             return false;
94         }
95
96         if (el.matches('input, textarea, select')) {
97             el.focus();
98             return true;
99         }
100
101         if (el.matches('a, button')) {
102             el.click();
103             return true;
104         }
105
106         if (el.matches('div[tabindex]')) {
107             el.click();
108             el.focus();
109             return true;
110         }
111
112         console.error(`Shortcut attempted to be ran for element type that does not have handling setup`, el);
113
114         return false;
115     }
116
117     showHints() {
118         const wrapper = document.createElement('div');
119         wrapper.classList.add('shortcut-container');
120         this.container.append(wrapper);
121
122         const shortcutEls = this.container.querySelectorAll('[data-shortcut]');
123         const displayedIds = new Set();
124         for (const shortcutEl of shortcutEls) {
125             const id = shortcutEl.getAttribute('data-shortcut');
126             if (displayedIds.has(id)) {
127                 continue;
128             }
129
130             const key = this.mapById[id];
131             this.showHintLabel(shortcutEl, key, wrapper);
132             displayedIds.add(id);
133         }
134
135         window.addEventListener('scroll', this.hideHints);
136         window.addEventListener('focus', this.hideHints);
137         window.addEventListener('blur', this.hideHints);
138         window.addEventListener('click', this.hideHints);
139
140         this.hintsShowing = true;
141     }
142
143     /**
144      * @param {Element} targetEl
145      * @param {String} key
146      * @param {Element} wrapper
147      */
148     showHintLabel(targetEl, key, wrapper) {
149         const targetBounds = targetEl.getBoundingClientRect();
150
151         const label = document.createElement('div');
152         label.classList.add('shortcut-hint');
153         label.textContent = key;
154
155         const linkage = document.createElement('div');
156         linkage.classList.add('shortcut-linkage');
157         linkage.style.left = targetBounds.x + 'px';
158         linkage.style.top = targetBounds.y + 'px';
159         linkage.style.width = targetBounds.width + 'px';
160         linkage.style.height = targetBounds.height + 'px';
161
162         wrapper.append(label, linkage);
163
164         const labelBounds = label.getBoundingClientRect();
165
166         label.style.insetInlineStart = `${((targetBounds.x + targetBounds.width) - (labelBounds.width + 6))}px`;
167         label.style.insetBlockStart = `${(targetBounds.y + (targetBounds.height - labelBounds.height) / 2)}px`;
168     }
169
170     hideHints() {
171         const wrapper = this.container.querySelector('.shortcut-container');
172         wrapper.remove();
173
174         window.removeEventListener('scroll', this.hideHints);
175         window.removeEventListener('focus', this.hideHints);
176         window.removeEventListener('blur', this.hideHints);
177         window.removeEventListener('click', this.hideHints);
178
179         this.hintsShowing = false;
180     }
181 }
182
183 export default Shortcuts;