]> BookStack Code Mirror - bookstack/blob - resources/js/components/shortcuts.js
7feb9bed047f4c7f7fb480c305fa0b9fcbcf703b
[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 shortcutEls = this.container.querySelectorAll('[data-shortcut]');
119         const displayedIds = new Set();
120         for (const shortcutEl of shortcutEls) {
121             const id = shortcutEl.getAttribute('data-shortcut');
122             if (displayedIds.has(id)) {
123                 continue;
124             }
125
126             const key = this.mapById[id];
127             this.showHintLabel(shortcutEl, key);
128             displayedIds.add(id);
129         }
130
131         window.addEventListener('scroll', this.hideHints);
132         window.addEventListener('focus', this.hideHints);
133         window.addEventListener('blur', this.hideHints);
134         window.addEventListener('click', this.hideHints);
135
136         this.hintsShowing = true;
137     }
138
139     showHintLabel(targetEl, key) {
140         const targetBounds = targetEl.getBoundingClientRect();
141         const label = document.createElement('div');
142         label.classList.add('shortcut-hint');
143         label.textContent = key;
144         this.container.append(label);
145
146         const labelBounds = label.getBoundingClientRect();
147
148         label.style.insetInlineStart = `${((targetBounds.x + targetBounds.width) - (labelBounds.width + 12))}px`;
149         label.style.insetBlockStart = `${(targetBounds.y + (targetBounds.height - labelBounds.height) / 2)}px`;
150     }
151
152     hideHints() {
153         const hints = this.container.querySelectorAll('.shortcut-hint');
154         for (const hint of hints) {
155             hint.remove();
156         }
157
158         window.removeEventListener('scroll', this.hideHints);
159         window.removeEventListener('focus', this.hideHints);
160         window.removeEventListener('blur', this.hideHints);
161         window.removeEventListener('click', this.hideHints);
162
163         this.hintsShowing = false;
164     }
165 }
166
167 export default Shortcuts;