feat(web): 点击区域设置 — a 3×3 tap grid with a per-cell action

The two-way 左退右进 / 左进右退 switch becomes nine cells, each 上一页, 下一页,
菜单, 上一章, 下一章 or 无操作, edited as a small page in 阅读设置 → 翻页 with
左右互换 and 恢复默认. The default grid is the old standard layout and a stored
`tapZone: 'reversed'` is folded into its grid. At least one 菜单 cell is kept,
right-to-left books mirror the grid, and the gesture layer now reports the row
as well as the column, from layout geometry like before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude Opus 5.5 committed 2026-10-31 10:27:51 +00:00
1 parent bbc0ec0ee9
commit a904bb5a83
10 files changed
+323 -48

No files matched your search

+15 -3
View File
@@ -564,7 +564,19 @@ SweetAlert、Mantine `Modal`、自绘遮罩或角落浮动卡片。
1. 读者按到的是控件(链接、按钮、输入、滑块)→ 控件拿走这次手势,不翻页;
2. 面板开着 → 面板拿走(点遮罩=关闭,不是翻页);
3. 否则拖动是滑动翻页;
4. 否则短按落在三段点击区里。
4. 否则短按落在点击区里。
**点击区域**是版心上的 3×3 九宫格,每格一个动作:上一页、下一页、菜单(显隐工具栏)、
上一章、下一章、无操作(`ui/tap-zones.ts`,设置项 `tapZones`,按设备保存)。默认等于原来的
「左退右进」三列;旧设备上存的 `tapZone: 'reversed'` 升级后变成对应的九宫格。设置在
「阅读设置 → 翻页 → 点击区域」,以一张缩小的页面呈现,点格子从菜单里选动作,另有
「左右互换」「恢复默认」。两条规则:
- **至少保留一格「菜单」**。最后一格菜单不能改成别的(菜单里说明原因),存储里没有菜单的
九宫格读出时中间格补成菜单——否则工具栏和这个设置本身都再也点不出来。
- **九宫格按从左到右的书描述,从右到左的书左右镜像**,与原来的左右互换开关同一条规则。
翻页和换章会顺手收起正在显示的工具栏;无操作什么都不做,也不收起。
**翻页模式下页面跟手。** 手指明显横向移动(超过 12px 且横向大于纵向)后,
`ReaderView.beginDrag` 开始让分栏带的 `scrollLeft` 跟着手指走,看到的就是真正的下一页;
@@ -591,9 +603,9 @@ SweetAlert、Mantine `Modal`、自绘遮罩或角落浮动卡片。
「可滚动的祖先拿走手势」这条规则必须排除它——否则它吃掉每一个手势,每一次
点击都归属某个滚动容器,翻页层级正确但永远到不了。这一条是 2026-09 那轮
「点击页面没反应」的真正原因。
- **三段点击区是*布局*几何,不是视觉几何**。`getBoundingClientRect()` 和
- **点击区是*布局*几何,不是视觉几何**。`getBoundingClientRect()` 和
`event.clientX` 都在**视觉**视口坐标里,两者相减得到的是视觉距离;而点击区是
版心宽度的三等分,版心宽度是**布局**像素。页面被捏合放大(或任何祖先变换)时
版心宽、高的三等分,用的是**布局**像素。页面被捏合放大(或任何祖先变换)时
两者不相等,误差可以超过判定阈值的一半——读者点中间三分之一想收起工具栏,
结果翻了页。所以宽度取 `offsetWidth`,偏移按两个盒子的比例 `rect.width /
offsetWidth` 换算回布局空间;无变换、无缩放时这个比例正好是 1,那一行是恒等
+9
View File
@@ -1,6 +1,7 @@
import type { KeyValueStore } from '../core/platform.ts';
import type { ViewSettings } from '../ui/reader-view.ts';
import { validReaderPaper, type ReaderPaper } from '../shared/theme/theme.ts';
import { normalizeTapZones, tapZonePreset, type TapZoneGrid } from '../ui/tap-zones.ts';
/**
* Reader preferences, persisted per device.
@@ -50,6 +51,12 @@ export interface AppSettings extends ViewSettings {
readoutTopRight: ReadoutMode;
readoutBottomLeft: ReadoutMode;
readoutBottomRight: ReadoutMode;
/**
* 点击区域: one action per cell of a 3×3 grid, row-major (see `ui/tap-zones.ts`).
* Supersedes `tapZone`, which is kept so an older build on the same device still
* finds the setting it knows; a device without a grid derives it from `tapZone`.
*/
tapZones: TapZoneGrid;
/** UTF-8 by default; a reader can force another for an odd TXT file. */
txtEncoding: string;
/** Only meaningful for fixed-layout books. */
@@ -115,6 +122,7 @@ export const DEFAULT_APP_SETTINGS: AppSettings = {
brightness: 1,
pageAnimation: 'slide',
tapZone: 'standard',
tapZones: tapZonePreset('standard'),
txtIndent: 2,
txtParagraphGap: 0.55,
txtEncoding: '',
@@ -174,6 +182,7 @@ export class SettingsStore {
this.settings.paper = legacy[String(parsed.theme)] ?? 'theme';
}
this.settings.paper = validReaderPaper(this.settings.paper);
this.settings.tapZones = normalizeTapZones(parsed.tapZones, parsed.tapZone);
if (typeof this.settings.paperFollowsDark !== 'boolean') this.settings.paperFollowsDark = true;
if (!['recent', 'added', 'title', 'author'].includes(this.settings.shelfSort)) {
this.settings.shelfSort = DEFAULT_APP_SETTINGS.shelfSort;
+67
View File
@@ -2705,6 +2705,73 @@ book-content {
padding-bottom: var(--space-2);
}
/* 点击区域设置: the page drawn small, nine cells, each a menu trigger. The grid is
* portrait like the page it stands for, so a cell's place reads as the place on
* the screen; its height is capped so the sheet still shows the rows around it. */
.tap-zones {
margin-bottom: var(--space-3);
}
.tap-zones-head {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.tap-zones-title {
margin-inline-end: auto;
font-size: var(--text-label);
color: var(--reader-muted);
}
.tap-zones-head .tap-zones-action {
padding: 0.35rem 0.6rem;
font-size: var(--text-label);
}
.tap-zones-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: var(--space-1);
width: min(100%, 15rem);
aspect-ratio: 3 / 4;
margin-inline: auto;
padding: var(--space-1);
border: 1px solid var(--reader-line);
border-radius: var(--reader-radius);
background: var(--reader-paper);
}
.tap-zones-grid .tap-zone-cell {
flex-direction: column;
min-width: 0;
min-height: 44px;
height: 100%;
padding: var(--space-1);
border-style: dashed;
background: transparent;
font-size: var(--text-label);
}
.tap-zones-grid .tap-zone-cell.is-menu {
border-style: solid;
border-color: var(--reader-accent);
background: var(--reader-fill);
color: var(--reader-on-fill);
}
.tap-zones-grid .tap-zone-cell.is-none {
color: var(--reader-muted);
}
.tap-zones > .muted {
margin: var(--space-2) 0 0;
font-size: var(--text-meta);
text-align: center;
}
/* The one action the read-aloud sheet exists for: full width, first, and tall enough to hit. */
.speech-start {
display: flex;
+15 -8
View File
@@ -3,9 +3,10 @@
*
* The rules that matter on a phone, and why:
*
* - A tap in the middle third toggles the chrome; taps in the outer thirds turn
* the page. This is the convention every mobile reader uses, and violating it
* is immediately noticeable.
* - A tap is reported as a cell of a 3×3 grid over the stage; what each cell
* does is the reader's 点击区域 setting (`tap-zones.ts`), by default the
* convention every mobile reader uses: the middle column toggles the chrome,
* the outer columns turn the page.
* - A swipe must be mostly horizontal to count. A diagonal drag is almost
* always an attempt to scroll, and treating it as a page turn makes a
* reflowable book feel like it is fighting back.
@@ -32,7 +33,8 @@
*/
export interface GestureHandlers {
onTapZone(zone: 'previous' | 'toggle-chrome' | 'next'): void;
/** A tap, as the cell of a 3×3 grid it fell in: `column` and `row` are each 0–2. */
onTapZone(column: number, row: number): void;
onSwipe(direction: 'left' | 'right' | 'up' | 'down'): void;
/** Fires continuously while a finger is down and moving; paged mode follows it. */
onDragDelta(deltaX: number, deltaY: number, pointerType: string): void;
@@ -281,10 +283,15 @@ export function attachGestures(element: HTMLElement, handlers: GestureHandlers):
// no-op — which is what makes this a correction rather than a conversion.
const scale = layoutWidth > 0 && rect.width > 0 ? rect.width / layoutWidth : 1;
const x = (event.clientX - rect.left - element.clientLeft * scale) / scale + element.scrollLeft;
const third = width / 3;
if (x < third) handlers.onTapZone('previous');
else if (x > third * 2) handlers.onTapZone('next');
else handlers.onTapZone('toggle-chrome');
// The row is the same arithmetic on the other axis. The stage itself does not
// scroll vertically (in scroll mode the shadow host inside it does), so the
// rows divide what is on screen, not the chapter.
const layoutHeight = element.offsetHeight;
const height = layoutHeight > 0 ? layoutHeight : rect.height || element.clientHeight;
const scaleY = layoutHeight > 0 && rect.height > 0 ? rect.height / layoutHeight : 1;
const y = (event.clientY - rect.top - element.clientTop * scaleY) / scaleY;
const cell = (offset: number, size: number): number => (offset < size / 3 ? 0 : offset > (size * 2) / 3 ? 2 : 1);
handlers.onTapZone(cell(x, width), height > 0 ? cell(y, height) : 1);
}
};
+61 -8
View File
@@ -30,6 +30,8 @@ import type { SpeechEngineKind } from '../render/speech.ts';
import { type ReactNode, type JSX, useState } from 'react';
import { Button, IconButton, SectionTitle, SwitchRow } from './toolkit.tsx';
import { Icon, type IconName } from './icon.tsx';
import { ActionMenu } from './action-menu.tsx';
import { TAP_ACTIONS, isLastMenuCell, normalizeTapZones, tapActionLabel, tapZonePreset, type TapZoneGrid } from './tap-zones.ts';
/**
* A table-of-contents row.
@@ -132,6 +134,8 @@ export interface ChromeState {
brightness: number;
pageAnimation: AppSettings['pageAnimation'];
tapZone: AppSettings['tapZone'];
/** 点击区域, the 3×3 grid; optional so an older state shape still renders. */
tapZones?: TapZoneGrid;
txtEncoding: string;
txtIndent: number;
txtParagraphGap: number;
@@ -604,14 +608,9 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
<SectionTitle>翻页</SectionTitle>
<Button onClick={() => { handlers.onSetting({ pageAnimation: 'none', mode: 'paged' }); handlers.onTheme({ reading: 'gray', mode: 'light', theme: handlers.theme().app.theme }); }}>应用电纸书预设</Button>
<p className="muted">黑白灰浅色、分页、无翻页动画;仅保存到当前设备,可继续单独调整。屏幕刷新由设备控制。</p>
<SegmentedRow
label="点击区域"
options={[
{ value: 'standard', label: '左退右进' },
{ value: 'reversed', label: '左进右退' },
]}
value={state.tapZone}
onChange={(value) => handlers.onSetting({ tapZone: value as AppSettings['tapZone'] })}
<TapZoneEditor
grid={normalizeTapZones(state.tapZones, state.tapZone)}
onChange={(tapZones) => handlers.onSetting({ tapZones })}
/>
<SegmentedRow
label="翻页动画"
@@ -922,6 +921,60 @@ function ReadingThemeRow({ label, state, onChange }: { label: string; state: Rea
}
/** Rows that are set once and left alone. A native <details>: keyboard and screen-reader behaviour for free. */
const TAP_CELL_NAMES = ['左上', '上方', '右上', '左侧', '中间', '右侧', '左下', '下方', '右下'];
/**
* 点击区域设置: the page as nine cells, each a menu of what a tap there does.
*
* Drawn as a small page rather than a list of nine selects, because the question
* the reader is answering is spatial — "what happens when my thumb lands *there*" —
* and a list makes them translate every row back into a position. The last 菜单
* cell cannot be changed to anything else (the menu says why): without one the
* bars, and this panel, could not be reached again by a tap.
*/
function TapZoneEditor({ grid, onChange }: { grid: TapZoneGrid; onChange(grid: TapZoneGrid): void }): JSX.Element {
const set = (index: number, action: TapZoneGrid[number]) => {
const next = [...grid];
next[index] = action;
onChange(next);
};
const mirrored = grid.map((_, index) => grid[index - (index % 3) + (2 - (index % 3))]!);
const isDefault = grid.every((action, index) => action === tapZonePreset('standard')[index]);
return (
<section className="tap-zones" aria-label="点击区域设置">
<div className="tap-zones-head">
<span className="tap-zones-title">点击区域</span>
<Button className="tap-zones-action" onClick={() => onChange(mirrored)}>左右互换</Button>
<Button className="tap-zones-action" disabled={isDefault} onClick={() => onChange(tapZonePreset('standard'))}>恢复默认</Button>
</div>
<div className="tap-zones-grid">
{grid.map((action, index) => {
const locked = isLastMenuCell(grid, index);
const name = TAP_CELL_NAMES[index]!;
return (
<ActionMenu
key={index}
className={`tap-zone-cell is-${action}`}
label={`${name}:${tapActionLabel(action)}`}
title={`点击${name}`}
icon={TAP_ACTIONS.find(item => item.value === action)?.icon ?? 'close'}
text={tapActionLabel(action)}
items={TAP_ACTIONS.map(item => ({
label: item.label,
checked: item.value === action,
disabled: locked && item.value !== 'menu',
...(locked && item.value !== 'menu' ? { description: '至少保留一个菜单区域' } : {}),
onSelect: () => set(index, item.value),
}))}
/>
);
})}
</div>
<p className="muted">点格子选择动作。从右到左排版的书会自动左右镜像。</p>
</section>
);
}
function FoldSection({ title, hint, children }: { title: string; hint?: string; children: ReactNode }): JSX.Element {
return (
<details className="panel-fold">
+22 -26
View File
@@ -38,6 +38,7 @@ import { AnnotationPopover, type AnnotationTarget } from './annotation-popover.t
import { newId } from '../core/id.ts';
import { htmlToText, isTextOnlyMarkup, textToChapterHtml } from '../formats/segments.ts';
import { parseLocator } from './locator.ts';
import { tapActionAt, tapZonePreset } from './tap-zones.ts';
export interface ReaderScreenOptions {
/** Where the shared app theme is stored (see the media theme service). */
@@ -1129,7 +1130,7 @@ export class ReaderScreen {
// back (see ReaderView.beginDrag). Mouse drags stay text selection.
let following = false;
this.detachGestures = attachGestures(this.stage, {
onTapZone: (zone) => this.onTapZone(zone),
onTapZone: (column, row) => this.onTapZone(column, row),
onSwipe: (direction) => {
// Swiping is only a page turn in fixed layout or paged mode. In scroll
// mode a horizontal swipe has no meaning and hijacking it would fight
@@ -1249,8 +1250,10 @@ export class ReaderScreen {
* behind an open panel is the bug the reader reports as "翻页和设置抢事件".
* The gesture layer cannot enforce this — it sees the stage, and the panel
* is a sibling — so it is enforced here.
* 2. **The middle third toggles the chrome**, in both directions.
* 3. **The outer thirds turn the page**, and *only* that.
* 2. **What the cell means is the reader's 点击区域 grid** (`tap-zones.ts`),
* by default 左/右 = 翻页, 中 = 显隐工具栏 — one meaning per cell. The grid
* describes a left-to-right book and is mirrored for a right-to-left one.
* 3. **菜单 toggles the chrome, in both directions; nothing else reveals it.**
*
* The third rule is the one that changed, and the report behind it is the whole
* of it: "在点击左右两边时只需要翻页、不需要显示工具栏,只有在中间点击时才需要切换
@@ -1260,40 +1263,31 @@ export class ReaderScreen {
* reader who tapped the right third to go forward got the next page *and* a
* toolbar across the text they had just turned to, so the immersive state was
* impossible to stay in — every page turn undid it. And the reader who *did* want
* the toolbar back had a dedicated, discoverable way to ask for it: the middle
* third.
* the toolbar back had a dedicated, discoverable way to ask for it: a 菜单 cell,
* of which the grid always keeps at least one.
*
* So the two gestures are now disjoint, one meaning each, which is the property
* the old version lacked: 左/右 = 翻页, 中 = 显隐工具栏. Nothing is revealed by a
* page turn, and nothing is turned by a tap that asked for the controls.
*
* One step further, by request ("点击翻页时自动隐藏工具栏"): a page turn *hides*
* a toolbar that is showing. A reader who turns the page has gone back to reading,
* and the bars would otherwise sit over the page they just turned to until they
* found the middle third. Hiding never moves the text — the bars float over a
* constant page (see `.reader-screen` in reader.css) — so this costs no reflow.
* One step further, by request ("点击翻页时自动隐藏工具栏"): a page or chapter turn
* *hides* a toolbar that is showing. A reader who turns the page has gone back to
* reading, and the bars would otherwise sit over the page they just turned to.
* Hiding never moves the text — the bars float over a constant page (see
* `.reader-screen` in reader.css) — so this costs no reflow. 无操作 does nothing
* at all, including hiding the bars.
*/
private onTapZone(zone: 'previous' | 'toggle-chrome' | 'next'): void {
private onTapZone(column: number, row: number): void {
if (this.chrome.tocOpen || this.chrome.settingsOpen) return;
// The tap that dismissed a note card (it closes on pointerdown) is spent on that:
// it must not also turn the page or toggle the bars (ui.md §3.8).
if (performance.now() - this.annotationClosedAt < 600) return;
if (zone === 'toggle-chrome') {
const action = tapActionAt(this.settings.tapZones, column, row, this.isRtl());
if (action === 'none') return;
if (action === 'menu') {
this.setChromeVisible(!this.chromeVisible);
return;
}
// The page turn, which also puts a showing toolbar away (see the doc comment
// above); only the middle third brings it back. `tapZone` decides which
// physical side is *forward*, so a left-handed reader can put next-page under
// the thumb that holds the phone.
// A right-to-left book reads forward to the left, as does a left-handed `reversed`
// layout; both together cancel out.
const reversed = (this.settings.tapZone === 'reversed') !== (this.isRtl());
const forward = reversed ? zone === 'previous' : zone === 'next';
if (this.chromeVisible) this.setChromeVisible(false);
void this.turnPage(forward ? 'next' : 'previous');
if (action === 'next' || action === 'previous') void this.turnPage(action);
else void this.goToChapter(action === 'next-chapter' ? 1 : -1);
}
private async restorePosition(book: Book, token: number): Promise<void> {
@@ -2256,6 +2250,7 @@ function emptyChromeSettings(): Partial<ChromeState> {
brightness: 1,
pageAnimation: 'slide',
tapZone: 'standard',
tapZones: tapZonePreset('standard'),
txtIndent: 2,
txtParagraphGap: 0.55,
txtEncoding: '',
@@ -2330,6 +2325,7 @@ function settingsView(
brightness: settings.brightness,
pageAnimation: settings.pageAnimation,
tapZone: settings.tapZone,
tapZones: settings.tapZones,
txtEncoding: settings.txtEncoding,
txtIndent: settings.txtIndent,
txtParagraphGap: settings.txtParagraphGap,
+67
View File
@@ -0,0 +1,67 @@
/**
* 点击区域: what a tap does, per cell of a 3×3 grid over the page.
*
* The grid is stored row-major, nine actions, and describes a left-to-right book.
* A right-to-left book (a manga set to 右→左, an Arabic EPUB) reads it mirrored, so
* the side the reader set as 下一页 stays the side the story moves toward — the same
* rule the old two-way 左退右进 / 左进右退 switch followed.
*
* At least one cell is always 菜单: with none, the bars could only come back by a
* long press nobody knows about, and a reader who mis-set the grid would be locked
* out of the very panel that undoes it.
*/
import type { IconName } from './icon.tsx';
import type { TapZone } from './reader-view.ts';
export type TapAction = 'previous' | 'next' | 'menu' | 'previous-chapter' | 'next-chapter' | 'none';
export type TapZoneGrid = TapAction[];
export const TAP_ACTIONS: ReadonlyArray<{ value: TapAction; label: string; icon: IconName }> = [
{ value: 'previous', label: '上一页', icon: 'chevron-left' },
{ value: 'next', label: '下一页', icon: 'chevron-right' },
{ value: 'menu', label: '菜单', icon: 'menu' },
{ value: 'previous-chapter', label: '上一章', icon: 'step-backward' },
{ value: 'next-chapter', label: '下一章', icon: 'step-forward' },
{ value: 'none', label: '无操作', icon: 'close' },
];
const VALID = new Set<string>(TAP_ACTIONS.map(action => action.value));
export const TAP_CELLS = 9;
const CENTER = 4;
/** The grid the old two-way switch meant: one action per column, every row alike. */
export function tapZonePreset(layout: TapZone): TapZoneGrid {
const row: TapAction[] = layout === 'reversed' ? ['next', 'menu', 'previous'] : ['previous', 'menu', 'next'];
return [...row, ...row, ...row];
}
/**
* A stored grid made safe to use: nine known actions and at least one 菜单.
*
* Without a stored grid — a device that last ran a build with only the two-way
* switch — the switch's choice becomes the grid, so a left-handed reader keeps
* their layout across the upgrade.
*/
export function normalizeTapZones(value: unknown, legacy: TapZone = 'standard'): TapZoneGrid {
if (!Array.isArray(value) || value.length !== TAP_CELLS || !value.every(item => VALID.has(item as string))) {
return tapZonePreset(legacy === 'reversed' ? 'reversed' : 'standard');
}
const grid = [...value] as TapZoneGrid;
if (!grid.includes('menu')) grid[CENTER] = 'menu';
return grid;
}
/** The action for a tap in `column`/`row` (each 0–2), mirrored for a right-to-left book. */
export function tapActionAt(grid: TapZoneGrid, column: number, row: number, rtl: boolean): TapAction {
const col = rtl ? 2 - column : column;
return grid[row * 3 + col] ?? 'none';
}
/** Whether `index` is the grid's last 菜单, which must not be changed to anything else. */
export function isLastMenuCell(grid: TapZoneGrid, index: number): boolean {
return grid[index] === 'menu' && grid.filter(action => action === 'menu').length === 1;
}
export function tapActionLabel(action: TapAction): string {
return TAP_ACTIONS.find(item => item.value === action)?.label ?? '无操作';
}
+16 -3
View File
@@ -12,7 +12,8 @@ import { attachGestures, type GestureHandlers } from '../src/ui/gestures.ts';
*
* 1. a control the reader actually touched owns the gesture, whatever it is;
* 2. otherwise a drag is a swipe;
* 3. otherwise a short press is a tap in one of the three zones.
* 3. otherwise a short press is a tap in one cell of the 3×3 grid (the zone
* names below are the default grid's columns).
*
* Everything here is a *pointer* sequence, because the layer tracks pointers: a
* `click` arrives after a drag that ended on a link, which is exactly why a tap
@@ -42,6 +43,7 @@ interface Harness {
stage: HTMLDivElement;
saw: {
zones: string[];
cells: Array<[number, number]>;
swipes: string[];
drags: number;
longPresses: number;
@@ -58,9 +60,12 @@ function harness(options: { selection?: boolean; width?: number } = {}): Harness
Object.defineProperty(stage, 'clientWidth', { get: () => width, configurable: true });
stage.getBoundingClientRect = () => ({ left: 0, top: 0, width, height: 600, right: width, bottom: 600, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
const saw = { zones: [] as string[], swipes: [] as string[], drags: 0, longPresses: 0 };
const saw = { zones: [] as string[], cells: [] as Array<[number, number]>, swipes: [] as string[], drags: 0, longPresses: 0 };
const handlers: GestureHandlers = {
onTapZone: (zone) => saw.zones.push(zone),
onTapZone: (column, row) => {
saw.zones.push(['previous', 'toggle-chrome', 'next'][column]!);
saw.cells.push([column, row]);
},
onSwipe: (direction) => saw.swipes.push(direction),
onDragDelta: () => { saw.drags += 1; },
onLongPress: () => { saw.longPresses += 1; },
@@ -99,6 +104,14 @@ describe('tap zones', () => {
expect(h.saw.zones).toEqual(['previous', 'toggle-chrome', 'next']);
});
it('reports the row as well, so each of the nine cells can mean something', () => {
const h = harness();
press(h.stage, [40, 100]);
press(h.stage, [150, 300]);
press(h.stage, [260, 500]);
expect(h.saw.cells).toEqual([[0, 0], [1, 1], [2, 2]]);
});
it('reads the zones from layout geometry, so a scaled page still taps where the finger is', () => {
// The failure this closes is a *half* of the tap area, and it is invisible at
// 100% zoom: `getBoundingClientRect()` and `event.clientX` are both in visual
+18
View File
@@ -202,6 +202,24 @@ describe('ReaderChrome', () => {
expect(tree.querySelector('.sheet')?.textContent).not.toContain('字号');
});
it('sets each of the nine tap cells, and keeps the last 菜单 cell a menu', () => {
const onSetting = vi.fn();
const stage = document.createElement('div');
const grid = ['previous', 'menu', 'next', 'previous', 'next', 'next', 'previous', 'next', 'next'];
render(h(ReaderChrome, { state: chromeState({ settingsOpen: true, settingsTab: 'behavior', tapZones: grid as NonNullable<ChromeState['tapZones']> }), stage, handlers: { ...handlers(), onSetting } }), container);
const cells = [...container.querySelectorAll<HTMLButtonElement>('.tap-zones-grid .tap-zone-cell')];
expect(cells.map(cell => cell.textContent)).toEqual(['上一页', '菜单', '下一页', '上一页', '下一页', '下一页', '上一页', '下一页', '下一页']);
act(() => cells[8]!.click());
const nextChapter = [...document.querySelectorAll<HTMLButtonElement>('.action-menu [role^=menuitem]')].find(item => item.textContent === '下一章')!;
act(() => nextChapter.click());
expect(onSetting).toHaveBeenLastCalledWith({ tapZones: [...grid.slice(0, 8), 'next-chapter'] });
act(() => cells[1]!.click());
const items = [...document.querySelectorAll<HTMLButtonElement>('.action-menu [role^=menuitem]')];
expect(items.filter(item => !item.disabled).map(item => item.getAttribute('aria-label') ?? item.textContent)).toEqual(['菜单']);
});
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')!;
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { isLastMenuCell, normalizeTapZones, tapActionAt, tapZonePreset } from '../src/ui/tap-zones.ts';
describe('点击区域', () => {
it('turns the old two-way switch into the grid it meant', () => {
expect(normalizeTapZones(undefined)).toEqual(tapZonePreset('standard'));
expect(normalizeTapZones(undefined, 'reversed').slice(0, 3)).toEqual(['next', 'menu', 'previous']);
});
it('rejects a malformed grid and keeps at least one 菜单', () => {
expect(normalizeTapZones(['next'])).toEqual(tapZonePreset('standard'));
expect(normalizeTapZones(Array(9).fill('bogus'))).toEqual(tapZonePreset('standard'));
const noMenu = normalizeTapZones(Array(9).fill('next'));
expect(noMenu[4]).toBe('menu');
expect(noMenu.filter(action => action === 'menu')).toHaveLength(1);
});
it('reads a cell row-major and mirrors it for a right-to-left book', () => {
const grid = normalizeTapZones(['previous', 'previous', 'next', 'previous', 'menu', 'next', 'previous', 'next', 'next']);
expect(tapActionAt(grid, 1, 0, false)).toBe('previous');
expect(tapActionAt(grid, 1, 2, false)).toBe('next');
expect(tapActionAt(grid, 0, 1, true)).toBe('next');
expect(tapActionAt(grid, 2, 1, true)).toBe('previous');
});
it('knows which 菜单 cell is the last one', () => {
const grid = tapZonePreset('standard');
expect(isLastMenuCell(grid, 4)).toBe(false);
const single = normalizeTapZones(['previous', 'menu', 'next', 'previous', 'next', 'next', 'previous', 'next', 'next']);
expect(isLastMenuCell(single, 1)).toBe(true);
expect(isLastMenuCell(single, 0)).toBe(false);
});
});