mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 05:50:54 +08:00
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:
1 parent
bbc0ec0ee9
commit
a904bb5a83
10 files changed
+323
-48
No files matched your search
+15
-3
@@ -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,那一行是恒等
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? '无操作';
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')!;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user