Skip to content

Commit 1d9a213

Browse files
committed
Merge develop into main: release v1.7.2
2 parents 0d51259 + dbbcc21 commit 1d9a213

51 files changed

Lines changed: 4043 additions & 1227 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# Changelog
22

3+
## [1.7.2] - 2026-06-13
4+
5+
### Added
6+
7+
- "Fill screen" zoom mode: fills one axis, overflows the other (#231)
8+
9+
### Fixed
10+
11+
- Keep Zoom and Original Size no longer pin pages to the top (#231)
12+
- Offset spreads button works again (#231)
13+
- "Zoom to fit" now works in continuous scroll mode (#231)
14+
- Night mode, invert, B&W, and OCR toggles show the correct state (#231)
15+
- WebDAV logins no longer silently fall back to anonymous (#229)
16+
17+
### Changed
18+
19+
- Continuous "Fit to width" is now "Fill screen" (#231)
20+
321
## [1.7.1] - 2026-06-10
422

523
### Fixed

CLAUDE.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -215,13 +215,16 @@ Tracked per volume in the `volumes` store:
215215
- Time spent reading (tracked by Timer component)
216216
- Last read date and current page
217217

218-
### Text Selection Handling
218+
### Reader Input Handling
219219

220-
The reader has complex text selection logic to prevent interference with drag panning:
220+
All reader gesture handling (pan, pinch, tap, swipe, wheel, keyboard) goes
221+
through the shared modules in `src/lib/reader/input/` — see
222+
**`docs/INPUT-CONTRACTS.md`** for the architecture and the contracts that
223+
must not break. Highlights:
221224

222-
- Pointer handlers skip pan initiation for `.textBox` targets (PagedViewport.svelte and the scroll readers), so text selection works inside boxes
223-
- Double-clicking text to select it does not trigger zoom (`.textBox` guard in Reader's onDoubleTap)
224-
- Text selection is only allowed within text boxes, not on background
225+
- `.textBox` is an input-routing protocol: double-tap there is the AnkiConnect capture gesture, mouse/pen drags are text selection (Yomitan/Migaku) — never pans, never zoom
226+
- Each surface owns its gestures via `PointerGestureTracker` config; Reader owns only keyboard + intent callbacks
227+
- Before starting any motion, handlers call their surface's `MotionGate` intent method instead of ad-hoc `finishNow()`/`stop()` combinations
225228

226229
### Modal Button Z-Index
227230

docs/INPUT-CONTRACTS.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Reader Input Contracts
2+
3+
How user input flows through the reader, and the contracts every input
4+
handler must respect. The modules in `src/lib/reader/input/` are the
5+
load-bearing implementation of this document — their doc comments carry the
6+
details; this file is the map.
7+
8+
## Architecture
9+
10+
```
11+
┌─────────────────────────────┐
12+
│ Reader.svelte │
13+
│ keyboard shortcuts, │
14+
│ page-flip + overlay │
15+
│ intents, volume nav │
16+
└─────────┬───────────────────┘
17+
props (callbacks) │ keyboard / $pagedZoom
18+
┌─────────────────────────┼─────────────────────────┐
19+
▼ ▼ ▼
20+
┌───────────────┐ ┌───────────────┐ ┌───────────────────┐
21+
│ PagedViewport │ │ VerticalScroll │ │ HorizontalScroll │
22+
│ │ │ Reader │ │ Reader │
23+
└───────┬───────┘ └───────┬───────┘ └─────────┬─────────┘
24+
│ each surface owns ALL of its pointer input │
25+
▼ ▼ ▼
26+
PointerGestureTracker · TapDiscriminator · classifySwipe · MotionGate
27+
(src/lib/reader/input/ — shared machinery)
28+
```
29+
30+
Each reading surface owns every gesture that starts on it. Reader owns
31+
keyboard shortcuts and supplies intent callbacks (`onPageFlip`,
32+
`onOverlayToggle`); it never touches pointer events.
33+
34+
## The shared machinery
35+
36+
| Module | Owns |
37+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
38+
| `pointer-tracker.ts` | THE pointer state machine: pan threshold, pinch upgrade/downgrade/re-baseline, capture policy, leak-proof window-level release, `wasDrag`/`wasPinch` |
39+
| `gesture-target.ts` | Element-role classification (`textbox` / `interactive` / `page`) and the keyboard-ignore guard |
40+
| `tap.ts` | Tap vs double-tap timing (300 ms), deferred vs immediate commit, text-box dismissal swallowing |
41+
| `swipe.ts` | Pure swipe-to-flip classification (edge gating #186, pinch suppression, thresholds) |
42+
| `motion-gate.ts` | The interrupt contract: what must stop before new motion starts (`beforeZoom` / `beforeManualPan` / `beforeAnimatedScroll` / `beforeNav`) |
43+
44+
Surfaces hold only **policy** (config objects) and **actuation** (scroll
45+
writes, camera moves); classification and lifecycle live in the shared
46+
modules.
47+
48+
## Contracts that must not break
49+
50+
### `.textBox` is an input-routing protocol, not styling
51+
52+
OCR text boxes own their gestures (see `gesture-target.ts`):
53+
54+
- **Double-tap on a text box is the AnkiConnect card-capture gesture.**
55+
TextBoxes.svelte handles it and `stopPropagation()`s; surfaces must also
56+
filter taps by role so pointer-based detection never zooms from a text
57+
box. Breaking this breaks users' Anki mining flow.
58+
- Mouse/pen drags on a text box are **text selection** (Yomitan/Migaku
59+
scanning) — never a pan. Single-finger **touch** is the exception: it
60+
pans everywhere, because touch has no drag-selection gesture.
61+
- After interacting with a text box, the next tap outside is a
62+
**dismissal** — it must not toggle the overlay
63+
(`TapDiscriminator.noteTextBoxInteraction`).
64+
65+
### Pinch always wins
66+
67+
Two pointers upgrade to pinch no matter where they pressed (text box
68+
included). Pointer-set changes re-baseline; dropping to one pointer
69+
ends the pinch. The survivor continues as a pan only on surfaces with
70+
incremental deltas (`pinchSurvivorPans` — paged); absolute-baseline
71+
surfaces (scroll readers) require a fresh press.
72+
73+
### Releases are window-level
74+
75+
`pointerup`/`pointercancel` listen on the window, always. A release landing
76+
on an overlay or outside the browser must still clean the pointer map —
77+
phantom entries get misread as pinches (this was a live production bug).
78+
79+
### One gesture at a time (MotionGate)
80+
81+
Every handler that starts motion opens with the gate call matching its
82+
intent. Never call `finishNow`/`stop`/`stopPan` combinations inline — add
83+
to the gate if a new intent appears.
84+
85+
### Swipe-to-flip is edge-gated (#186)
86+
87+
A touch swipe flips the page only if no pannable content was hidden in the
88+
swipe's direction **when the gesture began**. Gestures that pinched never
89+
flip (`tracker.wasPinch`).
90+
91+
### Tap timing differs by surface family — deliberately
92+
93+
- Scroll readers: **deferred** — overlay toggles 300 ms late so a
94+
double-tap (zoom) never flashes it.
95+
- Paged: **immediate** — overlay toggles instantly on every tap; a
96+
double-tap toggles twice (net zero) and zooms, reproducing the native
97+
click/click/dblclick sequence paged mode has always had.
98+
99+
### Settle reasons gate progress reporting
100+
101+
Zoom settles carry a `SettleReason` (`zoom-controller.ts`): only
102+
`'gesture'`/`'interrupt'` settles report reading progress; `'nav'` and
103+
`'reset'` settles are superseded by whatever caused them.
104+
105+
## Per-surface policy matrix
106+
107+
| Policy | PagedViewport | Scroll readers |
108+
| -------------- | --------------------------------- | --------------------------------------- |
109+
| Capture | deferred (at drag threshold) | immediate (at pan press) |
110+
| Pan deltas | incremental → `camera.adjustView` | totals → absolute scroll from baselines |
111+
| Text-box pan | suppressed for mouse/pen only | suppressed for all pointer types |
112+
| Pinch survivor | keeps panning | ignored until fresh press |
113+
| Tap commit | immediate | deferred (300 ms) |
114+
| Swipe-to-flip | yes (mobile setting, edge-gated) | no (panning is the scroll) |
115+
| Wheel | zoom or camera glide | zoom or (native/strip) scroll |
116+
117+
## Testing
118+
119+
Unit tests cover the shared machinery (`src/lib/reader/input/*.test.ts`,
120+
`src/lib/reader/page-nav.test.ts`); Playwright e2e covers zoom geometry
121+
(`e2e/zoom.spec.ts`). When changing gesture behavior, test manually with a
122+
Japanese-learning extension (Yomitan/Migaku) enabled and verify the Anki
123+
double-tap flow still captures.

e2e/zoom.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,58 @@ test('paged keepZoom: effective scale survives a content swap (spread)', async (
574574
expect(r.effectiveAfter).toBeCloseTo(r.effectiveBefore, 3);
575575
});
576576

577+
test('paged fillScreen: tall page fills the width and pans to the bottom; small pages center', async ({
578+
page
579+
}) => {
580+
await setupPagedWorld(page, { rtl: true, mode: 'zoomFillScreen' });
581+
const r = await page.evaluate(async () => {
582+
const z = (window as any).__paged;
583+
// 1400x2000 page: taller than the viewport aspect → width must fill.
584+
const rect0 = z.pageEl.getBoundingClientRect();
585+
const widthFills = Math.abs(rect0.width - innerWidth) < 1.5;
586+
const startsAtTop = Math.abs(rect0.top) < 1.5;
587+
588+
// The overflowing height is pannable all the way to the bottom edge.
589+
z.camera.adjustView(0, rect0.height * 2); // grossly past the end — clamps
590+
const rect1 = z.pageEl.getBoundingClientRect();
591+
const bottomReachable = Math.abs(rect1.bottom - innerHeight) < 1.5;
592+
593+
// A page smaller than the viewport centers — never welded to the corner
594+
// (the keepZoom/original top-pin bug class).
595+
z.pageEl.style.width = '400px';
596+
z.pageEl.style.height = '300px';
597+
z.applyBase({ width: 400, height: 300 });
598+
z.controller.snapToLevel(z.session.pagedLevels(z.state.baseScale, z.state.fitScale)[0]);
599+
const rect2 = z.pageEl.getBoundingClientRect();
600+
const centeredX = Math.abs(rect2.left - (innerWidth - rect2.width) / 2) < 1.5;
601+
return { widthFills, startsAtTop, bottomReachable, centeredX, w: rect2.width };
602+
});
603+
expect(r.widthFills).toBe(true);
604+
expect(r.startsAtTop).toBe(true);
605+
expect(r.bottomReachable).toBe(true);
606+
expect(r.centeredX).toBe(true);
607+
});
608+
609+
test('paged zoomOriginal: a page smaller than the viewport centers and zooming out never pins it', async ({
610+
page
611+
}) => {
612+
await setupPagedWorld(page, { rtl: true, mode: 'zoomOriginal' });
613+
const r = await page.evaluate(async () => {
614+
const z = (window as any).__paged;
615+
// Swap in a page smaller than the viewport at 1:1.
616+
z.pageEl.style.width = '600px';
617+
z.pageEl.style.height = '500px';
618+
z.applyBase({ width: 600, height: 500 });
619+
const rect = z.pageEl.getBoundingClientRect();
620+
return {
621+
cx: rect.left + rect.width / 2,
622+
cy: rect.top + rect.height / 2
623+
};
624+
});
625+
expect(r.cx).toBeCloseTo(1920 / 2, 0);
626+
expect(r.cy).toBeCloseTo(1080 / 2, 0);
627+
});
628+
577629
test('vertical fit-to-screen: side margins are not pannable while zoomed', async ({ page }) => {
578630
// Narrow fixed-size pages (560px in a 1920px viewport): at 2x the scaled
579631
// content (1120px) still fits, so there must be NO horizontal scroll range

package-lock.json

Lines changed: 62 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mokuro-reader",
3-
"version": "1.7.1",
3+
"version": "1.7.2",
44
"private": true,
55
"scripts": {
66
"dev": "vite dev",

src/app.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,12 @@ input[type='radio']:checked::after {
140140
display: none !important;
141141
content: none !important;
142142
}
143+
144+
/* Scrollable reader strips hide their scrollbars (used by both scroll readers). */
145+
.scrollbar-hide {
146+
scrollbar-width: none;
147+
-ms-overflow-style: none;
148+
}
149+
.scrollbar-hide::-webkit-scrollbar {
150+
display: none;
151+
}

0 commit comments

Comments
 (0)