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 ( +
+
+ 点击区域 + + +
+
+ {grid.map((action, index) => { + const locked = isLastMenuCell(grid, index); + const name = TAP_CELL_NAMES[index]!; + return ( + 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), + }))} + /> + ); + })} +
+

点格子选择动作。从右到左排版的书会自动左右镜像。

+
+ ); +} + 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); + }); +});