diff --git a/docs/ui.md b/docs/ui.md index 2599a74..85e0d6c 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -547,13 +547,14 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「 - 顶部左/右、底部左/右阅读信息可在“界面”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。 - 正文宿主的上下边界不依赖工具栏状态。工具栏覆盖正文,显隐不得改变页码、滚动位置、正文尺寸。 - 四个面板使用统一底部半屏布局;界面放排版与主题,设置放翻页行为,朗读放语音设置和播放控制。 +- “界面”面板按使用频率排序:字号、行距、主题(用各主题真实纸色与墨色绘制的色块)、亮度排在首屏;“更多排版”(页边距、对齐、字体,以及 TXT 的缩进、段间距、编码)和“页眉页脚”(四个阅读信息)收进默认折叠的区块。漫画不显示文字排版,只保留主题、亮度和“页眉页脚”。 - TXT 默认字号为基准的 1.125 倍、行距 1.8,支持两字缩进与段间距;EPUB 默认保留作者排版。 - 白、米黄、浅绿、夜间四个主题。漫画隐藏文字排版选项。 ### 3.11 朗读 朗读控制集中在朗读面板。上一句、播放/暂停、下一句、停止均有独立按钮;关闭面板不停止播放,再从底部“朗读”进入可暂停或停止。 -“从当前位置朗读”使用当前可见正文或选区作为起点。不可用的语音设置根据引擎能力隐藏。 +“从当前位置朗读”使用当前可见正文或选区作为起点,是面板里的第一项和唯一主按钮。其后依次是语速(滑杆加 0.8/1.0/1.2/1.5/2.0 常用档)、语音、章节播完;音调与音量收进默认折叠的“更多”。不可用的语音设置根据引擎能力隐藏。 ## 4. 文案 diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index eabf098..3cd9841 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -28,7 +28,8 @@ * Host shell: the viewer, not the book. Safe to style freely. * ------------------------------------------------------------------ */ -:root { +:root, +[data-theme='light'] { --reader-ink: #1a1a1a; --reader-paper: #fbfaf7; --reader-surface: #ffffff; @@ -767,6 +768,8 @@ book-content { .chip:focus-visible, .switch:focus-visible, .segmented button:focus-visible, +.theme-swatch:focus-visible .theme-swatch-chip, +.panel-fold > summary:focus-visible, .toc-list li button:focus-visible, .manager-row:focus-visible, .manager-crumb:focus-visible, @@ -2725,6 +2728,114 @@ book-content { display: none; } +/* ---- settings sheet: theme swatches and folded rows ---- */ + +/* Each chip is drawn in its own theme's paper and ink: the reader sees the choice + * instead of decoding a name. Selection is a thicker accent ring *and* a bolder + * label, so it never depends on colour alone. */ +.theme-swatches { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-2); +} + +.theme-swatch { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + min-height: 44px; + padding: 0; + border: 0; + background: none; + color: var(--reader-ink); + font: inherit; + font-size: var(--text-label); + cursor: pointer; +} + +.theme-swatch-chip { + display: flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + border: 1px solid var(--reader-line); + border-radius: 50%; + background: var(--reader-paper); + color: var(--reader-ink); + font-size: var(--text-title); + font-weight: 600; + transition: border-color var(--motion-duration) var(--motion-ease); +} + +.theme-swatch[aria-pressed='true'] .theme-swatch-chip { + border: 2px solid var(--reader-accent); +} + +.theme-swatch[aria-pressed='true'] .theme-swatch-label { + font-weight: 600; +} + +/* Rows that are set once and left alone sit behind a native
. */ +.panel-fold { + margin-top: var(--space-2); + border-top: 1px solid color-mix(in srgb, var(--reader-line) 60%, transparent); +} + +.panel-fold > summary { + display: flex; + align-items: center; + gap: var(--space-3); + min-height: 44px; + cursor: pointer; + list-style: none; +} + +.panel-fold > summary::-webkit-details-marker { + display: none; +} + +.panel-fold > summary::after { + content: ''; + flex-shrink: 0; + width: 0.5rem; + height: 0.5rem; + margin-inline-start: auto; + border-right: 2px solid var(--reader-muted); + border-bottom: 2px solid var(--reader-muted); + transform: rotate(45deg); + transition: transform var(--motion-duration) var(--motion-ease); +} + +.panel-fold[open] > summary::after { + transform: rotate(225deg); +} + +.panel-fold-title { + font-size: var(--text-body); + font-weight: 600; +} + +.panel-fold-hint { + font-size: var(--text-meta); + color: var(--reader-muted); +} + +.panel-fold-body { + padding-bottom: var(--space-2); +} + +/* The one action the read-aloud sheet exists for: full width, first, and tall enough to hit. */ +.speech-start { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 48px; + margin-bottom: var(--space-3); +} + /* ---- status ---- */ /* ---- misc ---- */ diff --git a/web/src/ui/reader-chrome.tsx b/web/src/ui/reader-chrome.tsx index 3b444fa..7977701 100644 --- a/web/src/ui/reader-chrome.tsx +++ b/web/src/ui/reader-chrome.tsx @@ -414,13 +414,9 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom return ( <> {(state.settingsTab ?? 'behavior') === 'appearance' ? <> - 阅读信息 - {READOUT_FIELDS.map(({ key, label }) => ( - handlers.onSetting({ [key]: value as ReadoutMode })} /> - ))} - 正文与主题 + {/* Most-used first. Size, spacing, theme and brightness are what a reader opens + this sheet to change, so they fill its first screen; the rows that are set once + and left alone are folded underneath instead of pushing these below the fold. */} {state.layout !== 'fixed' ? <> handlers.onSetting({ lineHeight: value })} /> + : null} + handlers.onSetting({ theme: value as AppSettings['theme'] })} + /> `${Math.round(value * 100)}%`} + onChange={(value) => handlers.onSetting({ brightness: value })} + /> + + {/* Plain text is the one format with no typography of its own, so its indent, gap + and encoding rows live with the other typography rows. They are absent for every + other format, where the answer to all three is "whatever the book said". */} + {state.layout !== 'fixed' || state.showTxtRows ? ( + + {state.layout !== 'fixed' ? <> + handlers.onSetting({ fontFamily: value })} /> - : null} - handlers.onSetting({ theme: value as AppSettings['theme'] })} - /> - `${Math.round(value * 100)}%`} - onChange={(value) => handlers.onSetting({ brightness: value })} - /> - - {/* Plain text is the one format with no typography of its own, so these are - the rows that give it some. They sit with 排版 rather than at the end, - because they are the *same kind of setting* — how the text looks — and a - reader looking for the indent should not have to scroll past the - read-aloud controls to find it. They are absent for every other format, - where the answer to all three is "whatever the book said". */} - {state.showTxtRows ? ( + : null} + {state.showTxtRows ? ( <> 正文 handlers.onSetting({ txtEncoding: value })} /> + ) : null} + ) : null} - + + {READOUT_FIELDS.map(({ key, label }) => ( + handlers.onSetting({ [key]: value as ReadoutMode })} /> + ))} + : <> {state.layout !== 'fixed' ? ); } + const RATE_PRESETS = [0.8, 1, 1.2, 1.5, 2]; return ( <> + {/* The thing a reader opens this sheet to do comes first; the tuning follows. */} + {/* The engine picker only appears when there is a choice to make. One engine is not a setting. */} {state.showEngineRow ? ( @@ -641,25 +651,11 @@ function SpeechSettings({ state, handlers }: { state: ChromeState; handlers: Chr format={(value) => `${value.toFixed(1)}×`} onChange={(value) => handlers.onSpeechSetting({ ttsRate: value })} /> - {state.showPitchRow ? ( - value.toFixed(1)} - onChange={(value) => handlers.onSpeechSetting({ ttsPitch: value })} - /> - ) : null} - `${Math.round(value * 100)}%`} - onChange={(value) => handlers.onSpeechSetting({ ttsVolume: value })} + ({ value: String(value), label: `${value.toFixed(1)}×` }))} + value={String(RATE_PRESETS.find((value) => Math.abs(value - state.ttsRate) < 0.05) ?? '')} + onChange={(value) => handlers.onSpeechSetting({ ttsRate: Number(value) })} /> {state.showVoiceRow ? ( @@ -673,9 +669,28 @@ function SpeechSettings({ state, handlers }: { state: ChromeState; handlers: Chr value={state.ttsAutoAdvance ? 'auto' : 'stop'} onChange={(value) => handlers.onSpeechSetting({ ttsAutoAdvance: value === 'auto' })} /> - + + {state.showPitchRow ? ( + value.toFixed(1)} + onChange={(value) => handlers.onSpeechSetting({ ttsPitch: value })} + /> + ) : null} + `${Math.round(value * 100)}%`} + onChange={(value) => handlers.onSpeechSetting({ ttsVolume: value })} + /> + ); } @@ -789,6 +804,53 @@ function SegmentedRow({ ); } +/** A theme picker drawn in the theme's own paper and ink, so the choice is seen, not decoded from a name. */ +function ThemeSwatchRow({ + label, + options, + value, + onChange, +}: { + label: string; + options: Array<{ value: string; label: string }>; + value: string; + onChange(value: string): void; +}): JSX.Element { + return ( +
+ +
+ {options.map((option) => ( + + ))} +
+
+ ); +} + +/** Rows that are set once and left alone. A native
: keyboard and screen-reader behaviour for free. */ +function FoldSection({ title, hint, children }: { title: string; hint?: string; children: ReactNode }): JSX.Element { + return ( +
+ + {title} + {hint ? {hint} : null} + +
{children}
+
+ ); +} + /** A labelled range input whose value label follows the drag. */ function SliderRow({ label, diff --git a/web/test/reader-chrome.test.ts b/web/test/reader-chrome.test.ts index 7fbf730..1701353 100644 --- a/web/test/reader-chrome.test.ts +++ b/web/test/reader-chrome.test.ts @@ -164,6 +164,52 @@ describe('ReaderChrome', () => { expect(tree.querySelector('.panel')?.textContent).not.toContain('字号'); }); + it('puts the most-used appearance rows first and folds the rarely changed ones', () => { + const tree = paint({ settingsOpen: true, settingsTab: 'appearance' }); + const body = tree.querySelector('.panel-body')!; + const order = [...body.children].map((element) => + element.matches('details.panel-fold') + ? `fold:${element.querySelector('.panel-fold-title')?.textContent}` + : (element.querySelector('label')?.textContent ?? '').split(' ')[0], + ); + // Size, spacing, theme and brightness fill the first screen; the readouts that + // used to come first are behind a fold, closed by default. + expect(order).toEqual(['字号', '行距', '主题', '亮度', 'fold:更多排版', 'fold:页眉页脚']); + for (const fold of body.querySelectorAll('details.panel-fold')) expect(fold.open).toBe(false); + expect(body.querySelector('details.panel-fold input[aria-label="页边距"]')).not.toBeNull(); + expect(body.querySelector('details.panel-fold select[aria-label="顶部左侧"]')).not.toBeNull(); + }); + + it('draws the theme choice as swatches and marks the selected one', () => { + const tree = paint({ settingsOpen: true, settingsTab: 'appearance', theme: 'sepia' }); + const swatches = [...tree.querySelectorAll('.theme-swatch')]; + expect(swatches.map((button) => button.querySelector('.theme-swatch-label')?.textContent)).toEqual(['白', '米黄', '浅绿', '夜间']); + expect(swatches.filter((button) => button.getAttribute('aria-pressed') === 'true').map((button) => button.textContent)).toEqual(['Aa米黄']); + // The chip is scoped to its own theme so it is drawn in that theme's colours. + expect(swatches.map((button) => button.querySelector('.theme-swatch-chip')?.getAttribute('data-theme'))).toEqual(['light', 'sepia', 'green', 'dark']); + }); + + it('keeps typography folded away for fixed layouts and offers only the readouts', () => { + const tree = paint({ settingsOpen: true, settingsTab: 'appearance', layout: 'fixed' }); + const folds = [...tree.querySelectorAll('details.panel-fold .panel-fold-title')].map((element) => element.textContent); + expect(folds).toEqual(['页眉页脚']); + expect(tree.querySelector('.panel')?.textContent).not.toContain('字号'); + expect(tree.querySelector('.panel')?.textContent).toContain('浅绿'); + }); + + it('puts the start action first in the read-aloud sheet and folds pitch and volume', () => { + const tree = paint({ settingsOpen: true, settingsTab: 'speech', ttsRate: 1.2, showPitchRow: true }); + const body = tree.querySelector('.panel-body')!; + // The action the sheet exists for is not buried under five sliders. + expect(body.firstElementChild?.textContent).toBe('从当前位置朗读'); + expect(body.firstElementChild?.classList.contains('primary')).toBe(true); + const presets = [...body.querySelectorAll('[aria-label="常用"] button')]; + expect(presets.map((button) => button.textContent)).toEqual(['0.8×', '1.0×', '1.2×', '1.5×', '2.0×']); + expect(presets.filter((button) => button.getAttribute('aria-pressed') === 'true').map((button) => button.textContent)).toEqual(['1.2×']); + expect(body.querySelector('details.panel-fold input[aria-label="音量"]')).not.toBeNull(); + expect(body.querySelector('details.panel-fold input[aria-label="音调"]')).not.toBeNull(); + }); + it('draws every read-aloud control, including stop, in their own row', () => { const tree = paint({ settingsOpen: true, settingsTab: 'speech',