diff --git a/docs/ui.md b/docs/ui.md
index db7d867..f27cd68 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -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,那一行是恒等
diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts
index 3cfa509..c3e7844 100644
--- a/web/src/store/settings.ts
+++ b/web/src/store/settings.ts
@@ -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;
diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css
index ee3bddc..9b7fba1 100644
--- a/web/src/styles/reader.css
+++ b/web/src/styles/reader.css
@@ -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;
diff --git a/web/src/ui/gestures.ts b/web/src/ui/gestures.ts
index 5493dd9..644a7b8 100644
--- a/web/src/ui/gestures.ts
+++ b/web/src/ui/gestures.ts
@@ -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);
}
};
diff --git a/web/src/ui/reader-chrome.tsx b/web/src/ui/reader-chrome.tsx
index e504942..9946d17 100644
--- a/web/src/ui/reader-chrome.tsx
+++ b/web/src/ui/reader-chrome.tsx
@@ -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
翻页
黑白灰浅色、分页、无翻页动画;仅保存到当前设备,可继续单独调整。屏幕刷新由设备控制。
- handlers.onSetting({ tapZone: value as AppSettings['tapZone'] })}
+ handlers.onSetting({ tapZones })}
/>
: 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 (
+
+
+
+ );
+}
+
function FoldSection({ title, hint, children }: { title: string; hint?: string; children: ReactNode }): JSX.Element {
return (
diff --git a/web/src/ui/reader-screen.tsx b/web/src/ui/reader-screen.tsx
index 3bf535d..611cd3e 100644
--- a/web/src/ui/reader-screen.tsx
+++ b/web/src/ui/reader-screen.tsx
@@ -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 {
@@ -2256,6 +2250,7 @@ function emptyChromeSettings(): Partial {
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,
diff --git a/web/src/ui/tap-zones.ts b/web/src/ui/tap-zones.ts
new file mode 100644
index 0000000..066089c
--- /dev/null
+++ b/web/src/ui/tap-zones.ts
@@ -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(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 ?? '无操作';
+}
diff --git a/web/test/gestures.test.ts b/web/test/gestures.test.ts
index 89115d6..bc9f1d2 100644
--- a/web/test/gestures.test.ts
+++ b/web/test/gestures.test.ts
@@ -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
diff --git a/web/test/reader-chrome.test.ts b/web/test/reader-chrome.test.ts
index 374bc5f..4bd7f3b 100644
--- a/web/test/reader-chrome.test.ts
+++ b/web/test/reader-chrome.test.ts
@@ -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 }), stage, handlers: { ...handlers(), onSetting } }), container);
+ const cells = [...container.querySelectorAll('.tap-zones-grid .tap-zone-cell')];
+ expect(cells.map(cell => cell.textContent)).toEqual(['上一页', '菜单', '下一页', '上一页', '下一页', '下一页', '上一页', '下一页', '下一页']);
+
+ act(() => cells[8]!.click());
+ const nextChapter = [...document.querySelectorAll('.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('.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')!;
diff --git a/web/test/tap-zones.test.ts b/web/test/tap-zones.test.ts
new file mode 100644
index 0000000..39974da
--- /dev/null
+++ b/web/test/tap-zones.test.ts
@@ -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);
+ });
+});