feat(reader): 配置四角阅读信息

This commit is contained in:
hectorqin committed 2026-09-20 17:36:59 +08:00
1 parent da1aa15174
commit d60d7e67f9
10 files changed
+185 -23

No files matched your search

+1 -1
View File
@@ -110,7 +110,7 @@ volumes:
### 界面与图标
阅读页以手机浏览器为优先:顶部显示返回、书名和章节,底部集中目录、朗读、界面、设置。
工具栏和半屏面板覆盖正文,显隐不改变正文区域尺寸;沉浸时只留底部书名、章内页码和全书进度。
工具栏和半屏面板覆盖正文,显隐不改变正文区域尺寸。界面设置可分别选择顶部左/右、底部左/右显示书名、章节名、章节进度、时间或不显示;默认顶部左侧章节名、底部左侧章内页码与全书百分比、底部右侧时间,按设备保存。
底栏上方保留一个日夜切换按钮,随工具栏一起收起。
图标使用 Lucide SVG,通过统一的 Icon 组件控制尺寸、线宽和无障碍标签,不再加载字体图标。
+1 -1
View File
@@ -543,7 +543,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
- 顶部:返回、书名、当前章节。
- 底部:上一章、本章页数滑杆、下一章;下一行是目录、朗读、界面、设置。
- 中间滑杆始终控制本章页数,不能变成全书进度。固定页章节只有一页时禁用滑杆。
- 工具栏隐藏后只保留底部书名、章内页码和全书百分比。日夜圆形快捷按钮随工具栏一起显隐。
- 顶部左/右、底部左/右阅读信息可在“界面”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。
- 正文宿主的上下边界不依赖工具栏状态。工具栏覆盖正文,显隐不得改变页码、滚动位置、正文尺寸。
- 四个面板使用统一底部半屏布局;界面放排版与主题,设置放翻页行为,朗读放语音设置和播放控制。
- TXT 默认字号为基准的 1.125 倍、行距 1.8,支持两字缩进与段间距;EPUB 默认保留作者排版。
+28
View File
@@ -29,7 +29,26 @@ export type ShelfDensity = 'compact' | 'cozy' | 'comfortable';
*/
export type ShelfSort = 'recent' | 'added' | 'title' | 'author';
export type ReadoutMode = 'none' | 'book' | 'chapter' | 'progress' | 'time';
export const READOUT_FIELDS = [
{ key: 'readoutTopLeft', label: '顶部左侧' },
{ key: 'readoutTopRight', label: '顶部右侧' },
{ key: 'readoutBottomLeft', label: '底部左侧' },
{ key: 'readoutBottomRight', label: '底部右侧' },
] as const;
export const READOUT_OPTIONS: Array<{ value: ReadoutMode; label: string }> = [
{ value: 'none', label: '不显示' },
{ value: 'book', label: '书名' },
{ value: 'chapter', label: '章节名' },
{ value: 'progress', label: '章节进度' },
{ value: 'time', label: '时间' },
];
export interface AppSettings extends ViewSettings {
readoutTopLeft: ReadoutMode;
readoutTopRight: ReadoutMode;
readoutBottomLeft: ReadoutMode;
readoutBottomRight: ReadoutMode;
/** UTF-8 by default; a reader can force another for an odd TXT file. */
txtEncoding: string;
/** Only meaningful for fixed-layout books. */
@@ -72,6 +91,10 @@ export interface AppSettings extends ViewSettings {
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
readoutTopLeft: 'chapter',
readoutTopRight: 'none',
readoutBottomLeft: 'progress',
readoutBottomRight: 'time',
mode: 'scroll',
fontScale: 1,
lineHeight: 'inherit',
@@ -113,6 +136,11 @@ export class SettingsStore {
try {
const parsed = JSON.parse(raw) as Partial<AppSettings> & { shelfSort?: string };
this.settings = { ...DEFAULT_APP_SETTINGS, ...parsed };
for (const { key } of READOUT_FIELDS) {
if (!READOUT_OPTIONS.some(option => option.value === this.settings[key])) {
this.settings[key] = DEFAULT_APP_SETTINGS[key];
}
}
/*
* Two stored values are folded forward, and both are the same repair.
*
+8 -7
View File
@@ -3660,29 +3660,30 @@ book-content {
svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; vertical-align: middle; }
.reader-screen { --reader-footer-height: calc(8.5rem + var(--safe-bottom)); }
.reader-screen .book-host {
inset-block-start: var(--safe-top);
inset-block-start: calc(1.8rem + var(--safe-top));
inset-block-end: calc(1.8rem + var(--safe-bottom) + var(--reader-page-trim, 0px));
}
.reading-indicator {
position: absolute;
inset-inline: 0;
bottom: 0;
height: calc(1.8rem + var(--safe-bottom));
padding: 0.35rem max(1.25rem, var(--safe-right)) var(--safe-bottom) max(1.25rem, var(--safe-left));
padding-inline: max(1.25rem, var(--safe-left)) max(1.25rem, var(--safe-right));
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
color: var(--reader-muted);
background: var(--reader-paper);
border-top: 1px solid color-mix(in srgb, var(--reader-line) 50%, transparent);
font-size: 0.72rem;
line-height: 1.2;
z-index: var(--reader-layer-readout);
pointer-events: none;
}
.indicator-chapter { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.indicator-progress { white-space: nowrap; font-variant-numeric: tabular-nums; }
.reading-indicator-top { top: 0; height: calc(1.8rem + var(--safe-top)); padding-top: calc(0.5rem + var(--safe-top)); }
.reading-indicator-bottom { bottom: 0; height: calc(1.8rem + var(--safe-bottom)); padding-top: 0.35rem; padding-bottom: var(--safe-bottom); }
.reading-indicator > span { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
.indicator-left { text-align: start; }
.indicator-right { text-align: end; }
.reading-indicator > span:empty { flex: 0; }
.reader-screen .topbar {
display: flex;
align-items: center;
+14 -9
View File
@@ -16,6 +16,8 @@
*/
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 { IconButton, SectionTitle } from './toolkit.tsx';
@@ -49,6 +51,10 @@ export interface SpeechBarState {
}
export interface ChromeState {
readoutTopLeft?: ReadoutMode;
readoutTopRight?: ReadoutMode;
readoutBottomLeft?: ReadoutMode;
readoutBottomRight?: ReadoutMode;
title: string;
author: string;
chromeVisible: boolean;
@@ -167,10 +173,7 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
<span className="status-dot" aria-hidden="true" /><span className="status-text">{state.statusText}</span>
</div>
<StageHost stage={stage} />
<div className="reading-indicator" aria-hidden="true">
<span className="indicator-chapter">{state.title || state.chapterLabel}</span>
<span className="indicator-progress">{currentPage}/{totalPages} · {percentOf(state.progress)}</span>
</div>
<ReaderIndicators state={state} />
<div className="reader-rail" role="toolbar" aria-label="阅读快捷操作">
<RailButton icon={state.theme === 'dark' ? 'sun' : 'moon'}
label={state.theme === 'dark' ? '日间' : '夜间'}
@@ -320,6 +323,13 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
return (
<>
{(state.settingsTab ?? 'behavior') === 'appearance' ? <>
<SectionTitle>阅读信息</SectionTitle>
{READOUT_FIELDS.map(({ key, label }) => (
<SelectRow key={key} label={label} options={READOUT_OPTIONS}
value={state[key] ?? DEFAULT_APP_SETTINGS[key]}
onChange={(value) => handlers.onSetting({ [key]: value as ReadoutMode })} />
))}
<SectionTitle>正文与主题</SectionTitle>
{state.layout !== 'fixed' ? <>
<SliderRow
label="字号"
@@ -803,9 +813,4 @@ function RailButton({
const LINE_HEIGHT_LADDER = ['inherit', '1.4', '1.6', '1.8', '2.1'];
/** The progress fraction as a percentage, clamped. */
function percentOf(fraction: number): string {
return `${Math.round(Math.min(1, Math.max(0, fraction)) * 100)}%`;
}
export type { SpeechEngineKind };
+41
View File
@@ -0,0 +1,41 @@
import { DEFAULT_APP_SETTINGS, READOUT_FIELDS, type ReadoutMode } from '../store/settings.ts';
import type { ChromeState } from './reader-chrome.tsx';
import { useEffect, useState } from './vendor/preact.ts';
/** Only this small subtree updates with the clock; the reading surface is untouched. */
export function ReaderIndicators({ state }: { state: ChromeState }) {
const modes = READOUT_FIELDS.map(({ key }) => state[key] ?? DEFAULT_APP_SETTINGS[key]);
const clockEnabled = modes.includes('time');
const [now, setNow] = useState(() => new Date());
useEffect(() => {
if (!clockEnabled) return;
let timer: ReturnType<typeof setTimeout>;
const refresh = () => {
clearTimeout(timer);
setNow(new Date());
timer = setTimeout(refresh, 60_000 - Date.now() % 60_000);
};
refresh();
document.addEventListener('visibilitychange', refresh);
return () => { clearTimeout(timer); document.removeEventListener('visibilitychange', refresh); };
}, [clockEnabled]);
const text = (mode: ReadoutMode): string => {
switch (mode) {
case 'book': return state.title;
case 'chapter': return state.chapterLabel;
case 'progress': return `第${Math.max(1, state.pageInChapter)}/${Math.max(1, state.chapterPages)}页 ${Math.round(Math.min(1, Math.max(0, state.progress)) * 100)}%`;
case 'time': return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
default: return '';
}
};
return <>{(['top', 'bottom'] as const).map((edge, index) => (
<div key={edge} className={`reading-indicator reading-indicator-${edge}`} aria-label={edge === 'top' ? '顶部阅读信息' : '底部阅读信息'}>
{(['left', 'right'] as const).map((side, offset) => {
const mode = modes[index * 2 + offset]!;
const value = text(mode);
return <span key={side} className={`indicator-${side}`} data-mode={mode} title={value}>{value}</span>;
})}
</div>
))}</>;
}
+13 -2
View File
@@ -9,7 +9,7 @@ import type { Platform } from '../core/platform.ts';
import { ReaderView, type Position, type ViewSettings } from './reader-view.ts';
import { makeAssetSigner } from '../net/asset-url.ts';
import { attachGestures } from './gestures.ts';
import type { AppSettings } from '../store/settings.ts';
import { READOUT_FIELDS, type AppSettings } from '../store/settings.ts';
import { createStagedDoc, isStagedKind, windowIndexOf } from '../formats/windowed.ts';
import type { BookDoc } from '../formats/types.ts';
import type { TocEntry } from '../api/types.ts';
@@ -1134,7 +1134,10 @@ export class ReaderScreen {
private async updateSetting(patch: Partial<AppSettings>): Promise<void> {
this.options.onSettingsChange(patch);
Object.assign(this.settings, patch);
this.view?.applySettings(this.viewSettings());
// Reading information changes only the chrome, never the book's pagination.
if (Object.keys(patch).some(key => !READOUT_FIELDS.some(field => field.key === key))) {
this.view?.applySettings(this.viewSettings());
}
this.patch({});
// Fixed-layout fit and direction changes are structural, so the current page
// has to be re-rendered rather than merely re-styled.
@@ -1558,6 +1561,10 @@ function emptyChromeSettings(): Partial<ChromeState> {
shelfSort: 'recent',
shelfShowAuthor: true,
shelfShowProgress: true,
readoutTopLeft: 'chapter',
readoutTopRight: 'none',
readoutBottomLeft: 'progress',
readoutBottomRight: 'time',
},
{ layout: 'reflowable', format: '', availability: { system: false, native: false, http: false, preferred: null }, engine: null, voices: [] },
);
@@ -1598,6 +1605,10 @@ function settingsView(
const activeEngine = context.engine ?? context.availability.preferred;
return {
mode: settings.mode,
readoutTopLeft: settings.readoutTopLeft,
readoutTopRight: settings.readoutTopRight,
readoutBottomLeft: settings.readoutBottomLeft,
readoutBottomRight: settings.readoutBottomRight,
fontScale: settings.fontScale,
lineHeight: settings.lineHeight,
theme: settings.theme,
+53
View File
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'preact/test-utils';
import { createElement as h, render } from '../src/ui/vendor/preact.ts';
import { ReaderIndicators } from '../src/ui/reader-indicators.tsx';
import type { ChromeState } from '../src/ui/reader-chrome.tsx';
import { SettingsStore } from '../src/store/settings.ts';
const container = document.createElement('div');
const state = {
title: '测试书名', chapterLabel: '第二章', pageInChapter: 3, chapterPages: 12, progress: 0.47,
} as ChromeState;
const texts = () => Array.from(container.querySelectorAll('span'), el => el.textContent);
afterEach(() => { act(() => render(null, container)); vi.useRealTimers(); });
describe('reading information', () => {
it('maps all four positions independently and allows hiding each one', () => {
act(() => render(h(ReaderIndicators, { state: { ...state,
readoutTopLeft: 'book', readoutTopRight: 'chapter',
readoutBottomLeft: 'progress', readoutBottomRight: 'none',
} }), container));
expect(texts()).toEqual(['测试书名', '第二章', '第3/12页 47%', '']);
act(() => render(h(ReaderIndicators, { state: { ...state,
readoutTopLeft: 'none', readoutTopRight: 'none',
readoutBottomLeft: 'none', readoutBottomRight: 'none',
} }), container));
expect(texts()).toEqual(['', '', '', '']);
expect(container.querySelectorAll('.reading-indicator')).toHaveLength(2);
});
it('refreshes time at the minute boundary and on resume, then cleans up', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 8, 20, 9, 5, 59));
act(() => render(h(ReaderIndicators, { state }), container));
expect(texts()[3]).toBe('09:05');
act(() => { vi.advanceTimersByTime(1000); });
expect(texts()[3]).toBe('09:06');
vi.setSystemTime(new Date(2026, 8, 20, 10, 15));
act(() => { document.dispatchEvent(new Event('visibilitychange')); });
expect(texts()[3]).toBe('10:15');
act(() => render(null, container));
expect(vi.getTimerCount()).toBe(0);
});
it('migrates older preferences and persists each position across store instances', async () => {
let saved = JSON.stringify({ theme: 'sepia', readoutTopRight: 'invalid' });
const kv = { get: async () => saved, set: async (_key: string, value: string) => { saved = value; }, remove: async () => {} };
const store = new SettingsStore(kv);
expect(await store.load()).toMatchObject({ theme: 'sepia', readoutTopLeft: 'chapter', readoutTopRight: 'none', readoutBottomLeft: 'progress', readoutBottomRight: 'time' });
await store.update({ readoutTopLeft: 'time', readoutTopRight: 'book', readoutBottomLeft: 'none', readoutBottomRight: 'chapter' });
expect(await new SettingsStore(kv).load()).toMatchObject({ readoutTopLeft: 'time', readoutTopRight: 'book', readoutBottomLeft: 'none', readoutBottomRight: 'chapter' });
});
});
+4 -3
View File
@@ -111,9 +111,10 @@ describe('the reading surface fills the screen', () => {
}
});
it('keeps the reading information in a single bottom strip', () => {
it('keeps reading information in fixed top and bottom strips', () => {
const box = rule('.reading-indicator');
expect(declaration(box, 'bottom')).toBe('0');
expect(declaration(rule('.reading-indicator-top'), 'top')).toBe('0');
expect(declaration(rule('.reading-indicator-bottom'), 'bottom')).toBe('0');
expect(declaration(box, 'display')).toBe('flex');
expect(declaration(box, 'pointer-events')).toBe('none');
});
@@ -141,7 +142,7 @@ describe('the reading surface fills the screen', () => {
it('uses the same reading geometry with chrome visible and hidden', () => {
expect(rule(".reader-screen[data-chrome='hidden'] .book-host")).toBe('');
const body = rule('.reader-screen .book-host');
expect(declaration(body, 'inset-block-start')).toBe('var(--safe-top)');
expect(declaration(body, 'inset-block-start')).toBe('calc(1.8rem + var(--safe-top))');
expect(declaration(body, 'inset-block-end')).toContain('var(--reader-page-trim');
expect(declaration(body, 'inset-block-end')).toContain('var(--safe-bottom)');
});
+22
View File
@@ -57,6 +57,28 @@ try {
assert.equal((await read()).y, after.y, `${label} close must preserve position`);
}
await cdp.clickText('.reader-actions button', '界面');
const beforeReadouts = await read();
for (const [label, mode] of [['顶部左侧','book'],['顶部右侧','chapter'],['底部左侧','time'],['底部右侧','progress']]) {
await cdp.run(`const el=document.querySelector('select[aria-label="${label}"]');el.value='${mode}';el.dispatchEvent(new Event('change',{bubbles:true}));`);
await cdp.sleep(100);
const next = await read();
for (const key of ['x','y','page','pages','top','height']) assert.equal(next[key], beforeReadouts[key], 'readout setting preserves '+key);
}
const modes = await cdp.evaluate('Array.from(document.querySelectorAll(".reading-indicator span"),el=>el.dataset.mode)');
assert.deepEqual(modes, ['book','chapter','time','progress']);
await cdp.screenshot('../docs/ui-review/mobile/readout-settings.png');
await cdp.click('.panel button[aria-label="关闭"]');
await cdp.tapMiddle();
await cdp.screenshot('../docs/ui-review/mobile/readout-custom.png');
await cdp.navigate(`${origin}/#/shelf`);
await cdp.navigate(`${origin}/#/book/${BOOK_ID}`);
await cdp.waitFor('Number(document.querySelector(".progress-scrubber")?.max) > 1');
assert.deepEqual(await cdp.evaluate('Array.from(document.querySelectorAll(".reading-indicator span"),el=>el.dataset.mode)'), modes, 'readout settings survive reopening');
await cdp.clickText('.reader-actions button', '界面');
for (const [label, mode] of [['顶部左侧','chapter'],['顶部右侧','none'],['底部左侧','progress'],['底部右侧','time']]) {
await cdp.run(`const el=document.querySelector('select[aria-label="${label}"]');el.value='${mode}';el.dispatchEvent(new Event('change',{bubbles:true}));`);
await cdp.sleep(100);
}
await cdp.clickText('.panel button', '浅绿');
await cdp.click('.panel button[aria-label="关闭"]');
await cdp.sleep(300);