mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 13:31:09 +08:00
- ui/sheet.tsx Sheet/ConfirmSheet replace the centred Modal, SweetAlert confirms, DialogView overlays, the reader Panel, the shelf settings floating card, the two media dialogs and Mantine Modal. - Phones: bottom sheet. Wide windows: right-edge drawer; compact sheets (confirm, prompt, pick) open next to the tap or focused control. - One set of exits (close, Escape, backdrop), 关闭 label, focus starts on 取消, danger confirms. - Remove sweetalert2 and the unused @mantine/notifications. - Remove the 来源与刮削 media settings panel and its entries in 我的 and 影音设置; TMDB and MusicBrainz stay configurable in 系统设置 > 服务配置. - docs/ui.md 3.7 describes the rule; tests and review scripts follow the new markup.
321 lines
15 KiB
TypeScript
321 lines
15 KiB
TypeScript
import { render } from '../src/shared/ui/render-root.ts';
|
||
// @vitest-environment jsdom
|
||
/**
|
||
* The reader's chrome, as a function of its state.
|
||
*
|
||
* Two of the reported defects live entirely in this tree, and both are the kind a
|
||
* screenshot review cannot separate from a styling problem:
|
||
*
|
||
* - the quick-action rail held five sheet-opening destinations, so it was a second
|
||
* settings panel floating over the text (竖排工具栏去掉 目录、字体、行距、边距、设置);
|
||
* - the read-aloud bar's controls were a single flex row, so on a phone the last
|
||
* of them — stop — was pushed off the right edge and the reader could not stop
|
||
* the voice at all (没法停止).
|
||
*/
|
||
import { beforeEach, describe, expect, it } from 'vitest';
|
||
import { createElement as h } from 'react';
|
||
import { ReaderChrome, type ChromeState, type ChromeHandlers, type ReaderThemeState } from '../src/ui/reader-chrome.tsx';
|
||
import { defaultThemePreference, type ThemePreference } from '../src/shared/theme/theme.ts';
|
||
|
||
const themeState = (preference: Partial<ThemePreference> = {}, dark = false): ReaderThemeState => {
|
||
const merged = { ...defaultThemePreference, ...preference };
|
||
const mode: 'dark' | 'light' = dark ? 'dark' : 'light';
|
||
const reading = { theme: merged.reading === 'gray' ? 'gray' as const : 'warm' as const, mode };
|
||
return { preference: merged, app: { theme: 'warm', mode }, reading, surface: { ...reading, paper: null } };
|
||
};
|
||
|
||
function chromeState(overrides: Partial<ChromeState> = {}): ChromeState {
|
||
return {
|
||
title: '书',
|
||
author: '',
|
||
chromeVisible: true,
|
||
statusText: '',
|
||
statusState: 'idle',
|
||
progress: 0.2,
|
||
chapterLabel: '第 1 章',
|
||
tocOpen: false,
|
||
settingsOpen: false,
|
||
toc: [],
|
||
currentSectionId: '',
|
||
pageInChapter: 1,
|
||
chapterPages: 3,
|
||
chapterIndex: 1,
|
||
chapterCount: 10,
|
||
navigating: false,
|
||
tts: {
|
||
active: false,
|
||
state: 'idle',
|
||
label: '',
|
||
chip: '从头朗读',
|
||
index: 0,
|
||
total: 0,
|
||
sentenceIndex: -1,
|
||
sentenceTotal: 0,
|
||
},
|
||
layout: 'reflowable',
|
||
format: 'epub',
|
||
mode: 'scroll',
|
||
fontScale: 1,
|
||
lineHeight: 'inherit',
|
||
theme: 'light',
|
||
fit: 'contain',
|
||
fontFamily: 'inherit',
|
||
pageMargin: 1.5,
|
||
textAlign: 'inherit',
|
||
brightness: 1,
|
||
pageAnimation: 'slide',
|
||
tapZone: 'standard',
|
||
txtEncoding: '',
|
||
txtIndent: 2,
|
||
txtParagraphGap: 0.55,
|
||
comicDirection: 'ltr',
|
||
paper: 'theme',
|
||
paperFollowsDark: true,
|
||
ttsRate: 1,
|
||
ttsPitch: 1,
|
||
ttsVolume: 1,
|
||
ttsVoice: '',
|
||
ttsAutoAdvance: true,
|
||
ttsEngine: 'auto',
|
||
showFitRow: false,
|
||
showDirectionRow: false,
|
||
showEncodingRow: false,
|
||
showTxtRows: false,
|
||
showEngineRow: false,
|
||
showPitchRow: true,
|
||
showVoiceRow: true,
|
||
speechUnavailable: false,
|
||
engineOptions: [{ value: 'auto', label: '自动' }],
|
||
voices: [{ value: '', label: '跟随系统' }],
|
||
...overrides,
|
||
} as ChromeState;
|
||
}
|
||
|
||
/** Every handler is a no-op: this file asserts the tree, not the behaviour. */
|
||
function handlers(theme: ReaderThemeState = themeState(), onTheme: ChromeHandlers['onTheme'] = () => undefined): ChromeHandlers {
|
||
const noop = (): void => undefined;
|
||
return {
|
||
onBack: noop,
|
||
toggleToc: noop,
|
||
toggleSettings: noop,
|
||
onSetting: noop,
|
||
theme: () => theme,
|
||
onTheme,
|
||
onSpeechSetting: noop,
|
||
onVoice: noop,
|
||
onSpeakFromHere: noop,
|
||
onSwitchEngine: noop,
|
||
onTocEntry: noop,
|
||
onChapter: noop,
|
||
onScrubPage: noop,
|
||
onTurnPage: noop,
|
||
onSpeechToggle: noop,
|
||
onSpeechPrevious: noop,
|
||
onSpeechNext: noop,
|
||
onSpeechScrub: noop,
|
||
onStopSpeech: noop,
|
||
} as unknown as ChromeHandlers;
|
||
}
|
||
|
||
describe('ReaderChrome', () => {
|
||
let container: HTMLDivElement;
|
||
|
||
beforeEach(() => {
|
||
container = document.createElement('div');
|
||
document.body.append(container);
|
||
});
|
||
|
||
function paint(state: Partial<ChromeState>): HTMLElement {
|
||
const stage = document.createElement('div');
|
||
stage.className = 'stage';
|
||
render(h(ReaderChrome, { state: chromeState(state), stage, handlers: handlers() }), container);
|
||
return container;
|
||
}
|
||
|
||
it('keeps only the page-local controls on the rail', () => {
|
||
// 主题 / 字号 / 听书. The five that were removed all open or reach the settings
|
||
// sheet, so having them on the rail as well made it a worse copy of that sheet
|
||
// floating over the text.
|
||
const tree = paint({});
|
||
const rail = tree.querySelector('.reader-rail');
|
||
expect(rail).not.toBeNull();
|
||
const labels = [...(rail?.querySelectorAll('.rail-button') ?? [])].map((button) =>
|
||
button.getAttribute('aria-label'),
|
||
);
|
||
expect(labels).toEqual(['夜间']);
|
||
for (const gone of ['目录', '字体', '行距', '页边距', '阅读设置']) {
|
||
expect(labels).not.toContain(gone);
|
||
}
|
||
});
|
||
|
||
it('offers refresh only in the chapter publication contents panel and disables it while busy', () => {
|
||
expect(paint({ tocOpen: true }).textContent).not.toContain('刷新目录');
|
||
let tree = paint({ tocOpen: true, canRefresh: true });
|
||
expect([...tree.querySelectorAll('button')].find((button) => button.getAttribute('aria-label') === '刷新目录')?.disabled).toBe(false);
|
||
tree = paint({ tocOpen: true, canRefresh: true, refreshing: true });
|
||
expect([...tree.querySelectorAll('button')].find((button) => button.getAttribute('aria-label') === '刷新目录')?.disabled).toBe(true);
|
||
});
|
||
|
||
it('uses chapter pages for the slider even when the book has many fixed pages', () => {
|
||
const tree = paint({ layout: 'fixed', chapterIndex: 50, chapterCount: 100, chapterPages: 1, pageInChapter: 1 });
|
||
const slider = tree.querySelector<HTMLInputElement>('.progress-scrubber')!;
|
||
expect(slider.max).toBe('1');
|
||
expect(slider.value).toBe('1');
|
||
expect(slider.disabled).toBe(true);
|
||
expect(slider.getAttribute('aria-label')).toBe('章节内页数');
|
||
});
|
||
|
||
it('separates appearance and behavior settings and omits text controls for comics', () => {
|
||
let tree = paint({ settingsOpen: true, settingsTab: 'appearance' });
|
||
expect(tree.querySelector('.sheet')?.textContent).toContain('黑白灰');
|
||
expect(tree.querySelector('.sheet')?.textContent).not.toContain('点击区域');
|
||
tree = paint({ settingsOpen: true, settingsTab: 'behavior' });
|
||
expect(tree.querySelector('.sheet')?.textContent).toContain('点击区域');
|
||
expect(tree.querySelector('.sheet')?.textContent).not.toContain('字号');
|
||
tree = paint({ settingsOpen: true, settingsTab: 'appearance', layout: 'fixed' });
|
||
expect(tree.querySelector('.sheet')?.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('.sheet-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<HTMLDetailsElement>('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('offers the reading themes as swatches, follow-app or 黑白灰, and marks the current one', () => {
|
||
const tree = paint({ settingsOpen: true, settingsTab: 'appearance' });
|
||
const swatches = [...tree.querySelectorAll<HTMLButtonElement>('[aria-label="主题"] .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.querySelector('.theme-swatch-label')?.textContent)).toEqual(['跟随应用']);
|
||
// Each chip is drawn in that theme's own background (light mode here): 黑白灰 #F5F5F5.
|
||
const chip = swatches[1]!.querySelector<HTMLElement>('.theme-swatch-chip')!;
|
||
expect(chip.style.background).toContain('rgb(245, 245, 245)');
|
||
const modes = [...tree.querySelectorAll<HTMLButtonElement>('[aria-label="明暗"] button')];
|
||
expect(modes.map((button) => button.textContent)).toEqual(['跟随系统', '浅色', '深色']);
|
||
expect(modes.find((button) => button.getAttribute('aria-pressed') === 'true')?.textContent).toBe('跟随系统');
|
||
});
|
||
|
||
it('saves reading-theme and day/night choices as theme preference patches', () => {
|
||
const patches: Array<Partial<ThemePreference>> = [];
|
||
render(h(ReaderChrome, { state: chromeState({ settingsOpen: true, settingsTab: 'appearance' }), stage: document.createElement('div'), handlers: handlers(themeState({}, true), (patch) => patches.push(patch)) }), container);
|
||
const tree = container;
|
||
[...tree.querySelectorAll<HTMLButtonElement>('[aria-label="主题"] .theme-swatch')][1]!.click();
|
||
[...tree.querySelectorAll<HTMLButtonElement>('[aria-label="明暗"] button')][1]!.click();
|
||
// The rail's day/night button: dark now, so it offers day and leaves follow-system.
|
||
const rail = tree.querySelector<HTMLButtonElement>('.reader-rail button')!;
|
||
expect(rail.getAttribute('aria-label')).toBe('日间');
|
||
rail.click();
|
||
expect(patches).toEqual([{ reading: 'gray' }, { mode: 'light', theme: 'warm' }, { mode: 'light', theme: 'warm' }]);
|
||
});
|
||
|
||
it('offers the reading papers as a page setting, and the follow-dark switch only for a light paper', () => {
|
||
const settings: Array<Record<string, unknown>> = [];
|
||
const paint2 = (state: Partial<ChromeState>) => {
|
||
const h2 = handlers();
|
||
(h2 as { onSetting: ChromeHandlers['onSetting'] }).onSetting = (patch) => settings.push(patch);
|
||
render(h(ReaderChrome, { state: chromeState({ settingsOpen: true, settingsTab: 'appearance', ...state }), stage: document.createElement('div'), handlers: h2 }), container);
|
||
return container;
|
||
};
|
||
let tree = paint2({});
|
||
const papers = [...tree.querySelectorAll<HTMLButtonElement>('[aria-label="纸色"] .theme-swatch')];
|
||
expect(papers.map((button) => button.querySelector('.theme-swatch-label')?.textContent)).toEqual(['跟随主题', '白纸', '暖黄', '浅绿', '灰调', '夜间']);
|
||
expect(papers[0]!.getAttribute('aria-pressed')).toBe('true');
|
||
// 暖黄 is drawn in its own paper: #F4ECD8.
|
||
expect(papers[2]!.querySelector<HTMLElement>('.theme-swatch-chip')!.style.background).toContain('rgb(244, 236, 216)');
|
||
expect(tree.textContent).not.toContain('跟随系统深色模式');
|
||
papers[2]!.click();
|
||
expect(settings).toEqual([{ paper: 'warm' }]);
|
||
tree = paint2({ paper: 'warm' });
|
||
expect(tree.textContent).toContain('跟随系统深色模式');
|
||
tree.querySelector<HTMLButtonElement>('.switch')!.click();
|
||
expect(settings).toEqual([{ paper: 'warm' }, { paperFollowsDark: false }]);
|
||
tree = paint2({ paper: 'night' });
|
||
expect(tree.textContent).not.toContain('跟随系统深色模式');
|
||
});
|
||
|
||
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('.sheet')?.textContent).not.toContain('字号');
|
||
expect(tree.querySelector('.sheet')?.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('.sheet-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',
|
||
tts: {
|
||
active: true,
|
||
state: 'playing',
|
||
label: '正文',
|
||
chip: '3/10',
|
||
index: 2,
|
||
total: 10,
|
||
sentenceIndex: 2,
|
||
sentenceTotal: 10,
|
||
},
|
||
});
|
||
const bar = tree.querySelector('.tts-bar');
|
||
expect(bar).not.toBeNull();
|
||
// The controls are one row of their own, so they divide the width evenly and
|
||
// none of them is clipped — which is what made 停止 unreachable.
|
||
const controls = bar?.querySelector('.tts-controls');
|
||
expect(controls).not.toBeNull();
|
||
const labels = [...(controls?.querySelectorAll('button') ?? [])].map((button) =>
|
||
button.getAttribute('aria-label'),
|
||
);
|
||
expect(labels).toContain('停止朗读');
|
||
expect(labels).toContain('暂停朗读');
|
||
expect(labels).toContain('上一句');
|
||
expect(labels).toContain('下一句');
|
||
// The sentence and its scrubber are the second row, so the sentence can never
|
||
// push a control out of the bar.
|
||
expect(bar?.querySelector('.tts-progress .tts-range')).not.toBeNull();
|
||
});
|
||
|
||
it('stops the voice with a square, not an ✕', () => {
|
||
// ✕ read as "close this bar", which is not what stop does: the reader who wanted
|
||
// the voice to stop pressed it, the bar disappeared, and the book kept reading.
|
||
const tree = paint({
|
||
settingsOpen: true, settingsTab: 'speech',
|
||
tts: {
|
||
active: true,
|
||
state: 'playing',
|
||
label: '正文',
|
||
chip: '1/2',
|
||
index: 0,
|
||
total: 2,
|
||
sentenceIndex: 0,
|
||
sentenceTotal: 2,
|
||
},
|
||
});
|
||
const stop = [...tree.querySelectorAll('.tts-controls button')].find(
|
||
(button) => button.getAttribute('aria-label') === '停止朗读',
|
||
);
|
||
expect(stop?.getAttribute('class')).toContain('tts-stop');
|
||
});
|
||
});
|