feat(web): adapt collections and reading controls for desktop

This commit is contained in:
hectorqin committed 2026-09-21 17:01:19 +08:00
1 parent 4a1aa7a4d0
commit 1663773f4d
7 files changed
+233 -40

No files matched your search

+6
View File
@@ -14,6 +14,12 @@
浏览器脚本使用可控 API fixture;没有上传实际文件,也未进行 Android/iOS 真机测试。
电脑端适配(2026-09-21):从 1024px 起,书架导航与搜索并排,书架、书库和文件管理共用最大 80rem 内容宽度与两侧留白。文件操作收至标题右侧,列表采用文件名、元数据、菜单分列布局。书架设置改为右侧 26rem 面板,支持 Esc 关闭并恢复触发按钮焦点;缩窄窗口时恢复底部 sheet。书源及通用插件页面使用紧凑左对齐 tab,标题与正文保持统一宽度。
桌面验证:生产 Web 在 Chromium 中覆盖 1024×768、1366×768、1920×1080;检查书架、书库、文件列表、设置侧栏、键盘焦点、暗色主题和打开面板时跨断点缩放;同时保留 320/390px 手机回归及短视口吸顶断言。34 项书架/书库组件测试通过;真实 HTTP 服务、SQLite、Node 插件的订阅、隔离、筛选搜索、入架、阅读和换源端到端通过。桌面截图为 `collections-*-desktop-*.png`,插件页面截图为 `plugin-config-desktop-*.png`。尚未验证 Firefox、Safari。
阅读页桌面适配:底部工具栏改为最大 68rem 的居中横排,目录使用左侧面板,界面、朗读和设置使用右侧面板。面板支持 Esc 关闭及焦点恢复,聚焦不触发页面滚动。`desktop-reader.mjs` 验证三种桌面尺寸下四个面板、工具栏显隐、正文尺寸/页码/滚动位置不变,以及 1024×420 短窗口的滚轮操作。6 项阅读工具栏组件测试及移动端 `reader-regression.mjs` 全部通过,后者覆盖正文翻页、阅读信息、主题、插图、漫画、PDF 和 320px 面板。截图为 `desktop-reader-*.png`。
书架设置吸顶修复:移除滚动区域顶部内边距,并取消首个分组标题的负外边距,避免标题吸顶时露出下方控件。Web 构建及浏览器检查通过;在 320/390/1280px 宽、568px 高的视口实际滚动面板,验证吸顶标题与面板头部无间隙,顶部命中区域始终由标题覆盖。截图为 `collections-shelf-settings-sticky-*.png`。
```powershell
+45 -2
View File
@@ -3898,8 +3898,51 @@ section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; paddin
.shelf-settings .panel-header > .icon-button { flex: none; margin-inline-start: auto; width: 44px; height: 44px; }
.shelf-settings .panel-body { padding-top: 0; }
.shelf-settings .section-title:first-child { margin-top: 0; }
@media (min-width: 72rem) {
.collection-header, .library-browse-body { width: 100%; max-width: 68rem; margin-inline: auto; }
.shelf-discovery { display: contents; }
/* Desktop keeps navigation compact and settings beside the collection. */
@media (min-width: 64rem) {
.shelf { --shelf-measure: 80rem; }
.shelf-head, .shelf-discovery, .shelf-section { width: calc(100% - 3rem); }
.shelf-head { padding-top: 2rem; padding-bottom: 1.25rem; }
.shelf-discovery { display: flex; align-items: center; gap: 1.5rem; max-width: var(--shelf-measure); margin: 0 auto 1rem; padding-inline: .9rem; }
.shelf-discovery .shelf-links { flex: none; margin: 0; padding: 0; }
.shelf-discovery .shelf-search { flex: 1; max-width: 36rem; margin: 0 0 0 auto; }
.collection-link.button { flex: none; min-width: 9rem; min-height: 44px; }
.collection-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .5rem 2rem; padding-top: 2rem; }
.collection-header .collection-links { margin-top: 0; }
.collection-header .manager-crumbs { grid-column: 1 / -1; }
.collection-header, .library-browse-body, .library-files-screen .manager-body { width: calc(100% - 3rem); max-width: 80rem; margin-inline: auto; }
.library-files-screen .manager-row { grid-template-columns: 24px minmax(0, 1fr) minmax(15rem, 22rem) 44px; min-height: 64px; padding-block: .5rem; }
.library-files-screen .manager-check { grid-row: 1; }
.library-files-screen .manager-meta { grid-column: 3; grid-row: 1; justify-content: flex-end; padding-left: 0; }
.library-files-screen .manager-more { grid-column: 4; grid-row: 1; }
.shelf-settings.panel { inset: 1rem 1rem auto auto; width: 26rem; height: min(42rem, calc(100dvh - 2rem)); max-height: calc(100dvh - 2rem); border: 1px solid var(--reader-line); border-radius: 1rem; box-shadow: 0 12px 48px #0003; overflow: hidden; animation-name: shelf-settings-in; }
.shelf-settings .switch-field { grid-template-columns: minmax(0, 1fr) auto; }
.sources-header { padding-inline: max(1rem, calc((100% - 64rem) / 2)); padding-block: 1.5rem; }
.sources-hub .sources-tabs [role=tab], .extension-tabs button { flex: 0 1 auto; min-width: 9rem; padding-inline: 1.5rem; }
.sources-hub .sources-body { width: 100%; max-width: 66rem; margin-inline: auto; }
.reader-screen { --reader-footer-height: 5.5rem; }
.reader-screen .topbar { padding-inline: max(2rem, calc((100% - 68rem) / 2)); }
.reader-screen .footer { display: flex; align-items: center; gap: 2rem; inset-inline: 2rem; bottom: 1rem; width: calc(100% - 4rem); max-width: 68rem; height: 4.5rem; margin-inline: auto; padding: .5rem 1rem; border: 1px solid var(--reader-line); border-radius: 1rem; }
.reader-screen .progress-row { flex: 1; min-width: 0; }
.reader-screen .reader-actions { display: flex; gap: .25rem; }
.reader-screen .reader-actions .top-button { flex-direction: row; min-width: 4.75rem; min-height: 44px; padding: .5rem; }
.reader-screen .reader-actions .icon { font-size: 1.1rem; }
.reader-screen .reader-rail { right: max(2rem, calc((100% - 68rem) / 2)); }
.reader-screen .panel { inset: 1rem 1rem 1rem auto; width: 26rem; height: calc(100% - 2rem); max-height: none; border: 1px solid var(--reader-line); border-radius: 1rem; overflow: hidden; animation-name: shelf-settings-in; }
.reader-screen .panel[data-placement='start'] { inset-inline: 1rem auto; width: 23rem; animation-name: reader-contents-in; }
.reader-screen .panel-grip { display: none; }
.reader-screen .panel-header { flex: none; padding: .75rem 1.25rem; }
.reader-screen .panel-body { padding-top: 0; }
.reader-screen .section-title:first-child { margin-top: 0; }
}
@keyframes reader-contents-in {
from { opacity: 0; transform: translateX(-1rem); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes shelf-settings-in {
from { opacity: 0; transform: translateX(1rem); }
to { opacity: 1; transform: translateX(0); }
}
@media (max-width: 600px) {
.source-entry-actions { flex-direction: column; gap: 0; }
+13 -4
View File
@@ -20,7 +20,7 @@ import type { AppSettings } from '../store/settings.ts';
import { DEFAULT_APP_SETTINGS, READOUT_FIELDS, READOUT_OPTIONS, type ReadoutMode } from '../store/settings.ts';
import { ReaderIndicators } from './reader-indicators.tsx';
import type { SpeechEngineKind } from '../render/speech.ts';
import { type ComponentChildren, type JSX } from './vendor/preact.ts';
import { type ComponentChildren, type JSX, useLayoutEffect, useRef } from './vendor/preact.ts';
import { Button, IconButton, IconTextButton, SectionTitle } from './toolkit.tsx';
import { Icon, type IconName } from './icon.tsx';
@@ -216,7 +216,7 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
</div>
</div>
{state.tocOpen ? (
<Panel title="目录" subtitle={`${state.toc.length} 章`} onClose={handlers.toggleToc}>
<Panel title="目录" placement="start" subtitle={`${state.toc.length} 章`} onClose={handlers.toggleToc}>
{state.canRefresh ? (
<IconTextButton
icon="refresh"
@@ -307,7 +307,7 @@ function StageHost({ stage }: { stage: HTMLElement }): JSX.Element {
}
/**
* A half-screen sheet over the reading area.
* A bottom sheet on phones and a side panel on desktop.
*
* Bottom sheet rather than a full-screen page, and the comment on `.panel` in the
* stylesheet is where the reasoning lives. What belongs here is the *interaction*:
@@ -327,22 +327,31 @@ function StageHost({ stage }: { stage: HTMLElement }): JSX.Element {
*/
function Panel({
title,
placement = 'end',
subtitle,
onClose,
children,
}: {
title: string;
placement?: 'start' | 'end';
subtitle?: string;
onClose(): void;
children: ComponentChildren;
}): JSX.Element {
const panel = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const trigger = document.activeElement;
panel.current?.querySelector<HTMLButtonElement>('.panel-header button')?.focus({ preventScroll: true });
return () => { if (trigger instanceof HTMLElement && trigger.isConnected) trigger.focus({ preventScroll: true }); };
}, []);
return (
<>
{/* `onClick` on the scrim rather than a document listener: the scrim *is* the
dismiss control, so it should be a node the reader can hit, with the same
processing as any other tap on the page. */}
<div className="scrim" onClick={onClose} role="presentation" />
<div className="panel" role="group" aria-label={title}>
<div className="panel" data-placement={placement} ref={panel} role="group" aria-label={title}
onKeyDown={(event) => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } }}>
<div className="panel-grip" aria-hidden="true" />
<div className="panel-header">
<h2>{title}</h2>
+32 -30
View File
@@ -845,36 +845,38 @@ export class ShelfScreen {
</div>
</header>
<nav className="collection-links shelf-links" aria-label="发现书籍">
<button type="button" className="button collection-link" aria-label="书库" onClick={() => this.options.onOpenLibrary(state.libraryPath, state.libraryPage)}><Icon name="library" /><span>书库</span><Icon name="chevron-right" /></button>
{this.options.onOpenSources && <button type="button" className="button collection-link" onClick={this.options.onOpenSources}><Icon name="search" /><span>书源</span><Icon name="chevron-right" /></button>}
</nav>
<div className="shelf-search" role="search">
<Icon name="search" class="search-glyph" />
<input
type="search"
placeholder="搜索书名、作者、系列"
aria-label="搜索书库"
enterKeyHint="search"
value={state.search}
onInput={(event) => this.onSearchInput((event.currentTarget as HTMLInputElement).value)}
onKeyDown={(event) => {
// `search` inputs fire a non-standard `search` event on clear, but
// only in some browsers; Enter is the one that is reliable
// everywhere and it also means "stop waiting for the debounce".
if (event.key === 'Enter') {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = null;
this.patch({ query: state.search.trim() });
void this.refresh();
}
}}
/>
{state.search.length > 0 ? (
<button type="button" className="search-clear" aria-label="清除搜索" onClick={() => this.clearSearch()}>
<Icon name="close" />
</button>
) : null}
<div className="shelf-discovery">
<nav className="collection-links shelf-links" aria-label="发现书籍">
<button type="button" className="button collection-link" aria-label="书库" onClick={() => this.options.onOpenLibrary(state.libraryPath, state.libraryPage)}><Icon name="library" /><span>书库</span><Icon name="chevron-right" /></button>
{this.options.onOpenSources && <button type="button" className="button collection-link" onClick={this.options.onOpenSources}><Icon name="search" /><span>书源</span><Icon name="chevron-right" /></button>}
</nav>
<div className="shelf-search" role="search">
<Icon name="search" class="search-glyph" />
<input
type="search"
placeholder="搜索书名、作者、系列"
aria-label="搜索书库"
enterKeyHint="search"
value={state.search}
onInput={(event) => this.onSearchInput((event.currentTarget as HTMLInputElement).value)}
onKeyDown={(event) => {
// `search` inputs fire a non-standard `search` event on clear, but
// only in some browsers; Enter is the one that is reliable
// everywhere and it also means "stop waiting for the debounce".
if (event.key === 'Enter') {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = null;
this.patch({ query: state.search.trim() });
void this.refresh();
}
}}
/>
{state.search.length > 0 ? (
<button type="button" className="search-clear" aria-label="清除搜索" onClick={() => this.clearSearch()}>
<Icon name="close" />
</button>
) : null}
</div>
</div>
<section className="shelf-section" aria-label={hasQuery ? '搜索结果' : '全部书籍'}>
+10 -2
View File
@@ -23,7 +23,7 @@
import type { AppSettings } from '../store/settings.ts';
import { IconButton, SectionTitle, Segmented, SwitchRow } from './toolkit.tsx';
import type { JSX } from './vendor/preact.ts';
import { type JSX, useLayoutEffect, useRef } from './vendor/preact.ts';
export const DENSITY_LABELS: Record<AppSettings['shelfDensity'], string> = {
compact: '紧凑',
@@ -58,8 +58,16 @@ export function ShelfSettingsPanel({
onPatch,
onClose,
}: ShelfSettingsOptions & { open: boolean }): JSX.Element {
const panel = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!open) return;
const trigger = document.activeElement;
panel.current?.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
return () => { if (trigger instanceof HTMLElement && trigger.isConnected) trigger.focus({ preventScroll: true }); };
}, [open]);
return (
<div className="panel shelf-settings" hidden={!open}>
<div className="panel shelf-settings" ref={panel} role="dialog" aria-label="书架设置" hidden={!open}
onKeyDown={(event) => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } }}>
<div className="panel-header">
<h2>书架设置</h2>
<IconButton label="关闭" icon="close" onClick={onClose} />
+46 -2
View File
@@ -65,9 +65,19 @@ try {
await page.setViewportSize({ width, height: 844 });
await page.locator('.shelf-head button[aria-label^=书架设置]').click();
const close = page.locator('.shelf-settings button[aria-label="关闭"]');
await page.locator('.shelf-settings .panel-body').evaluate(el => { el.scrollTop = 0; });
await shot('shelf-settings-' + width);
assert.equal(await close.evaluate(el => { const r = el.getBoundingClientRect(), p = el.closest('.panel-header').getBoundingClientRect(); return p.right - r.right <= 20 && r.top >= p.top && r.bottom <= p.bottom; }), true);
await page.setViewportSize({ width, height: 568 });
if (width >= 1280) {
const desktopPanel = await page.locator('.shelf-settings').evaluate(el => {
const r = el.getBoundingClientRect();
return { rightGap: innerWidth - r.right, width: r.width, top: r.top, bottom: innerHeight - r.bottom };
});
assert.ok(desktopPanel.width >= 400 && desktopPanel.width <= 450, 'desktop settings should be a compact side panel');
assert.ok(desktopPanel.rightGap <= 20 && desktopPanel.top >= 10 && desktopPanel.bottom >= 10, 'desktop settings should sit inside the viewport');
assert.equal(await page.locator('.shelf-discovery').evaluate(el => getComputedStyle(el).display), 'flex');
}
await page.setViewportSize({ width, height: width >= 1024 ? 420 : 568 });
const body = page.locator('.shelf-settings .panel-body');
await body.evaluate(el => { el.scrollTop = 45; });
await shot('shelf-settings-sticky-' + width);
@@ -95,11 +105,45 @@ try {
const chooser = page.waitForEvent('filechooser'); await button('上传书籍').click(); await chooser;
await page.goto(base + '/#/library/files/folder'); await page.reload(); await button('返回').click(); await page.waitForURL('**/#/library/files');
await button('返回').click(); await page.waitForURL('**/#/library');
// Real desktop sizes, including a narrow window and a full-HD monitor.
for (const [width, height] of [[1024, 768], [1366, 768], [1920, 1080]]) {
await page.setViewportSize({ width, height });
await page.goto(base + '/#/shelf'); await page.locator('.book-grid .book-card').first().waitFor();
await shot('shelf-desktop-' + width);
const trigger = page.locator('.shelf-head button[aria-label^=书架设置]');
await trigger.click();
await page.locator('.shelf-settings .panel-body').evaluate(el => { el.scrollTop = 0; });
await shot('settings-desktop-' + width);
if (width === 1366) {
await page.evaluate(() => document.documentElement.dataset.theme = 'dark');
await shot('settings-desktop-dark');
await page.evaluate(() => document.documentElement.dataset.theme = 'light');
await page.setViewportSize({ width: 390, height: 844 });
await shot('settings-resized-mobile');
assert.equal(await page.locator('.shelf-settings').evaluate(el => {
const r = el.getBoundingClientRect();
return Math.abs(r.left) < 1 && Math.abs(r.right - innerWidth) < 1 && Math.abs(r.bottom - innerHeight) < 1;
}), true, 'resizing an open desktop panel restores the mobile bottom sheet');
await page.setViewportSize({ width, height });
}
await page.locator('.shelf-settings button[aria-label="关闭"]').press('Escape');
assert.equal(await page.locator('.shelf-settings').isVisible(), false);
assert.equal(await trigger.evaluate(el => document.activeElement === el), true, 'closing settings restores keyboard focus');
await page.goto(base + '/#/library'); await page.locator('.library-browse-grid .book-card').first().waitFor();
await shot('library-desktop-' + width);
await page.goto(base + '/#/library/files'); await page.locator('.manager-row').first().waitFor();
assert.equal(await page.locator('.manager-row').first().evaluate(el => {
const name = el.querySelector('.manager-name').getBoundingClientRect();
const meta = el.querySelector('.manager-meta').getBoundingClientRect();
return meta.left >= name.right && meta.top < name.bottom;
}), true, 'desktop file metadata occupies a separate column');
await shot('files-desktop-' + width);
}
// A member cannot reach file management even through a direct link.
await page.route('**/api/v1/auth/me', async route => { const response = await route.fetch(); const json = await response.json(); json.user.role = 'member'; await route.fulfill({ json }); });
await page.goto(base + '/#/library/files'); await page.reload(); await page.locator('.library-browse-screen').waitFor();
assert.equal(await button('文件管理').count(), 0); assert.equal(await button('上传书籍').count(), 0);
assert.equal(await page.locator('.manager-upload-input').count(), 0);
assert.deepEqual(errors, []);
console.log('PASS collection UI: browsing without upload/settings, admin file actions, member guard, hierarchical back, sheet close alignment, long filenames, 320/390/1280px');
console.log('PASS collection UI: permissions, navigation, sticky settings, desktop sidebar and keyboard focus, file columns, dark theme, responsive resize; 320/390/1024/1280/1366/1920px');
} finally { await browser.close(); await server.close(); }
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
import { chromium } from 'playwright';
import { BOOK_ID, createReviewServer } from './server.mjs';
const server = createReviewServer({ port: 0 });
await server.listen();
const base = 'http://127.0.0.1:' + server.server.address().port;
const browser = await chromium.launch({ headless: true, ...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}) });
try {
await mkdir('docs/ui-review', { recursive: true });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(base);
await page.locator('input[autocomplete=username]').fill('review');
await page.locator('input[type=password]').fill('password12');
await page.locator('button[type=submit]').click();
await page.locator('.shelf-screen').waitFor();
const surface = () => page.locator('book-content').evaluate(el => {
const rect = el.getBoundingClientRect();
const flow = el.shadowRoot?.querySelector('.book-flow') ?? el.querySelector('.book-flow');
const scroller = el.dataset.paginated === 'true' ? flow : el;
return { width: rect.width, height: rect.height, top: rect.top, x: scroller.scrollLeft, y: scroller.scrollTop,
page: document.querySelector('.progress-scrubber').value };
});
async function shot(name) {
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, name + ' horizontal overflow');
await page.screenshot({ path: 'docs/ui-review/desktop-reader-' + name + '.png', animations: 'disabled' });
}
for (const [width, height] of [[1024, 768], [1366, 768], [1920, 1080]]) {
await page.setViewportSize({ width, height });
await page.goto(base + '/#/shelf');
await page.goto(base + '/#/book/' + BOOK_ID);
await page.waitForFunction(() => Number(document.querySelector('.progress-scrubber')?.max) > 1);
await shot('toolbar-' + width);
assert.ok(await page.locator('.footer').evaluate(el => el.getBoundingClientRect().width <= 1088), 'desktop controls have a bounded width');
for (const label of ['目录', '界面', '设置', '朗读']) {
const before = await surface();
const trigger = page.locator('.reader-actions').getByRole('button', { name: label, exact: true });
await trigger.click();
const panel = page.locator('.reader-screen .panel');
await panel.waitFor();
await shot(label + '-' + width);
const bounds = await panel.evaluate(el => {
const r = el.getBoundingClientRect();
return { left: r.left, rightGap: innerWidth - r.right, width: r.width, top: r.top, bottomGap: innerHeight - r.bottom,
overflow: el.scrollWidth > el.clientWidth };
});
assert.ok(bounds.width <= 420 && bounds.width >= 360, 'bounded panel width');
assert.ok(bounds.top >= 10 && bounds.bottomGap >= 10 && !bounds.overflow, 'panel fits viewport');
assert.ok((label === '目录' ? bounds.left : bounds.rightGap) <= 20, 'contents on left, settings on right');
assert.deepEqual(await surface(), before, 'opening ' + label + ' preserves the reading position and geometry');
await panel.getByRole('button', { name: '关闭', exact: true }).press('Escape');
await panel.waitFor({ state: 'detached' });
assert.equal(await trigger.evaluate(el => document.activeElement === el), true, 'Escape returns focus to the opener');
assert.deepEqual(await surface(), before, 'closing ' + label + ' preserves the reading position');
}
const before = await surface();
await page.mouse.click(width / 2, height / 2);
await page.locator('.reader-screen[data-chrome=hidden]').waitFor();
await shot('immersive-' + width);
assert.deepEqual(await surface(), before, 'hiding chrome preserves the page');
await page.mouse.click(width / 2, height / 2);
await page.locator('.reader-screen[data-chrome=visible]').waitFor();
}
await page.setViewportSize({ width: 1024, height: 420 });
await page.locator('.reader-actions').getByRole('button', { name: '界面', exact: true }).click();
const body = page.locator('.panel-body');
await body.hover(); await page.mouse.wheel(0, 300);
await page.waitForFunction(() => document.querySelector('.panel-body')?.scrollTop > 0);
await shot('short-window-scroll');
await page.locator('.panel button[aria-label="关闭"]').click();
assert.deepEqual(errors, []);
console.log('PASS desktop reader: 1024/1366/1920px, four panels, Escape/focus, immersive mode, stable page geometry and short-window scrolling');
} finally {
await browser.close();
await server.close();
}