From c212deb7b0f57c786dbb94b9ac25de47d3c5c666 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 4 Jul 2026 21:18:47 +0200 Subject: [PATCH] feat(reader): add a keyboard-shortcut help overlay Press `?` in the reader to open a Sheet documenting the existing bindings (arrows/J/K paging, Home/End, chapter nav, Esc); Esc or the close button dismisses it. While open, the reader swallows navigation keys so paging can't happen behind the overlay. e2e covers open-on-? and close-on-Esc. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/reader-shortcuts.spec.ts | 61 +++++++++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../[id]/chapter/[chapter_id]/+page.svelte | 60 ++++++++++++++++++ 6 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 frontend/e2e/reader-shortcuts.spec.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index da0d003..c8bd883 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.99.0" +version = "0.100.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f4d0b44..0432e04 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.99.0" +version = "0.100.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/reader-shortcuts.spec.ts b/frontend/e2e/reader-shortcuts.spec.ts new file mode 100644 index 0000000..b50f844 --- /dev/null +++ b/frontend/e2e/reader-shortcuts.spec.ts @@ -0,0 +1,61 @@ +import { test, expect, type Page } from './fixtures'; + +// The reader exposes a keyboard-shortcut help overlay: `?` opens it, Esc +// (or the close button) dismisses it. + +const MANGA_ID = 'm1'; +const CHAPTER_ID = 'c1'; + +async function mockReader(page: Page) { + await page.route('**/api/v1/**', async (route) => { + const { pathname } = new URL(route.request().url()); + const json = (status: number, body: unknown) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + + if (pathname.includes('/files/')) { + return route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: '' + }); + } + if (pathname.endsWith('/auth/config')) return json(200, { self_register_enabled: true, private_mode: false }); + if (pathname.endsWith('/auth/me')) return json(401, { error: { code: 'unauthenticated', message: 'no' } }); + if (pathname.endsWith('/auth/me/preferences')) return json(401, { error: { code: 'unauthenticated', message: 'no' } }); + if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}/pages`)) { + return json(200, { + pages: [ + { id: 'p1', chapter_id: CHAPTER_ID, page_number: 1, storage_key: 'k/1', content_type: 'image/svg+xml' }, + { id: 'p2', chapter_id: CHAPTER_ID, page_number: 2, storage_key: 'k/2', content_type: 'image/svg+xml' } + ] + }); + } + if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}`)) { + return json(200, { id: CHAPTER_ID, manga_id: MANGA_ID, number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 }); + } + if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) { + return json(200, { items: [{ id: CHAPTER_ID, manga_id: MANGA_ID, number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 }], page: { limit: 200, offset: 0, total: 1 } }); + } + if (pathname.endsWith(`/mangas/${MANGA_ID}`)) { + return json(200, { id: MANGA_ID, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 }); + } + if (pathname.includes(`/me/read-progress/${MANGA_ID}`)) return json(401, { error: { code: 'unauthenticated', message: 'no' } }); + return json(503, { error: { code: 'e2e_unmocked', message: pathname } }); + }); +} + +test('? opens the keyboard-shortcut overlay and Esc closes it', async ({ page }) => { + await mockReader(page); + await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`); + await expect(page.getByTestId('reader-page')).toBeVisible(); + + await expect(page.getByTestId('reader-shortcuts')).toHaveCount(0); + + await page.keyboard.press('Shift+Slash'); // "?" + await expect(page.getByTestId('reader-shortcuts')).toBeVisible(); + // Lists at least one real binding. + await expect(page.getByTestId('reader-shortcuts')).toContainText('Next page'); + + await page.keyboard.press('Escape'); + await expect(page.getByTestId('reader-shortcuts')).toBeHidden(); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 425dfa2..c78181e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mangalord-frontend", - "version": "0.99.0", + "version": "0.100.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mangalord-frontend", - "version": "0.99.0", + "version": "0.100.0", "devDependencies": { "@lucide/svelte": "^1.16.0", "@playwright/test": "^1.48.0", diff --git a/frontend/package.json b/frontend/package.json index ab85d0c..60f903f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.99.0", + "version": "0.100.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte index 8b0de1b..e376d94 100644 --- a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte @@ -110,6 +110,7 @@ // mode, or per-image timer in continuous mode) opens an action // sheet that funnels into the same modals. Unauthenticated users // see neither — there's nothing for them to act on. + let shortcutsOpen = $state(false); let contextMenuOpen = $state(false); let contextMenuAnchor = $state<{ x: number; y: number }>({ x: 0, y: 0 }); let activePageId = $state(null); @@ -657,6 +658,15 @@ // Don't hijack keys while the user is typing in an input. const target = e.target as HTMLElement | null; if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return; + // `?` opens the keyboard-shortcut help overlay. While it's open, + // swallow every key here so paging can't happen behind it — the + // Sheet owns its own Escape/close. + if (e.key === '?') { + e.preventDefault(); + shortcutsOpen = true; + return; + } + if (shortcutsOpen) return; // Esc always exits fullscreen if active — applies in both // modes, including when the bars are hidden. Handled before // the per-mode switches so it doesn't get shadowed. @@ -1419,6 +1429,23 @@ + (shortcutsOpen = false)} + testid="reader-shortcuts" +> +
+
/ J
Next page
+
/ K
Previous page
+
Home
First page
+
End
Last page
+
/
Next / previous chapter (continuous mode)
+
Esc
Exit full-screen / close this overlay
+
?
Show this help
+
+
+