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) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.99.0"
|
||||
version = "0.100.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.99.0"
|
||||
version = "0.100.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
61
frontend/e2e/reader-shortcuts.spec.ts
Normal file
61
frontend/e2e/reader-shortcuts.spec.ts
Normal file
@@ -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: '<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1200"/>'
|
||||
});
|
||||
}
|
||||
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();
|
||||
});
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.99.0",
|
||||
"version": "0.100.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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<string | null>(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 @@
|
||||
</ul>
|
||||
</Sheet>
|
||||
|
||||
<Sheet
|
||||
open={shortcutsOpen}
|
||||
title="Keyboard shortcuts"
|
||||
onClose={() => (shortcutsOpen = false)}
|
||||
testid="reader-shortcuts"
|
||||
>
|
||||
<dl class="shortcut-list">
|
||||
<div class="shortcut"><dt><kbd>→</kbd> / <kbd>J</kbd></dt><dd>Next page</dd></div>
|
||||
<div class="shortcut"><dt><kbd>←</kbd> / <kbd>K</kbd></dt><dd>Previous page</dd></div>
|
||||
<div class="shortcut"><dt><kbd>Home</kbd></dt><dd>First page</dd></div>
|
||||
<div class="shortcut"><dt><kbd>End</kbd></dt><dd>Last page</dd></div>
|
||||
<div class="shortcut"><dt><kbd>→</kbd> / <kbd>←</kbd></dt><dd>Next / previous chapter (continuous mode)</dd></div>
|
||||
<div class="shortcut"><dt><kbd>Esc</kbd></dt><dd>Exit full-screen / close this overlay</dd></div>
|
||||
<div class="shortcut"><dt><kbd>?</kbd></dt><dd>Show this help</dd></div>
|
||||
</dl>
|
||||
</Sheet>
|
||||
|
||||
<Sheet
|
||||
open={settingsOpen}
|
||||
title="Reader settings"
|
||||
@@ -2080,6 +2107,39 @@
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.shortcut-list {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.shortcut dt {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shortcut dd {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.shortcut kbd {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--font-xs);
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.chapter-jump-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user