From bb4747851194896034d7569e0758614397b0b012 Mon Sep 17 00:00:00 2001 From: ajurna Date: Fri, 16 Oct 2026 09:41:45 +0100 Subject: [PATCH] Add keyboard shortcut help and improved navigation controls - Introduced a modal to display keyboard shortcuts with a toggle button (`?`) in the interface. - Added support for toggling shortcuts list visibility via `?` and `Escape` keys. - Enabled navigation with additional shortcuts: `n/p`, `Home/End`, `m`, and `Space/Shift+Space` for improved usability. - Modified keypress handling to accommodate new shortcuts while preventing default browser scroll actions where necessary. - Updated styles to ensure proper positioning and accessibility of the shortcut list. --- frontend/src/components/TheComicReader.vue | 112 ++++++++++++++++++++- frontend/test/comicreader.test.js | 92 +++++++++++++++++ 2 files changed, 200 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/TheComicReader.vue b/frontend/src/components/TheComicReader.vue index 16cbb4b..747efa5 100644 --- a/frontend/src/components/TheComicReader.vue +++ b/frontend/src/components/TheComicReader.vue @@ -46,6 +46,29 @@ @nextComic="nextComic" /> +
+ +
+ + @@ -84,6 +107,18 @@ export default { save_timeout: null, key_locked: false, key_timeout: null, + show_shortcuts: false, + shortcuts: [ + { keys: '← / →', action: 'Previous / next page' }, + { keys: '↑ / ↓', action: 'Scroll within the page' }, + { keys: 'Space', action: 'Next page' }, + { keys: 'Shift + Space', action: 'Previous page' }, + { keys: 'n / p', action: 'Next / previous page' }, + { keys: 'Home / End', action: 'First / last page' }, + { keys: 'm', action: 'Switch reading mode' }, + { keys: '?', action: 'Show or hide this list' }, + { keys: 'Esc', action: 'Hide this list' }, + ], } }, props: { @@ -181,6 +216,10 @@ export default { return } + if (!this.deck) { + return + } + this.current_page = pageNum-1 this.deck.slide(this.current_page) }, @@ -197,6 +236,17 @@ export default { return } + // These only show or hide the shortcut list, so they still work when a control + // has focus. + if (e.key === 'Escape') { + this.show_shortcuts = false + return + } + if (e.key === '?') { + this.show_shortcuts = !this.show_shortcuts + return + } + // The reader listens on window, so a key pressed while a control has focus would // otherwise page as well as activate that control. if (e.target && e.target.closest && e.target.closest('input, select, textarea, button')) { @@ -213,10 +263,34 @@ export default { // scrolling: the page is taller than the viewport, and intercepting them here would // double up with the native scroll. In continuous mode the observer records whichever // page is topmost, so scrolling inside a page does not change the saved page. - if (e.key === 'ArrowRight') { - this.nextPage('auto') - } else if (e.key === 'ArrowLeft') { - this.prevPage('auto') + const key = e.key.toLowerCase() + switch (key) { + case 'arrowright': + case 'n': + this.nextPage('auto') + return + case 'arrowleft': + case 'p': + this.prevPage('auto') + return + case ' ': + // Space would otherwise scroll the page as well as turning it. + e.preventDefault?.() + if (e.shiftKey) { + this.prevPage('auto') + } else { + this.nextPage('auto') + } + return + case 'home': + case 'end': + // Same for Home/End, which the browser would scroll to the page edges. + e.preventDefault?.() + this.setPage(key === 'home' ? 1 : this.pages) + return + case 'm': + this.setMode(this.mode === 'paged' ? 'scroll' : 'paged') + return } }, // Continuous mode: reveal is not used for visibility here, so the page in view is @@ -415,6 +489,36 @@ section { padding-bottom: 60px; } +.shortcuts { + position: fixed; + right: 12px; + bottom: 64px; + max-width: 340px; + padding: 10px 12px; + border: 1px solid #adb5bd; + border-radius: 6px; + background: rgba(255, 255, 255, 0.95); + font-size: 0.85rem; + z-index: 20; +} + +.shortcuts-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.shortcuts td { + padding: 2px 8px 2px 0; + vertical-align: top; +} + +.shortcuts kbd { + font-family: inherit; + white-space: nowrap; +} + /* reveal.css is deliberately not loaded, so visibility is the `hidden` attribute and reveal's own transition classes do nothing. A transition cannot run across display:none -> block, so paged mode uses an animation on the slide that becomes diff --git a/frontend/test/comicreader.test.js b/frontend/test/comicreader.test.js index f6286c0..93159fb 100644 --- a/frontend/test/comicreader.test.js +++ b/frontend/test/comicreader.test.js @@ -231,6 +231,98 @@ describe('TheComicReader', () => { expect(api.put).not.toHaveBeenCalled() }) + it('shows the shortcut list with ? and hides it again', async () => { + const wrapper = mountReader() + await flushPromises() + + expect(wrapper.find('.shortcuts').exists()).toBe(false) + + wrapper.vm.keyPress({ key: '?' }) + await flushPromises() + expect(wrapper.find('.shortcuts').exists()).toBe(true) + + wrapper.vm.keyPress({ key: '?' }) + await flushPromises() + expect(wrapper.find('.shortcuts').exists()).toBe(false) + }) + + it('hides the shortcut list with Escape even when a control has focus', async () => { + const wrapper = mountReader() + await flushPromises() + + wrapper.vm.keyPress({ key: '?' }) + await flushPromises() + expect(wrapper.find('.shortcuts').exists()).toBe(true) + + wrapper.vm.keyPress({ key: 'Escape', target: document.createElement('input') }) + await flushPromises() + expect(wrapper.find('.shortcuts').exists()).toBe(false) + }) + + it('toggles the shortcut list from the ? button', async () => { + const wrapper = mountReader() + await flushPromises() + + const button = wrapper.find('button[aria-pressed]') + await button.trigger('click') + expect(wrapper.find('.shortcuts').exists()).toBe(true) + expect(button.attributes('aria-pressed')).toBe('true') + }) + + it('pages with Space and Shift+Space', async () => { + const wrapper = mountReader() + await flushPromises() + deck.slide.mockClear() + + const preventDefault = vi.fn() + wrapper.vm.keyPress({ key: ' ', preventDefault }) + expect(preventDefault).toHaveBeenCalled() + expect(deck.slide).toHaveBeenCalledWith(3) + + vi.advanceTimersByTime(100) + wrapper.vm.keyPress({ key: ' ', shiftKey: true, preventDefault }) + expect(deck.slide).toHaveBeenCalledWith(2) + }) + + it('pages with n and p whatever the case', async () => { + const wrapper = mountReader() + await flushPromises() + deck.slide.mockClear() + + wrapper.vm.keyPress({ key: 'n' }) + expect(deck.slide).toHaveBeenCalledWith(3) + + vi.advanceTimersByTime(100) + wrapper.vm.keyPress({ key: 'P' }) + expect(deck.slide).toHaveBeenCalledWith(2) + }) + + it('jumps to the first and last page with Home and End', async () => { + const wrapper = mountReader() + await flushPromises() + deck.slide.mockClear() + + const preventDefault = vi.fn() + wrapper.vm.keyPress({ key: 'Home', preventDefault }) + expect(preventDefault).toHaveBeenCalled() + expect(deck.slide).toHaveBeenCalledWith(0) + + vi.advanceTimersByTime(100) + wrapper.vm.keyPress({ key: 'End', preventDefault }) + expect(deck.slide).toHaveBeenCalledWith(4) + }) + + it('switches reading mode with m', async () => { + const wrapper = mountReader() + await flushPromises() + + wrapper.vm.keyPress({ key: 'm' }) + await flushPromises() + + expect(store.commit).toHaveBeenCalledWith('setReaderMode', 'scroll') + expect(wrapper.vm.mode).toBe('scroll') + }) + it('shows the top of the page when changing pages in paged mode', async () => { const wrapper = mountReader() await flushPromises()