import {Component} from './component'; function reverseMap(map) { const reversed = {}; for (const [key, value] of Object.entries(map)) { reversed[value] = key; } return reversed; } export class Shortcuts extends Component { setup() { this.container = this.$el; this.mapById = JSON.parse(this.$opts.keyMap); this.mapByShortcut = reverseMap(this.mapById); this.hintsShowing = false; this.hideHints = this.hideHints.bind(this); this.hintAbortController = null; this.setupListeners(); } setupListeners() { window.addEventListener('keydown', event => { if (event.target.closest('input, select, textarea, .cm-editor')) { return; } this.handleShortcutPress(event); }); window.addEventListener('keydown', event => { if (event.key === '?') { if (this.hintsShowing) { this.hideHints(); } else { this.showHints(); } } }); } /** * @param {KeyboardEvent} event */ handleShortcutPress(event) { const keys = [ event.ctrlKey ? 'Ctrl' : '', event.metaKey ? 'Cmd' : '', event.key, ]; const combo = keys.filter(s => Boolean(s)).join(' + '); const shortcutId = this.mapByShortcut[combo]; if (shortcutId) { const wasHandled = this.runShortcut(shortcutId); if (wasHandled) { event.preventDefault(); } } } /** * Run the given shortcut, and return a boolean to indicate if the event * was successfully handled by a shortcut action. * @param {String} id * @return {boolean} */ runShortcut(id) { const el = this.container.querySelector(`[data-shortcut="${id}"]`); if (!el) { return false; } if (el.matches('input, textarea, select')) { el.focus(); return true; } if (el.matches('a, button')) { el.click(); return true; } if (el.matches('div[tabindex]')) { el.click(); el.focus(); return true; } console.error('Shortcut attempted to be ran for element type that does not have handling setup', el); return false; } showHints() { const wrapper = document.createElement('div'); wrapper.classList.add('shortcut-container'); this.container.append(wrapper); const shortcutEls = this.container.querySelectorAll('[data-shortcut]'); const displayedIds = new Set(); for (const shortcutEl of shortcutEls) { const id = shortcutEl.getAttribute('data-shortcut'); if (displayedIds.has(id)) { continue; } const key = this.mapById[id]; this.showHintLabel(shortcutEl, key, wrapper); displayedIds.add(id); } this.hintAbortController = new AbortController(); const signal = this.hintAbortController.signal; window.addEventListener('scroll', this.hideHints, {signal}); window.addEventListener('focus', this.hideHints, {signal}); window.addEventListener('blur', this.hideHints, {signal}); window.addEventListener('click', this.hideHints, {signal}); this.hintsShowing = true; } /** * @param {Element} targetEl * @param {String} key * @param {Element} wrapper */ showHintLabel(targetEl, key, wrapper) { const targetBounds = targetEl.getBoundingClientRect(); const label = document.createElement('div'); label.classList.add('shortcut-hint'); label.textContent = key; const linkage = document.createElement('div'); linkage.classList.add('shortcut-linkage'); linkage.style.left = `${targetBounds.x}px`; linkage.style.top = `${targetBounds.y}px`; linkage.style.width = `${targetBounds.width}px`; linkage.style.height = `${targetBounds.height}px`; wrapper.append(label, linkage); const labelBounds = label.getBoundingClientRect(); label.style.insetInlineStart = `${((targetBounds.x + targetBounds.width) - (labelBounds.width + 6))}px`; label.style.insetBlockStart = `${(targetBounds.y + (targetBounds.height - labelBounds.height) / 2)}px`; } hideHints() { const wrapper = this.container.querySelector('.shortcut-container'); wrapper.remove(); this.hintAbortController?.abort(); this.hintsShowing = false; } }