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"
/>
+
+
+
+
+
+
+ Keyboard shortcuts
+
+
+
+
+
+ | {{ shortcut.keys }} |
+ {{ shortcut.action }} |
+
+
+
@@ -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()