diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 5b84edba0..6203d0b74 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -43,6 +43,7 @@ import popup from "./popup.js" import settingAppColorPicker from "./setting-app-color-picker.js" import settingColorPicker from "./setting-color-picker.js" import shelfSort from "./shelf-sort.js" +import shortcuts from "./shortcuts"; import sidebar from "./sidebar.js" import sortableList from "./sortable-list.js" import submitOnChange from "./submit-on-change.js" @@ -101,6 +102,7 @@ const componentMapping = { "setting-app-color-picker": settingAppColorPicker, "setting-color-picker": settingColorPicker, "shelf-sort": shelfSort, + "shortcuts": shortcuts, "sidebar": sidebar, "sortable-list": sortableList, "submit-on-change": submitOnChange, diff --git a/resources/js/components/shortcuts.js b/resources/js/components/shortcuts.js new file mode 100644 index 000000000..799f0e629 --- /dev/null +++ b/resources/js/components/shortcuts.js @@ -0,0 +1,119 @@ +/** + * The default mapping of unique id to shortcut key. + * @type {Object} + */ +const defaultMap = { + "edit": "e", + "global_search": "/", +}; + +function reverseMap(map) { + const reversed = {}; + for (const [key, value] of Object.entries(map)) { + reversed[value] = key; + } + return reversed; +} + +/** + * @extends {Component} + */ +class Shortcuts { + + setup() { + this.container = this.$el; + this.mapById = defaultMap; + this.mapByShortcut = reverseMap(this.mapById); + + this.hintsShowing = false; + // TODO - Allow custom key maps + // TODO - Allow turning off shortcuts + // TODO - Roll out to interface elements + // TODO - Hide hints on focus, scroll, click + + this.setupListeners(); + } + + setupListeners() { + window.addEventListener('keydown', event => { + + if (event.target.closest('input, select, textarea')) { + return; + } + + const shortcutId = this.mapByShortcut[event.key]; + if (shortcutId) { + const wasHandled = this.runShortcut(shortcutId); + if (wasHandled) { + event.preventDefault(); + } + } + }); + + window.addEventListener('keydown', event => { + if (event.key === '?') { + this.hintsShowing ? this.hideHints() : this.showHints(); + this.hintsShowing = !this.hintsShowing; + } + }); + } + + /** + * 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}"]`); + console.info('Shortcut run', el); + if (!el) { + return false; + } + + if (el.matches('input, textarea, select')) { + el.focus(); + return true; + } + + if (el.matches('a, button')) { + el.click(); + return true; + } + + console.error(`Shortcut attempted to be ran for element type that does not have handling setup`, el); + + return false; + } + + showHints() { + const shortcutEls = this.container.querySelectorAll('[data-shortcut]'); + for (const shortcutEl of shortcutEls) { + const id = shortcutEl.getAttribute('data-shortcut'); + const key = this.mapById[id]; + this.showHintLabel(shortcutEl, key); + } + } + + showHintLabel(targetEl, key) { + const targetBounds = targetEl.getBoundingClientRect(); + const label = document.createElement('div'); + label.classList.add('shortcut-hint'); + label.textContent = key; + this.container.append(label); + + const labelBounds = label.getBoundingClientRect(); + + label.style.insetInlineStart = `${((targetBounds.x + targetBounds.width) - (labelBounds.width + 12))}px`; + label.style.insetBlockStart = `${(targetBounds.y + (targetBounds.height - labelBounds.height) / 2)}px`; + } + + hideHints() { + const hints = this.container.querySelectorAll('.shortcut-hint'); + for (const hint of hints) { + hint.remove(); + } + } +} + +export default Shortcuts; \ No newline at end of file diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index acb45100f..661fce758 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -982,4 +982,18 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .status-indicator-inactive { background-color: $negative; +} + +.shortcut-hint { + position: fixed; + padding: $-xxs $-xxs; + font-size: .85rem; + font-weight: 700; + line-height: 1; + z-index: 99; + background-color: #eee; + border-radius: 3px; + border: 1px solid #b4b4b4; + box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 2px 0 0 rgba(255, 255, 255, .7) inset; + color: #333; } \ No newline at end of file diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index b95b69d1b..dbd2cbb35 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -109,7 +109,7 @@
@if(userCan('book-update', $book)) - + @icon('edit') {{ trans('common.edit') }} diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index 197b80c27..1b0e64ac4 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -22,6 +22,7 @@ diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 9f6e9f89a..928eb17a0 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -31,7 +31,7 @@ @stack('translations') - + @include('layouts.parts.base-body-start') @include('common.skip-to-content')