mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
feat(reader): 配置四角阅读信息
This commit is contained in:
1 parent
da1aa15174
commit
d60d7e67f9
10 files changed
+185
-23
No files matched your search
@@ -110,7 +110,7 @@ volumes:
|
||||
### 界面与图标
|
||||
|
||||
阅读页以手机浏览器为优先:顶部显示返回、书名和章节,底部集中目录、朗读、界面、设置。
|
||||
工具栏和半屏面板覆盖正文,显隐不改变正文区域尺寸;沉浸时只留底部书名、章内页码和全书进度。
|
||||
工具栏和半屏面板覆盖正文,显隐不改变正文区域尺寸。界面设置可分别选择顶部左/右、底部左/右显示书名、章节名、章节进度、时间或不显示;默认顶部左侧章节名、底部左侧章内页码与全书百分比、底部右侧时间,按设备保存。
|
||||
底栏上方保留一个日夜切换按钮,随工具栏一起收起。
|
||||
|
||||
图标使用 Lucide SVG,通过统一的 Icon 组件控制尺寸、线宽和无障碍标签,不再加载字体图标。
|
||||
|
||||
+1
-1
@@ -543,7 +543,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
- 顶部:返回、书名、当前章节。
|
||||
- 底部:上一章、本章页数滑杆、下一章;下一行是目录、朗读、界面、设置。
|
||||
- 中间滑杆始终控制本章页数,不能变成全书进度。固定页章节只有一页时禁用滑杆。
|
||||
- 工具栏隐藏后只保留底部书名、章内页码和全书百分比。日夜圆形快捷按钮随工具栏一起显隐。
|
||||
- 顶部左/右、底部左/右阅读信息可在“界面”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。
|
||||
- 正文宿主的上下边界不依赖工具栏状态。工具栏覆盖正文,显隐不得改变页码、滚动位置、正文尺寸。
|
||||
- 四个面板使用统一底部半屏布局;界面放排版与主题,设置放翻页行为,朗读放语音设置和播放控制。
|
||||
- TXT 默认字号为基准的 1.125 倍、行距 1.8,支持两字缩进与段间距;EPUB 默认保留作者排版。
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
))}</>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user