diff --git a/apps/readest-app/docs/superpowers/plans/2026-05-29-line-aware-reading-ruler.md b/apps/readest-app/docs/superpowers/plans/2026-05-29-line-aware-reading-ruler.md new file mode 100644 index 00000000..b3e0c5e0 --- /dev/null +++ b/apps/readest-app/docs/superpowers/plans/2026-05-29-line-aware-reading-ruler.md @@ -0,0 +1,613 @@ +# Line-Aware Reading Ruler Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the reading ruler snap to the next group of *actual* rendered text lines on each tap/page-change so lines stay centered in the band, eliminating the drift caused by the current arithmetic step. + +**Architecture:** Two new pure functions in `src/app/reader/utils/readingRuler.ts` derive line boxes from `progress.range.getClientRects()` and compute a snapped band center. `ReadingRuler.tsx` caches the line boxes per page and calls the snap function from both the tap handler and the page-change auto-move, falling back to the existing arithmetic step when line geometry is unavailable (scrolled mode, fixed-layout, missing range). + +**Tech Stack:** TypeScript, React, Vitest, foliate-js paginator (`progress.range`). + +**Design spec:** `docs/superpowers/specs/2026-05-29-line-aware-reading-ruler-design.md` + +--- + +## File Structure + +- `src/app/reader/utils/readingRuler.ts` (modify) — add `ReadingRulerLineBox` type, `READING_RULER_LINE_PADDING_PX`, `buildLineBoxes`, `snapReadingRulerToLines`. Pure, no DOM. +- `src/__tests__/utils/readingRuler.test.ts` (modify) — unit tests for the two new functions. +- `src/app/reader/components/ReadingRuler.tsx` (modify) — glue: padded band size, per-page line-box cache, snap in tap handler + page-change auto-move, fallbacks. + +--- + +## Task 1: `ReadingRulerLineBox` type, padding constant, and `buildLineBoxes` + +**Files:** +- Modify: `src/app/reader/utils/readingRuler.ts` +- Test: `src/__tests__/utils/readingRuler.test.ts` + +`buildLineBoxes` converts per-fragment client rects (from `range.getClientRects()`) into sorted visual-line spans along the ruler axis, in container coordinates. It clusters fragments that belong to the same visual line (high overlap on the ruler axis) and maps coordinates exactly as the existing auto-move does: +- horizontal: span = `[rect.top - containerRect.top, rect.bottom - containerRect.top]` +- vertical-rl (`rtl=true`): span = `[containerRect.right - rect.right, containerRect.right - rect.left]` +- vertical-lr (`rtl=false`): span = `[rect.left - containerRect.left, rect.right - containerRect.left]` + +- [ ] **Step 1: Write the failing tests** + +Add these imports and tests to `src/__tests__/utils/readingRuler.test.ts`. Update the existing top import block to also import the new symbols: + +```typescript +import { + buildLineBoxes, + calculateReadingRulerSize, + clampReadingRulerPosition, + FIXED_LAYOUT_READING_RULER_LINE_HEIGHT, + getReadingRulerMoveDirection, + READING_RULER_LINE_PADDING_PX, + snapReadingRulerToLines, + stepReadingRulerPosition, +} from '@/app/reader/utils/readingRuler'; +``` + +Then add a new `describe` block at the end of the file: + +```typescript +type RectLike = { + top: number; + bottom: number; + left: number; + right: number; + width: number; + height: number; +}; + +const rect = (top: number, left: number, height: number, width: number): RectLike => ({ + top, + left, + bottom: top + height, + right: left + width, + height, + width, +}); + +const container = { top: 0, left: 0, right: 300, bottom: 400 }; + +describe('buildLineBoxes', () => { + it('clusters horizontal fragments into one box per visual line', () => { + const rects = [ + rect(0, 10, 16, 50), // line 1, fragment A + rect(0, 60, 16, 40), // line 1, fragment B (same vertical band) + rect(20, 10, 16, 80), // line 2 + ]; + expect(buildLineBoxes(rects, false, false, container)).toEqual([ + { start: 0, end: 16 }, + { start: 20, end: 36 }, + ]); + }); + + it('ignores zero-size rects', () => { + const rects = [rect(0, 10, 16, 50), rect(20, 0, 0, 0), rect(40, 10, 16, 50)]; + expect(buildLineBoxes(rects, false, false, container)).toEqual([ + { start: 0, end: 16 }, + { start: 40, end: 56 }, + ]); + }); + + it('returns sorted boxes even when rects are out of order', () => { + const rects = [rect(40, 10, 16, 50), rect(0, 10, 16, 50), rect(20, 10, 16, 50)]; + expect(buildLineBoxes(rects, false, false, container).map((b) => b.start)).toEqual([0, 20, 40]); + }); + + it('maps vertical-rl columns as distance from the right edge', () => { + // container.right = 300; a column at left=260,right=276 -> [300-276, 300-260] = [24, 40] + const rects = [rect(0, 260, 200, 16)]; + expect(buildLineBoxes(rects, true, true, container)).toEqual([{ start: 24, end: 40 }]); + }); + + it('maps vertical-lr columns as distance from the left edge', () => { + const rects = [rect(0, 24, 200, 16)]; + expect(buildLineBoxes(rects, true, false, container)).toEqual([{ start: 24, end: 40 }]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test src/__tests__/utils/readingRuler.test.ts` +Expected: FAIL — `buildLineBoxes`, `READING_RULER_LINE_PADDING_PX`, `snapReadingRulerToLines` are not exported (also a build/type error on the import). + +- [ ] **Step 3: Implement the type, constant, and `buildLineBoxes`** + +In `src/app/reader/utils/readingRuler.ts`, add below the existing `FIXED_LAYOUT_READING_RULER_LINE_HEIGHT` constant (line 3): + +```typescript +// Extra band height (px) added so the centered lines clear the band edges. +export const READING_RULER_LINE_PADDING_PX = 6; + +export interface ReadingRulerLineBox { + start: number; + end: number; +} + +type RulerRect = { + top: number; + bottom: number; + left: number; + right: number; + width: number; + height: number; +}; + +type RulerContainerRect = { top: number; left: number; right: number }; + +/** + * Convert per-fragment client rects into sorted visual-line spans along the + * ruler axis, in container coordinates. Fragments that overlap by more than + * half of the smaller fragment on the ruler axis are treated as one line. + */ +export const buildLineBoxes = ( + rects: RulerRect[], + isVertical: boolean, + rtl: boolean, + containerRect: RulerContainerRect, +): ReadingRulerLineBox[] => { + const spans: ReadingRulerLineBox[] = []; + for (const r of rects) { + if (!r || r.width <= 0 || r.height <= 0) continue; + let start: number; + let end: number; + if (isVertical) { + if (rtl) { + start = containerRect.right - r.right; + end = containerRect.right - r.left; + } else { + start = r.left - containerRect.left; + end = r.right - containerRect.left; + } + } else { + start = r.top - containerRect.top; + end = r.bottom - containerRect.top; + } + if (end < start) [start, end] = [end, start]; + spans.push({ start, end }); + } + + spans.sort((a, b) => a.start - b.start || a.end - b.end); + + const lines: ReadingRulerLineBox[] = []; + for (const span of spans) { + const current = lines[lines.length - 1]; + if (current) { + const overlap = Math.min(current.end, span.end) - Math.max(current.start, span.start); + const minHeight = Math.min(current.end - current.start, span.end - span.start); + if (overlap > 0.5 * minHeight) { + current.start = Math.min(current.start, span.start); + current.end = Math.max(current.end, span.end); + continue; + } + } + lines.push({ start: span.start, end: span.end }); + } + + return lines; +}; +``` + +- [ ] **Step 4: Run tests to verify the `buildLineBoxes` tests pass** + +Run: `pnpm test src/__tests__/utils/readingRuler.test.ts` +Expected: the `buildLineBoxes` describe block PASSES. (The `snapReadingRulerToLines` import still makes the file fail to compile — that's fixed in Task 2. If the runner refuses to run due to the missing export, temporarily comment out the `snapReadingRulerToLines` import line to confirm, then restore it.) + +- [ ] **Step 5: Commit** + +```bash +git add apps/readest-app/src/app/reader/utils/readingRuler.ts apps/readest-app/src/__tests__/utils/readingRuler.test.ts +git commit -m "feat(ruler): add buildLineBoxes for line-aware ruler geometry" +``` + +--- + +## Task 2: `snapReadingRulerToLines` + +**Files:** +- Modify: `src/app/reader/utils/readingRuler.ts` +- Test: `src/__tests__/utils/readingRuler.test.ts` + +Given the current band center, viewport dimension, padded band size, line count, direction, and the line boxes, return the next band center (px) centered on the next `lines`-line block — or `null` when there is no next group (caller falls back to a page flip). + +- [ ] **Step 1: Write the failing tests** + +Append to `src/__tests__/utils/readingRuler.test.ts`: + +```typescript +describe('snapReadingRulerToLines', () => { + // 10 lines, each 16px tall, 20px apart, starting at 0. + const evenBoxes = [0, 20, 40, 60, 80, 100, 120, 140, 160, 180].map((s) => ({ + start: s, + end: s + 16, + })); + + it('returns null when there are no line boxes', () => { + expect(snapReadingRulerToLines(100, 400, 40, 2, 'forward', [])).toBeNull(); + }); + + it('advances forward to the next block of N lines, centered on the block', () => { + // band center 20 -> band [0,40]; next group starts at line index 2 (start 40), + // block = lines[2..3] => [40, 76], center = 58. + expect(snapReadingRulerToLines(20, 400, 40, 2, 'forward', evenBoxes)).toBe(58); + }); + + it('moves backward to the previous block of N lines, centered on the block', () => { + const boxes = [40, 60, 80, 100, 120, 140, 160, 180, 200, 220].map((s) => ({ + start: s, + end: s + 16, + })); + // band center 200 -> band [180,220]; last line fully above is index 6 (end 176), + // block = lines[5..6] => [140, 176], center = 158. + expect(snapReadingRulerToLines(200, 400, 40, 2, 'backward', boxes)).toBe(158); + }); + + it('returns null at the bottom boundary so the page can flip', () => { + const boxes = [ + { start: 0, end: 16 }, + { start: 20, end: 36 }, + ]; + // band center 100 -> band [80,120]; no line starts below -> null. + expect(snapReadingRulerToLines(100, 200, 40, 2, 'forward', boxes)).toBeNull(); + }); + + it('returns null at the top boundary so the page can flip', () => { + const boxes = [ + { start: 80, end: 96 }, + { start: 100, end: 116 }, + ]; + // band center 100 -> band [80,120]; no line ends above -> null. + expect(snapReadingRulerToLines(100, 200, 40, 2, 'backward', boxes)).toBeNull(); + }); + + it('clamps the snapped center so the band stays inside the viewport', () => { + const boxes = [{ start: 180, end: 196 }]; + // forward target block center = 188, but dimension 200 / size 40 clamps to 180. + expect(snapReadingRulerToLines(100, 200, 40, 2, 'forward', boxes)).toBe(180); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test src/__tests__/utils/readingRuler.test.ts` +Expected: FAIL — `snapReadingRulerToLines is not a function` (export missing). + +- [ ] **Step 3: Implement `snapReadingRulerToLines`** + +In `src/app/reader/utils/readingRuler.ts`, add after `stepReadingRulerPosition`: + +```typescript +const clampCenterPx = (center: number, dimension: number, rulerSize: number): number => { + const half = rulerSize / 2; + if (half * 2 >= dimension) return dimension / 2; + return Math.max(half, Math.min(dimension - half, center)); +}; + +/** + * Snap the ruler band to the next/previous block of `lines` real text lines, + * centered on that block. Returns the new band center in px, or null when there + * is no next group in the given direction (the caller then flips the page). + */ +export const snapReadingRulerToLines = ( + currentCenterPx: number, + dimension: number, + rulerSize: number, + lines: number, + direction: 'backward' | 'forward', + lineBoxes: ReadingRulerLineBox[], +): number | null => { + if (lineBoxes.length === 0 || dimension <= 0) return null; + + const count = Math.max(1, Math.floor(lines)); + const heights = lineBoxes + .map((b) => b.end - b.start) + .filter((h) => h > 0) + .sort((a, b) => a - b); + const medianHeight = heights.length ? heights[Math.floor(heights.length / 2)] : 0; + const eps = medianHeight * 0.3; + + const half = rulerSize / 2; + const bandStart = currentCenterPx - half; + const bandEnd = currentCenterPx + half; + + if (direction === 'forward') { + const startIdx = lineBoxes.findIndex((b) => b.start >= bandEnd - eps); + if (startIdx === -1) return null; + const endIdx = Math.min(startIdx + count - 1, lineBoxes.length - 1); + const center = (lineBoxes[startIdx].start + lineBoxes[endIdx].end) / 2; + return clampCenterPx(center, dimension, rulerSize); + } + + let endIdx = -1; + for (let i = lineBoxes.length - 1; i >= 0; i--) { + if (lineBoxes[i].end <= bandStart + eps) { + endIdx = i; + break; + } + } + if (endIdx === -1) return null; + const startIdx = Math.max(endIdx - count + 1, 0); + const center = (lineBoxes[startIdx].start + lineBoxes[endIdx].end) / 2; + return clampCenterPx(center, dimension, rulerSize); +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm test src/__tests__/utils/readingRuler.test.ts` +Expected: PASS (all describe blocks, including the original ones). + +- [ ] **Step 5: Commit** + +```bash +git add apps/readest-app/src/app/reader/utils/readingRuler.ts apps/readest-app/src/__tests__/utils/readingRuler.test.ts +git commit -m "feat(ruler): add snapReadingRulerToLines for line-aware stepping" +``` + +--- + +## Task 3: Wire line-aware snapping into `ReadingRuler.tsx` + +**Files:** +- Modify: `src/app/reader/components/ReadingRuler.tsx` + +Glue the pure functions in: padded band size for snap-capable books, a per-page line-box cache, and snapping in both the tap handler and the page-change auto-move, with the existing arithmetic path as fallback. + +This task is verified by `pnpm lint` + `pnpm test` (the pure logic is fully covered by Tasks 1–2) and a manual check in the reader, since the behavior depends on live DOM range geometry that unit tests cannot reproduce. + +- [ ] **Step 1: Update imports** + +In `src/app/reader/components/ReadingRuler.tsx`, change the `@/types/book` import (line 4) from: + +```typescript +import { BookFormat, ViewSettings } from '@/types/book'; +``` + +to: + +```typescript +import { BookFormat, FIXED_LAYOUT_FORMATS, ViewSettings } from '@/types/book'; +``` + +And change the `../utils/readingRuler` import (lines 12-16) from: + +```typescript +import { + calculateReadingRulerSize, + clampReadingRulerPosition, + stepReadingRulerPosition, +} from '../utils/readingRuler'; +``` + +to: + +```typescript +import { + buildLineBoxes, + calculateReadingRulerSize, + clampReadingRulerPosition, + READING_RULER_LINE_PADDING_PX, + ReadingRulerLineBox, + snapReadingRulerToLines, + stepReadingRulerPosition, +} from '../utils/readingRuler'; +``` + +- [ ] **Step 2: Compute `supportsLineSnap` and padded `rulerSize`** + +Replace line 61: + +```typescript + const rulerSize = calculateReadingRulerSize(lines, viewSettings, bookFormat); +``` + +with: + +```typescript + const supportsLineSnap = !viewSettings.scrolled && !FIXED_LAYOUT_FORMATS.has(bookFormat); + const baseRulerSize = calculateReadingRulerSize(lines, viewSettings, bookFormat); + const rulerSize = baseRulerSize + (supportsLineSnap ? READING_RULER_LINE_PADDING_PX : 0); +``` + +- [ ] **Step 3: Add the line-box cache ref** + +After the `currentPositionRef` declaration (line 59), add: + +```typescript + const lineBoxesRef = useRef([]); +``` + +- [ ] **Step 4: Keep the line-box cache in sync per page** + +Immediately after the container-size effect (the `useEffect` that ends at line 118, returning `() => resizeObserver.disconnect()`), add a new effect: + +```typescript + // Cache the visible line boxes for the current page so taps can snap to real lines. + useEffect(() => { + if (!supportsLineSnap) { + lineBoxesRef.current = []; + return; + } + const range = progress?.range ?? null; + const containerRect = containerRef.current?.getBoundingClientRect(); + if (!range || !containerRect) { + lineBoxesRef.current = []; + return; + } + try { + const rects = Array.from(range.getClientRects()); + lineBoxesRef.current = buildLineBoxes(rects, isVertical, rtl, containerRect); + } catch { + lineBoxesRef.current = []; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + progress?.range, + progress?.pageinfo?.current, + containerSize.width, + containerSize.height, + isVertical, + rtl, + supportsLineSnap, + ]); +``` + +- [ ] **Step 5: Snap on page-change auto-move** + +Replace the `performAutoMove` function body (lines 193-215) with the version below. It tries the line snap first and keeps the existing first-visible-text offset as a fallback: + +```typescript + const performAutoMove = (range: Range | null) => { + const containerRect = containerRef.current?.getBoundingClientRect(); + if (!containerRect) return; + + const containerDimension = isVertical ? containerRect.width : containerRect.height; + if (containerDimension <= 0) return; + + if (supportsLineSnap && range) { + try { + const rects = Array.from(range.getClientRects()); + const boxes = buildLineBoxes(rects, isVertical, rtl, containerRect); + lineBoxesRef.current = boxes; + // Align to the first line group from the top of the page. + const snapped = snapReadingRulerToLines( + -rulerSize, + containerDimension, + rulerSize, + lines, + 'forward', + boxes, + ); + if (snapped != null) { + setRulerPosition((snapped / containerDimension) * 100, true); + return; + } + } catch { + /* fall through to default offset */ + } + } + + const textPosition = getFirstVisibleTextPosition(range); + // For vertical mode: use marginRight for vertical-rl, marginLeft for vertical-lr + const defaultOffset = isVertical + ? rtl + ? (viewSettings.marginRightPx ?? 44) + : (viewSettings.marginLeftPx ?? 44) + : (viewSettings.marginTopPx ?? 44); + + const offset = textPosition ?? defaultOffset; + const targetPosition = clampPosition( + ((offset + rulerSize / 2) / containerDimension) * 100, + containerDimension, + ); + + setRulerPosition(targetPosition, true); + }; +``` + +Then add `lines` and `supportsLineSnap` to the auto-move effect's dependency array (the array currently ending at lines 232-242). It should read: + +```typescript + }, [ + progress?.pageinfo?.current, + viewSettings.scrolled, + isVertical, + rtl, + viewSettings.marginTopPx, + viewSettings.marginLeftPx, + viewSettings.marginRightPx, + rulerSize, + lines, + supportsLineSnap, + setRulerPosition, + ]); +``` + +- [ ] **Step 6: Snap in the tap/key move handler** + +In the `reading-ruler-move` effect, replace the body from the `const nextPosition = stepReadingRulerPosition(...)` block through the `return true;` (lines 400-412) with: + +```typescript + let nextPosition: number; + if (supportsLineSnap && lineBoxesRef.current.length > 0) { + const currentCenterPx = (currentPositionRef.current / 100) * dimension; + const snapped = snapReadingRulerToLines( + currentCenterPx, + dimension, + rulerSize, + lines, + detail.direction, + lineBoxesRef.current, + ); + // No next line group in this direction: let the page flip instead. + if (snapped == null) return false; + nextPosition = (snapped / dimension) * 100; + } else { + nextPosition = stepReadingRulerPosition( + currentPositionRef.current, + dimension, + rulerSize, + detail.direction, + ); + } + + if (Math.abs(nextPosition - currentPositionRef.current) < 0.001) { + return false; + } + + setRulerPosition(nextPosition, true); + return true; +``` + +Then add `lines` and `supportsLineSnap` to that effect's dependency array (currently line 419) so it reads: + +```typescript + }, [ + bookKey, + containerSize.height, + containerSize.width, + isVertical, + lines, + rulerSize, + supportsLineSnap, + setRulerPosition, + ]); +``` + +- [ ] **Step 7: Type-check and lint** + +Run: `pnpm lint` +Expected: PASS (no Biome errors, no tsgo type errors). If tsgo complains that `ReadingRulerLineBox` is unused, confirm Step 3 added the `lineBoxesRef` typed with it. + +- [ ] **Step 8: Run the full unit suite** + +Run: `pnpm test` +Expected: PASS — no regressions. + +- [ ] **Step 9: Manual verification in the reader** + +Start the web dev server (`pnpm dev-web`), open a reflowable EPUB, enable the reading ruler (Settings → Color/Layout → Reading Ruler), and confirm: +- Tapping the page advances the band so the next lines sit centered in it, with no manual adjustment needed across many taps. +- At the bottom of a page, a forward tap flips the page and the band lands centered on the first lines of the new page. +- Backward taps and (if available) a vertical-writing-mode book behave symmetrically. +- A fixed-layout PDF still uses the old fixed-step behavior (no errors). + +- [ ] **Step 10: Commit** + +```bash +git add apps/readest-app/src/app/reader/components/ReadingRuler.tsx +git commit -m "feat(ruler): snap reading ruler to real text lines on tap and page change" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** snapping rule (Tasks 1–2), fixed-size + padding (`READING_RULER_LINE_PADDING_PX`, Task 3 Step 2), reflowable + vertical scope (`buildLineBoxes` mapping + `supportsLineSnap`), fallback matrix (scrolled/fixed-layout/missing range/boundary all route to `stepReadingRulerPosition` or page flip), per-page caching (Task 3 Step 4). All covered. +- **Type consistency:** `ReadingRulerLineBox { start; end }`, `buildLineBoxes(rects, isVertical, rtl, containerRect)`, and `snapReadingRulerToLines(currentCenterPx, dimension, rulerSize, lines, direction, lineBoxes)` are used identically in tests and glue. +- **No placeholders:** every code step contains complete code and exact run commands. diff --git a/apps/readest-app/docs/superpowers/specs/2026-05-29-line-aware-reading-ruler-design.md b/apps/readest-app/docs/superpowers/specs/2026-05-29-line-aware-reading-ruler-design.md new file mode 100644 index 00000000..ac552d1c --- /dev/null +++ b/apps/readest-app/docs/superpowers/specs/2026-05-29-line-aware-reading-ruler-design.md @@ -0,0 +1,153 @@ +# Line-Aware Reading Ruler — Design + +**Date:** 2026-05-29 +**Status:** Approved (pending spec review) + +## Problem + +The reading ruler is a band overlay that helps the user track lines while reading. +Tapping the screen advances the band forward/backward by exactly one ruler height +(`stepReadingRulerPosition` in `src/app/reader/utils/readingRuler.ts`), where the +ruler height is computed arithmetically as `lines × fontSize × lineHeight`. + +That arithmetic height rarely matches the *actual* rendered line-to-line distance: +fonts, inline images, headings, ruby text, and CSS overrides all shift real line +positions. The error accumulates across taps, so text drifts out of the band's +center and the user must manually drag the ruler to recenter it — which makes the +feature tedious and undercuts its purpose. + +The on-page-change auto-move (`ReadingRuler.tsx`, the `getFirstVisibleTextPosition` +effect) already aligns to the first visible line using real geometry, but +within-page taps do not. + +## Goal + +Make tap-advance **line-aware**: snap the band to the next group of `lines` *actual* +rendered lines, centered on that block, so text stays centered in the band without +manual adjustment. Drift is eliminated because every step is computed from real +line geometry rather than an accumulating arithmetic offset. + +## Decisions (locked) + +- **Sizing model:** Fixed band size — keep the configured band height; snap *position* + to real lines rather than dynamically resizing per tap. A small constant padding is + added to the rendered band so the centered lines are fully contained with breathing + room. +- **Scope:** Reflowable horizontal **and** vertical writing mode. Fixed-layout + (PDF/CBZ) and scrolled mode keep the current arithmetic stepping as fallback. +- **Geometry source:** Reuse `progress.range` (Approach A). `progress.range` already + spans the entire first-to-last *visible* text (foliate `#getVisibleRange`, + `packages/foliate-js/paginator.js`), and the existing auto-move already reads + `progress.range.getClientRects()` mapped to container coords. Since a paginated page + does not scroll, those rects are stable for every tap on that page. + +## Snapping rule + +The band advances by exactly `lines` *real* lines per tap and is centered on that +block's midpoint: + +- **Forward:** find the first line whose start is at/after the current band's far edge + (within a small epsilon). Take that line plus the next `lines − 1` lines to form a + block `[blockStart, blockEnd]`. The new band center is `(blockStart + blockEnd) / 2`, + clamped to the viewport. +- **Backward:** symmetric — find the `lines` lines ending just before the current + band's near edge and center on their block midpoint. +- **No next group** (already at the last/first line group, or no line geometry): + return `null`. The caller falls back to today's behavior — page flip on tap, or + `stepReadingRulerPosition` where appropriate. + +"Center on the block midpoint" guarantees that, regardless of how the configured band +height compares to the real line block, the lines sit centered with equal clipping on +either side in the worst case. + +## Architecture + +### Pure logic — `src/app/reader/utils/readingRuler.ts` + +Two new pure functions (no DOM), unit-tested before implementation: + +- `buildLineBoxes(rects, isVertical, rtl, containerRect): LineBox[]` + - Input: plain rect-like objects (from `range.getClientRects()`), orientation flags, + and the container rect for coordinate mapping. + - Clusters per-fragment rects into visual lines by overlap on the cross axis + (horizontal: cluster by vertical overlap → line tops/bottoms; vertical: cluster by + horizontal overlap → column spans). Maps to the ruler axis in container coordinates, + matching the existing auto-move mapping (`rect.top − containerRect.top` for + horizontal; distance-from-edge for vertical-rl/lr). + - Returns a sorted array of `{ start, end }` spans along the ruler axis. + +- `snapReadingRulerToLines(currentCenterPx, dimension, lines, direction, lineBoxes): number | null` + - Implements the snapping rule above. Returns the next band center in px, or `null` + when there is no next group to advance to. + +Existing `clampReadingRulerPosition`, `stepReadingRulerPosition`, +`getReadingRulerMoveDirection`, and `calculateReadingRulerSize` are unchanged and remain +the fallback path. + +### Glue — `src/app/reader/components/ReadingRuler.tsx` + +- Memoize line boxes per page: rebuild from `progress.range.getClientRects()` when the + page (`progress.pageinfo.current`) / range changes or the container size changes. +- The page-change auto-move effect and the `reading-ruler-move` tap handler both: + 1. Compute the snap target via `snapReadingRulerToLines`. + 2. If it returns a value, animate the band to it (existing `setRulerPosition(_, true)`). + 3. If it returns `null`, fall back to the current logic + (`stepReadingRulerPosition` / no-op so the page flip proceeds). +- Render the band at `rulerSize + READING_RULER_LINE_PADDING_PX` so centered lines are + fully contained. The padding affects only the rendered band and its overlay/clamp + math, not the line-advance computation. + +### Fallback matrix + +| Condition | Behavior | +| -------------------------------------- | --------------------------------- | +| Reflowable/vertical, line boxes found | Line-aware snap | +| `progress.range` missing / no rects | `stepReadingRulerPosition` (arith)| +| Scrolled mode | Existing behavior (unchanged) | +| Fixed-layout (PDF/CBZ) | `stepReadingRulerPosition` (arith)| +| Snap returns `null` (at boundary) | Page flip / no movement (as today)| + +## Testing + +Test-first, per project rule. Pure-function unit tests added to the existing +`src/__tests__/utils/readingRuler.test.ts`: + +- `buildLineBoxes`: clusters multi-fragment rects into correct line spans; horizontal vs + vertical-rl vs vertical-lr mapping; ignores zero-size rects; sorted output. +- `snapReadingRulerToLines`: forward/backward advance by exactly `lines`; centers on + block midpoint; returns `null` at boundaries; respects clamping; degenerate inputs + (empty `lineBoxes`, `lines` larger than available). + +Then implement and verify with `pnpm test` and `pnpm lint`. + +## Multi-column layouts (column-aware band) + +In paginated layouts that render more than one column (`view.renderer.columnCount > 1`), +clustering visible lines by vertical position alone tangles the two columns once their +line grids drift apart (headings/images), causing the band to jump erratically. + +For multi-column horizontal layouts the ruler is **column-aware and spans one column at +a time**: + +- `buildReadingRulerColumns(rects, columnCount, overlayWidth, rtl)` groups the + overlay-relative rects into columns by x (bucketed by `overlayWidth / columnCount`), + then into line boxes within each column, returned in reading order. +- Rects are mapped to overlay coordinates with the iframe's frame offset + (`frameRect.left/top`), because paginated multi-column pages shift the iframe far + off-screen horizontally; vertical-only mapping is insufficient here. +- `snapReadingRulerColumns(columnIndex, centerPx, …, columns)` advances within the + active column; at the column's end it moves to the first/last group of the + next/previous column; past the last/first column it returns `null` → page flip. +- The band renders over the active column's horizontal extent; the rest of the page — + **including the inactive column** — is dimmed (top/bottom/left/right dim rects). +- Single column collapses to the full-width band (one column spanning the viewport); + vertical writing mode keeps the flat `buildLineBoxes`/`snapReadingRulerToLines` path. + +## Non-goals / YAGNI + +- No dynamic per-tap band resizing. +- No new foliate-js APIs or TreeWalker enumeration (Approach B) — reuse `progress.range`. +- No `caretPositionFromPoint` probing (Approach C). +- No changes to scrolled mode or fixed-layout ruler behavior beyond keeping them on the + existing fallback path. +- No new user-facing settings. diff --git a/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx b/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx index ea24fe55..efb86ebc 100644 --- a/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx +++ b/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx @@ -13,6 +13,7 @@ vi.mock('@/context/EnvContext', () => ({ vi.mock('@/store/readerStore', () => ({ useReaderStore: () => ({ getProgress: () => null, + getView: () => ({ renderer: { columnCount: 1 } }), }), })); @@ -116,7 +117,10 @@ describe('ReadingRuler', () => { const ruler = container.querySelector('.ruler') as HTMLDivElement; const overlays = container.querySelectorAll('.bg-base-100'); - expect(ruler.style.top).toBe('37.8%'); + // With no progress range available the handler falls back to fixed + // stepping; the fallback band size is base + 2*padding = 48 + 2*7 = 62, + // so a 1000px viewport steps from 33% to (330 + 62) / 1000 = 39.2%. + expect(parseFloat(ruler.style.top)).toBeCloseTo(39.2, 5); expect(ruler.style.transition).toContain('top 0.6s'); expect(overlays[0]?.getAttribute('style')).toContain('transition: height 0.6s'); expect(overlays[1]?.getAttribute('style')).toContain('transition: height 0.6s'); @@ -124,7 +128,7 @@ describe('ReadingRuler', () => { {}, 'book-1', 'readingRulerPosition', - 37.8, + expect.closeTo(39.2), false, false, ); @@ -165,7 +169,7 @@ describe('ReadingRuler', () => { isVertical={false} rtl={false} lines={2} - position={97.6} + position={96.9} opacity={0.5} color='transparent' bookFormat='EPUB' @@ -174,6 +178,8 @@ describe('ReadingRuler', () => { />, ); + // 96.9% is the clamp max for the fallback band of 62px in a 1000px viewport + // (100 - (62 / 2 / 1000) * 100), so a forward step cannot advance further. const consumed = eventDispatcher.dispatchSync('reading-ruler-move', { bookKey: 'book-1', direction: 'forward', diff --git a/apps/readest-app/src/__tests__/utils/readingRuler.test.ts b/apps/readest-app/src/__tests__/utils/readingRuler.test.ts index 23b58670..85f53127 100644 --- a/apps/readest-app/src/__tests__/utils/readingRuler.test.ts +++ b/apps/readest-app/src/__tests__/utils/readingRuler.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from 'vitest'; import { + buildLineBoxes, + buildReadingRulerColumns, + calculateReadingRulerPadding, calculateReadingRulerSize, clampReadingRulerPosition, FIXED_LAYOUT_READING_RULER_LINE_HEIGHT, + filterVisibleLineBoxes, getReadingRulerMoveDirection, + isReadingRulerMoveKey, + snapReadingRulerColumns, + snapReadingRulerToLines, stepReadingRulerPosition, } from '@/app/reader/utils/readingRuler'; @@ -63,4 +70,306 @@ describe('readingRuler utils', () => { expect(getReadingRulerMoveDirection('down', 'rtl')).toBe('forward'); expect(getReadingRulerMoveDirection('up', 'ltr')).toBe('backward'); }); + + it('restricts arrow-key ruler movement to up/down in vertical layout', () => { + // Vertical: only up/down move the ruler; left/right turn pages. + expect(isReadingRulerMoveKey('up', true)).toBe(true); + expect(isReadingRulerMoveKey('down', true)).toBe(true); + expect(isReadingRulerMoveKey('left', true)).toBe(false); + expect(isReadingRulerMoveKey('right', true)).toBe(false); + // Horizontal: every arrow moves the ruler. + expect(isReadingRulerMoveKey('up', false)).toBe(true); + expect(isReadingRulerMoveKey('left', false)).toBe(true); + expect(isReadingRulerMoveKey('right', false)).toBe(true); + }); + + it('pads each side by 0.3 of a line height', () => { + // round(16 * 1.5 * 0.3) = 7 + expect(calculateReadingRulerPadding({ defaultFontSize: 16, lineHeight: 1.5 }, 'EPUB')).toBe(7); + // fixed-layout: round(28 * 0.3) = 8 + expect(calculateReadingRulerPadding({ defaultFontSize: 16, lineHeight: 1.5 }, 'PDF')).toBe( + Math.round(FIXED_LAYOUT_READING_RULER_LINE_HEIGHT * 0.3), + ); + }); +}); + +type RectLike = { + top: number; + bottom: number; + left: number; + right: number; + width: number; + height: number; +}; + +const rect = (top: number, left: number, height: number, width: number): RectLike => ({ + top, + left, + bottom: top + height, + right: left + width, + height, + width, +}); + +const container = { top: 0, left: 0, right: 300, bottom: 400 }; + +describe('buildLineBoxes', () => { + it('clusters horizontal fragments into one box per visual line', () => { + const rects = [ + rect(0, 10, 16, 50), // line 1, fragment A + rect(0, 60, 16, 40), // line 1, fragment B (same vertical band) + rect(20, 10, 16, 80), // line 2 + ]; + expect(buildLineBoxes(rects, false, false, container)).toEqual([ + { start: 0, end: 16 }, + { start: 20, end: 36 }, + ]); + }); + + it('ignores zero-size rects', () => { + const rects = [rect(0, 10, 16, 50), rect(20, 0, 0, 0), rect(40, 10, 16, 50)]; + expect(buildLineBoxes(rects, false, false, container)).toEqual([ + { start: 0, end: 16 }, + { start: 40, end: 56 }, + ]); + }); + + it('returns sorted boxes even when rects are out of order', () => { + const rects = [rect(40, 10, 16, 50), rect(0, 10, 16, 50), rect(20, 10, 16, 50)]; + expect(buildLineBoxes(rects, false, false, container).map((b) => b.start)).toEqual([0, 20, 40]); + }); + + it('maps vertical-rl columns as distance from the right edge', () => { + // container.right = 300; a column at left=260,right=276 -> [300-276, 300-260] = [24, 40] + const rects = [rect(0, 260, 200, 16)]; + expect(buildLineBoxes(rects, true, true, container)).toEqual([{ start: 24, end: 40 }]); + }); + + it('maps vertical-lr columns as distance from the left edge', () => { + const rects = [rect(0, 24, 200, 16)]; + expect(buildLineBoxes(rects, true, false, container)).toEqual([{ start: 24, end: 40 }]); + }); + + it('drops multi-line block rects so paragraphs are not merged into one box', () => { + // Range.getClientRects() includes the

border box (h=160) alongside its + // 4 line boxes; the tall block rect must be discarded, not merged. + const rects = [ + rect(0, 10, 16, 400), + rect(20, 10, 16, 400), + rect(40, 10, 16, 400), + rect(60, 10, 16, 400), + rect(0, 10, 160, 400), // the paragraph block box + ]; + expect(buildLineBoxes(rects, false, false, container)).toEqual([ + { start: 0, end: 16 }, + { start: 20, end: 36 }, + { start: 40, end: 56 }, + { start: 60, end: 76 }, + ]); + }); +}); + +describe('snapReadingRulerToLines', () => { + // 10 lines, each 16px tall, 20px apart, starting at 0. + const evenBoxes = [0, 20, 40, 60, 80, 100, 120, 140, 160, 180].map((s) => ({ + start: s, + end: s + 16, + })); + + it('returns null when there are no line boxes', () => { + expect(snapReadingRulerToLines(0, 36, 2, 'forward', [])).toBeNull(); + }); + + it('returns the next block of N lines as { start, end }', () => { + // current block covers lines[0..1] => [0,36]; next group is lines[2..3] => [40,76]. + expect(snapReadingRulerToLines(0, 36, 2, 'forward', evenBoxes)).toEqual({ start: 40, end: 76 }); + }); + + it('returns the previous block of N lines when moving backward', () => { + // current block covers lines[2..3] => [40,76]; previous group is lines[0..1] => [0,36]. + expect(snapReadingRulerToLines(40, 76, 2, 'backward', evenBoxes)).toEqual({ + start: 0, + end: 36, + }); + }); + + it('returns the first group from the top with -Infinity', () => { + expect(snapReadingRulerToLines(-Infinity, -Infinity, 2, 'forward', evenBoxes)).toEqual({ + start: 0, + end: 36, + }); + }); + + it('returns the last group from the bottom with Infinity', () => { + expect(snapReadingRulerToLines(Infinity, Infinity, 2, 'backward', evenBoxes)).toEqual({ + start: 160, + end: 196, + }); + }); + + it('returns null at the bottom boundary so the page can flip', () => { + const boxes = [ + { start: 0, end: 16 }, + { start: 20, end: 36 }, + ]; + expect(snapReadingRulerToLines(0, 36, 2, 'forward', boxes)).toBeNull(); + }); + + it('returns null at the top boundary so the page can flip', () => { + const boxes = [ + { start: 80, end: 96 }, + { start: 100, end: 116 }, + ]; + expect(snapReadingRulerToLines(80, 116, 2, 'backward', boxes)).toBeNull(); + }); + + it('re-snapping forward from a block start stays on the same block (no skip)', () => { + // Anchoring the re-snap at the band's leading edge must return the same block, + // not advance one line. (Anchoring at the band CENTER, which sits inside a line, + // would skip to the next line — the page-turn "first line skipped" bug.) + const blockStart = 40; // lines[2..3] => [40,76] + expect(snapReadingRulerToLines(blockStart, blockStart, 2, 'forward', evenBoxes)).toEqual({ + start: 40, + end: 76, + }); + // A center anchor (sitting inside lines[2]) advances to the next group — the bug. + const center = 48; // inside lines[2] (40..56) + expect(snapReadingRulerToLines(center, center, 2, 'forward', evenBoxes)).toEqual({ + start: 60, + end: 96, + }); + }); +}); + +describe('filterVisibleLineBoxes', () => { + it('keeps lines at least half visible within the viewport and drops the rest', () => { + const boxes = [ + { start: 0, end: 16 }, // fully visible + { start: 190, end: 206 }, // 10/16 visible at the bottom edge -> kept + { start: 196, end: 212 }, // 4/16 visible -> dropped + { start: -8, end: 8 }, // 8/16 visible at the top edge -> kept + { start: -12, end: 4 }, // 4/16 visible -> dropped + ]; + expect(filterVisibleLineBoxes(boxes, 200)).toEqual([ + { start: 0, end: 16 }, + { start: 190, end: 206 }, + { start: -8, end: 8 }, + ]); + }); + + it('returns all line boxes when the dimension is unknown', () => { + const boxes = [{ start: 500, end: 516 }]; + expect(filterVisibleLineBoxes(boxes, 0)).toEqual(boxes); + }); +}); + +describe('buildReadingRulerColumns', () => { + // Two columns in a 1000px-wide overlay: col0 centers < 500, col1 centers >= 500. + const colRect = (top: number, left: number): RectLike => rect(top, left, 16, 420); + + it('splits rects into columns by x and lines by y', () => { + const rects = [colRect(0, 40), colRect(20, 40), colRect(0, 540), colRect(20, 540)]; + const cols = buildReadingRulerColumns(rects, 2, 1000, false); + expect(cols).toEqual([ + { + left: 40, + right: 460, + lines: [ + { start: 0, end: 16 }, + { start: 20, end: 36 }, + ], + }, + { + left: 540, + right: 960, + lines: [ + { start: 0, end: 16 }, + { start: 20, end: 36 }, + ], + }, + ]); + }); + + it('orders columns right-to-left when rtl', () => { + const rects = [colRect(0, 40), colRect(0, 540)]; + const cols = buildReadingRulerColumns(rects, 2, 1000, true); + expect(cols.map((c) => c.left)).toEqual([540, 40]); + }); + + it('returns a single full-width column when columnCount is 1', () => { + const rects = [colRect(0, 40), colRect(0, 540)]; + const cols = buildReadingRulerColumns(rects, 1, 1000, false); + expect(cols).toHaveLength(1); + expect(cols[0]!.lines).toHaveLength(1); // both rects share top 0 -> one line + }); + + it('drops multi-line block rects inside a column', () => { + const rects = [ + colRect(0, 40), + colRect(20, 40), + colRect(40, 40), + colRect(60, 40), + rect(0, 40, 160, 420), // paragraph block box in column 0 + ]; + const cols = buildReadingRulerColumns(rects, 2, 1000, false); + expect(cols).toHaveLength(1); + expect(cols[0]!.lines).toEqual([ + { start: 0, end: 16 }, + { start: 20, end: 36 }, + { start: 40, end: 56 }, + { start: 60, end: 76 }, + ]); + }); +}); + +describe('snapReadingRulerColumns', () => { + // col0: 5 lines (0..80); col1: 3 lines (0..40); each 16px tall, 20px apart. + const columns = [ + { + left: 40, + right: 460, + lines: [0, 20, 40, 60, 80].map((s) => ({ start: s, end: s + 16 })), + }, + { + left: 540, + right: 960, + lines: [0, 20, 40].map((s) => ({ start: s, end: s + 16 })), + }, + ]; + + it('advances within the active column', () => { + // col0, current block lines[0..1] => [0,36]; next block lines[2..3] => [40,76]. + expect(snapReadingRulerColumns(0, 0, 36, 2, 'forward', columns)).toEqual({ + columnIndex: 0, + start: 40, + end: 76, + }); + }); + + it('jumps to the next column at the bottom of the current one', () => { + // col0 current block lines[3..4] => [60,96]; nothing below -> col1 first block [0,36]. + expect(snapReadingRulerColumns(0, 60, 96, 2, 'forward', columns)).toEqual({ + columnIndex: 1, + start: 0, + end: 36, + }); + }); + + it('returns null past the last line of the last column', () => { + // col1 current block covers its last lines [20,56]; no next group anywhere. + expect(snapReadingRulerColumns(1, 20, 56, 2, 'forward', columns)).toBeNull(); + }); + + it('jumps to the previous column when moving backward off the top', () => { + // col1 current block [0,36]; nothing above in col1 -> col0 last block [60,96]. + expect(snapReadingRulerColumns(1, 0, 36, 2, 'backward', columns)).toEqual({ + columnIndex: 0, + start: 60, + end: 96, + }); + }); + + it('returns null when there are no columns', () => { + expect(snapReadingRulerColumns(0, 0, 36, 2, 'forward', [])).toBeNull(); + }); }); diff --git a/apps/readest-app/src/app/reader/components/ReadingRuler.tsx b/apps/readest-app/src/app/reader/components/ReadingRuler.tsx index 8a3df00d..6cb5ece4 100644 --- a/apps/readest-app/src/app/reader/components/ReadingRuler.tsx +++ b/apps/readest-app/src/app/reader/components/ReadingRuler.tsx @@ -1,7 +1,8 @@ import clsx from 'clsx'; import React, { useCallback, useRef, useState, useEffect } from 'react'; import { Insets } from '@/types/misc'; -import { BookFormat, ViewSettings } from '@/types/book'; +import { BookFormat, FIXED_LAYOUT_FORMATS, ViewSettings } from '@/types/book'; +import { FoliateView } from '@/types/view'; import { useEnv } from '@/context/EnvContext'; import { useReaderStore } from '@/store/readerStore'; import { saveViewSettings } from '@/helpers/settings'; @@ -10,11 +11,114 @@ import { throttle } from '@/utils/throttle'; import { eventDispatcher } from '@/utils/event'; import { useTouchInterceptor } from '../hooks/useTouchInterceptor'; import { + buildLineBoxes, + buildReadingRulerColumns, + calculateReadingRulerPadding, calculateReadingRulerSize, clampReadingRulerPosition, + filterVisibleLineBoxes, + ReadingRulerColumn, + ReadingRulerLineBox, + snapReadingRulerColumns, + snapReadingRulerToLines, stepReadingRulerPosition, } from '../utils/readingRuler'; +type OverlayRect = { + top: number; + bottom: number; + left: number; + right: number; + width: number; + height: number; +}; + +// Map a range's client rects (iframe-content coordinates) to overlay-relative +// coordinates, accounting for the iframe's offset within the top document +// (paginated multi-column pages shift the iframe far off-screen horizontally). +const mapRangeRectsToOverlay = (range: Range, containerRect: DOMRect): OverlayRect[] => { + const doc = range.startContainer?.ownerDocument; + const frame = doc?.defaultView?.frameElement?.getBoundingClientRect(); + const fx = frame?.left ?? 0; + const fy = frame?.top ?? 0; + return Array.from(range.getClientRects()).map((r) => ({ + top: r.top + fy - containerRect.top, + bottom: r.bottom + fy - containerRect.top, + left: r.left + fx - containerRect.left, + right: r.right + fx - containerRect.left, + width: r.width, + height: r.height, + })); +}; + +// Visible line boxes for the single-column / vertical path. Rects are mapped to +// overlay coordinates via the iframe frame offset (the section iframe is offset +// by the page/scroll position along the flow axis, for both horizontal columns +// and vertical writing mode). For vertical-rl the boxes are distance-from-right +// so the snap advances right-to-left in reading order. +const buildVisibleLineBoxes = ( + range: Range, + containerRect: DOMRect, + isVertical: boolean, + rtl: boolean, +): ReadingRulerLineBox[] => { + const dimension = isVertical ? containerRect.width : containerRect.height; + const mapped = mapRangeRectsToOverlay(range, containerRect); + const boxes = buildLineBoxes(mapped, isVertical, rtl, { + top: 0, + left: 0, + right: containerRect.width, + }); + return filterVisibleLineBoxes(boxes, dimension); +}; + +// In scrolled mode the relocate range covers only part of the viewport, so build +// line boxes from the visible section(s) directly: walk each on-screen content +// doc and map its lines via the frame offset. Lines just outside the viewport are +// kept (not filtered) so the snap can find the next/previous block to scroll to. +// Works for both horizontal flow (sections stack vertically, scroll axis = y) and +// vertical writing mode (sections stack horizontally, scroll axis = x). +const buildScrolledLineBoxes = ( + view: FoliateView | null, + containerRect: DOMRect, + isVertical: boolean, + rtl: boolean, +): ReadingRulerLineBox[] => { + if (!view) return []; + const boxes: ReadingRulerLineBox[] = []; + for (const content of view.renderer.getContents()) { + const doc = content.doc; + const frame = doc?.defaultView?.frameElement?.getBoundingClientRect(); + if (!frame) continue; + // Skip sections fully outside the viewport along the scroll axis. + const offscreen = isVertical + ? frame.right <= containerRect.left || frame.left >= containerRect.right + : frame.bottom <= containerRect.top || frame.top >= containerRect.bottom; + if (offscreen) continue; + const range = doc.createRange(); + range.selectNodeContents(doc.body); + const mapped = mapRangeRectsToOverlay(range, containerRect); + boxes.push( + ...buildLineBoxes(mapped, isVertical, rtl, { top: 0, left: 0, right: containerRect.width }), + ); + } + boxes.sort((a, b) => a.start - b.start || a.end - b.end); + return boxes; +}; + +// Confine each column's lines to those at least half visible within the viewport. +const filterVisibleColumns = ( + columns: ReadingRulerColumn[], + dimension: number, +): ReadingRulerColumn[] => + columns + .map((c) => ({ + left: c.left, + right: c.right, + lines: filterVisibleLineBoxes(c.lines, dimension), + })) + .filter((c) => c.lines.length > 0); + interface ReadingRulerProps { bookKey: string; isVertical: boolean; @@ -40,11 +144,18 @@ const ReadingRuler: React.FC = ({ viewSettings, }) => { const { envConfig } = useEnv(); - const { getProgress } = useReaderStore(); + const { getProgress, getView } = useReaderStore(); const progress = getProgress(bookKey); const containerRef = useRef(null); const [currentPosition, setCurrentPosition] = useState(position); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + // Active column extent (px, overlay-relative) for multi-column layouts; null = full width. + const [activeColumnRect, setActiveColumnRect] = useState<{ left: number; right: number } | null>( + null, + ); + // Band thickness along the ruler axis (px); 0 until first measured, then the + // real text-block size + symmetric padding. Falls back to a fixed size. + const [bandSize, setBandSize] = useState(0); // State for visibility animation (fade in) const [isVisible, setIsVisible] = useState(false); @@ -57,13 +168,32 @@ const ReadingRuler: React.FC = ({ const lastPageRef = useRef(null); const animationTimeoutRef = useRef | null>(null); const currentPositionRef = useRef(position); + const lineBoxesRef = useRef([]); + const columnsRef = useRef([]); + const activeColumnIndexRef = useRef(0); + const bandSizeRef = useRef(0); + const cachePageRef = useRef(null); + // In scrolled mode, set when a tap advances past the view edge and scrolls the + // view; the next relocate realigns the band to the start/end of the new view. + const pendingScrollAlignRef = useRef<'forward' | 'backward' | null>(null); - const rulerSize = calculateReadingRulerSize(lines, viewSettings, bookFormat); + const supportsLineSnap = !FIXED_LAYOUT_FORMATS.has(bookFormat); + const columnCount = getView(bookKey)?.renderer?.columnCount ?? 1; + const isMultiColumn = supportsLineSnap && !isVertical && columnCount > 1; + const baseRulerSize = calculateReadingRulerSize(lines, viewSettings, bookFormat); + // Symmetric breathing room on each side of the text block. + const padding = supportsLineSnap ? calculateReadingRulerPadding(viewSettings, bookFormat) : 0; + // Fixed band size used until lines are measured and for non-snap fallbacks. + const fallbackRulerSize = baseRulerSize + 2 * padding; + // Cap the dynamic band at (lines + 1) line heights so a tall element (e.g. a + // full-page image) inside the block doesn't expand the band to cover all of it. + const maxBandSize = calculateReadingRulerSize(lines + 1, viewSettings, bookFormat); const baseColor = READING_RULER_COLORS[color] || READING_RULER_COLORS['yellow']; const clampPosition = useCallback( - (pos: number, dimension: number) => clampReadingRulerPosition(pos, dimension, rulerSize), - [rulerSize], + (pos: number, dimension: number) => + clampReadingRulerPosition(pos, dimension, bandSizeRef.current || fallbackRulerSize), + [fallbackRulerSize], ); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -96,6 +226,25 @@ const ReadingRuler: React.FC = ({ [throttledSave], ); + // Size the band to the real text block plus symmetric padding, centered on + // the block so the breathing room is equal on both sides. + const applyBlock = useCallback( + (start: number, end: number, dimension: number, animate: boolean) => { + // Band = text block + symmetric padding, capped at (lines + 1) line heights + // so a tall element in the block can't blow the band up to cover all of it. + const size = Math.min(end - start + 2 * padding, maxBandSize); + bandSizeRef.current = size; + setBandSize(size); + // Always center the band on the text block (equal padding all around). The + // caller scrolls the view when a block can't be centered within it, so the + // center never needs clamping here. + const centerPct = + dimension > 0 ? ((start + end) / 2 / dimension) * 100 : currentPositionRef.current; + setRulerPosition(centerPct, animate); + }, + [padding, maxBandSize, setRulerPosition], + ); + // Track container size for overlay calculations useEffect(() => { if (!containerRef.current) return; @@ -117,6 +266,104 @@ const ReadingRuler: React.FC = ({ return () => resizeObserver.disconnect(); }, []); + // Cache the visible line geometry for the current page so taps can snap to + // real lines: columns for multi-column layouts, otherwise a flat line list. + useEffect(() => { + const range = progress?.range ?? null; + const containerRect = containerRef.current?.getBoundingClientRect(); + const page = progress?.pageinfo?.current ?? null; + // Page changes are handled by the auto-move effect; here we only (re)derive + // the band on initial mount and on resize/relayout. + const pageChanged = cachePageRef.current !== null && cachePageRef.current !== page; + cachePageRef.current = page; + + if (!supportsLineSnap || !range || !containerRect) { + lineBoxesRef.current = []; + columnsRef.current = []; + setActiveColumnRect(null); + return; + } + try { + const dimension = isVertical ? containerRect.width : containerRect.height; + const center = dimension > 0 ? (currentPositionRef.current / 100) * dimension : 0; + // Re-snap anchor: the band's leading edge (block start), not its center. + // Snapping 'forward' from the center skips the line the center sits inside, + // which would advance the band by one line on every relayout/relocate (e.g. + // the settle relocate right after a page turn skipped the new page's line 1). + const halfBlock = Math.max(0, (bandSizeRef.current || fallbackRulerSize) / 2 - padding); + const anchor = center - halfBlock; + if (isMultiColumn) { + const mapped = mapRangeRectsToOverlay(range, containerRect); + const cols = filterVisibleColumns( + buildReadingRulerColumns(mapped, columnCount, containerRect.width, rtl), + dimension, + ); + columnsRef.current = cols; + lineBoxesRef.current = []; + const idx = Math.max(0, Math.min(activeColumnIndexRef.current, cols.length - 1)); + const col = cols[idx]; + setActiveColumnRect(col ? { left: col.left, right: col.right } : null); + if (!pageChanged) { + const block = snapReadingRulerColumns(idx, anchor, anchor, lines, 'forward', cols); + if (block) { + activeColumnIndexRef.current = block.columnIndex; + const target = cols[block.columnIndex]; + if (target) setActiveColumnRect({ left: target.left, right: target.right }); + applyBlock(block.start, block.end, dimension, false); + } + } + } else { + const scrolled = !!viewSettings.scrolled; + lineBoxesRef.current = scrolled + ? buildScrolledLineBoxes(getView(bookKey), containerRect, isVertical, rtl) + : buildVisibleLineBoxes(range, containerRect, isVertical, rtl); + columnsRef.current = []; + setActiveColumnRect(null); + // Position the band against the on-screen lines only, so it lands in view + // (the handler uses the unfiltered set to scroll toward off-screen lines). + const derivBoxes = scrolled + ? filterVisibleLineBoxes(lineBoxesRef.current, dimension) + : lineBoxesRef.current; + const pending = pendingScrollAlignRef.current; + if (pending) { + // The view just scrolled because a tap advanced past its edge: put the + // band at the start (forward) or end (backward) of the new view. + pendingScrollAlignRef.current = null; + const block = + pending === 'forward' + ? snapReadingRulerToLines(-Infinity, -Infinity, lines, 'forward', derivBoxes) + : snapReadingRulerToLines(Infinity, Infinity, lines, 'backward', derivBoxes); + if (block) applyBlock(block.start, block.end, dimension, true); + } else if (!pageChanged) { + const block = + snapReadingRulerToLines(anchor, anchor, lines, 'forward', derivBoxes) ?? + snapReadingRulerToLines(Infinity, Infinity, lines, 'backward', derivBoxes); + if (block) applyBlock(block.start, block.end, dimension, false); + } + } + } catch { + lineBoxesRef.current = []; + columnsRef.current = []; + setActiveColumnRect(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + progress?.range, + progress?.pageinfo?.current, + containerSize.width, + containerSize.height, + isVertical, + rtl, + supportsLineSnap, + isMultiColumn, + columnCount, + lines, + viewSettings.scrolled, + bookKey, + getView, + applyBlock, + ]); + // Fade in on mount (delayed to prevent flash before content loads) useEffect(() => { const timer = setTimeout(() => setIsVisible(true), 30); @@ -190,13 +437,66 @@ const ReadingRuler: React.FC = ({ return null; }; - const performAutoMove = (range: Range | null) => { + const performAutoMove = (range: Range | null, direction: 'forward' | 'backward') => { const containerRect = containerRef.current?.getBoundingClientRect(); if (!containerRect) return; const containerDimension = isVertical ? containerRect.width : containerRect.height; if (containerDimension <= 0) return; + // Paging forward lands on the first line of the new page; paging backward + // lands on the last line (so reading continues from where it left off). + const forward = direction === 'forward'; + + if (isMultiColumn && range) { + try { + const mapped = mapRangeRectsToOverlay(range, containerRect); + const columns = filterVisibleColumns( + buildReadingRulerColumns(mapped, columnCount, containerRect.width, rtl), + containerDimension, + ); + columnsRef.current = columns; + // Forward: first line group of the first column. Backward: last line + // group of the last column. + const block = forward + ? snapReadingRulerColumns(0, -Infinity, -Infinity, lines, 'forward', columns) + : snapReadingRulerColumns( + columns.length - 1, + Infinity, + Infinity, + lines, + 'backward', + columns, + ); + if (block) { + activeColumnIndexRef.current = block.columnIndex; + const col = columns[block.columnIndex]; + if (col) setActiveColumnRect({ left: col.left, right: col.right }); + applyBlock(block.start, block.end, containerDimension, true); + return; + } + } catch { + /* fall through to default offset */ + } + } + + if (supportsLineSnap && !isMultiColumn && range) { + try { + const boxes = buildVisibleLineBoxes(range, containerRect, isVertical, rtl); + lineBoxesRef.current = boxes; + // Forward: first line group from the top. Backward: last line group. + const block = forward + ? snapReadingRulerToLines(-Infinity, -Infinity, lines, 'forward', boxes) + : snapReadingRulerToLines(Infinity, Infinity, lines, 'backward', boxes); + if (block) { + applyBlock(block.start, block.end, containerDimension, true); + return; + } + } catch { + /* fall through to default offset */ + } + } + const textPosition = getFirstVisibleTextPosition(range); // For vertical mode: use marginRight for vertical-rl, marginLeft for vertical-lr const defaultOffset = isVertical @@ -206,8 +506,10 @@ const ReadingRuler: React.FC = ({ : (viewSettings.marginTopPx ?? 44); const offset = textPosition ?? defaultOffset; + bandSizeRef.current = fallbackRulerSize; + setBandSize(fallbackRulerSize); const targetPosition = clampPosition( - ((offset + rulerSize / 2) / containerDimension) * 100, + ((offset + fallbackRulerSize / 2) / containerDimension) * 100, containerDimension, ); @@ -219,7 +521,8 @@ const ReadingRuler: React.FC = ({ // Only auto-move if page actually changed (not on initial load) if (lastPageRef.current !== null && lastPageRef.current !== currentPage) { - requestAnimationFrame(() => performAutoMove(range)); + const direction = currentPage > lastPageRef.current ? 'forward' : 'backward'; + requestAnimationFrame(() => performAutoMove(range, direction)); } lastPageRef.current = currentPage; @@ -237,7 +540,13 @@ const ReadingRuler: React.FC = ({ viewSettings.marginTopPx, viewSettings.marginLeftPx, viewSettings.marginRightPx, - rulerSize, + fallbackRulerSize, + lines, + supportsLineSnap, + isMultiColumn, + columnCount, + applyBlock, + clampPosition, setRulerPosition, ]); @@ -250,7 +559,10 @@ const ReadingRuler: React.FC = ({ const rect = containerRef.current?.getBoundingClientRect(); if (rect) { const dimension = isVertical ? rect.width : rect.height; - const pointerPosition = isVertical ? e.clientX - rect.left : e.clientY - rect.top; + const fromStart = isVertical ? e.clientX - rect.left : e.clientY - rect.top; + // Position along the ruler axis: distance-from-right for vertical-rl so it + // matches the stored position; distance-from-left/top otherwise. + const pointerPosition = isVertical && rtl ? rect.width - fromStart : fromStart; const rulerCenter = (currentPositionRef.current / 100) * dimension; dragPointerOffsetRef.current = pointerPosition - rulerCenter; } else { @@ -261,7 +573,7 @@ const ReadingRuler: React.FC = ({ setShouldAnimate(false); e.currentTarget.setPointerCapture(e.pointerId); }, - [isVertical], + [isVertical, rtl], ); const handlePointerMove = useCallback( @@ -274,8 +586,12 @@ const ReadingRuler: React.FC = ({ let newPosition: number; if (isVertical) { - const relativeX = e.clientX - rect.left - dragPointerOffsetRef.current; - newPosition = clampPosition((relativeX / rect.width) * 100, rect.width); + const fromStart = e.clientX - rect.left; + const pointerPosition = rtl ? rect.width - fromStart : fromStart; + newPosition = clampPosition( + ((pointerPosition - dragPointerOffsetRef.current) / rect.width) * 100, + rect.width, + ); } else { const relativeY = e.clientY - rect.top - dragPointerOffsetRef.current; newPosition = clampPosition((relativeY / rect.height) * 100, rect.height); @@ -283,7 +599,7 @@ const ReadingRuler: React.FC = ({ setCurrentPosition(newPosition); currentPositionRef.current = newPosition; }, - [isVertical, clampPosition], + [isVertical, rtl, clampPosition], ); const handlePointerUp = useCallback( @@ -324,8 +640,12 @@ const ReadingRuler: React.FC = ({ const vy = detail.touch.screenY - (window.screenY || 0); const dim = isVertical ? rect.width : rect.height; const center = (currentPositionRef.current / 100) * dim; - const half = rulerSize / 2; - const rel = isVertical ? vx - rect.left : vy - rect.top; + const half = (bandSizeRef.current || fallbackRulerSize) / 2; + const rel = isVertical + ? rtl + ? rect.width - (vx - rect.left) + : vx - rect.left + : vy - rect.top; touchInRulerRef.current = rel >= center - half && rel <= center + half; isTouchDraggingRef.current = false; return false; @@ -359,7 +679,11 @@ const ReadingRuler: React.FC = ({ const vx = detail.touch.screenX - (window.screenX || 0); const vy = detail.touch.screenY - (window.screenY || 0); const dim = isVertical ? rect.width : rect.height; - const rel = isVertical ? vx - rect.left : vy - rect.top; + const rel = isVertical + ? rtl + ? rect.width - (vx - rect.left) + : vx - rect.left + : vy - rect.top; const newPos = clampPosition((rel / dim) * 100, dim); setCurrentPosition(newPos); currentPositionRef.current = newPos; @@ -397,17 +721,74 @@ const ReadingRuler: React.FC = ({ if (!dimension) return false; + // The lines currently covered by the band (block extent, without padding). + const center = (currentPositionRef.current / 100) * dimension; + const halfBlock = Math.max(0, (bandSizeRef.current || fallbackRulerSize) / 2 - padding); + const curStart = center - halfBlock; + const curEnd = center + halfBlock; + + if (isMultiColumn && columnsRef.current.length > 0) { + const block = snapReadingRulerColumns( + activeColumnIndexRef.current, + curStart, + curEnd, + lines, + detail.direction, + columnsRef.current, + ); + // No next line group in any column this direction: let the page flip. + if (!block) return false; + activeColumnIndexRef.current = block.columnIndex; + const col = columnsRef.current[block.columnIndex]; + if (col) setActiveColumnRect({ left: col.left, right: col.right }); + applyBlock(block.start, block.end, dimension, true); + return true; + } + + if (supportsLineSnap && lineBoxesRef.current.length > 0) { + const block = snapReadingRulerToLines( + curStart, + curEnd, + lines, + detail.direction, + lineBoxesRef.current, + ); + if (!block) { + // No next line in the loaded geometry: in scrolled mode let the view + // scroll, then realign the band on the next relocate. + if (viewSettings.scrolled) pendingScrollAlignRef.current = detail.direction; + return false; + } + + // Scrolled mode: keep the text centered in the band. While the next block + // can be centered within the view, place it there (the band moves). When + // it can't, let foliate page-scroll the view (its relocate fires after the + // layout settles, unlike a manual scrollBy) and realign on that relocate. + if (viewSettings.scrolled) { + const bandHalf = (block.end - block.start) / 2 + padding; + const blockCenter = (block.start + block.end) / 2; + const centerable = blockCenter - bandHalf >= 0 && blockCenter + bandHalf <= dimension; + if (!centerable) { + pendingScrollAlignRef.current = detail.direction; + return false; + } + } + + applyBlock(block.start, block.end, dimension, true); + return true; + } + const nextPosition = stepReadingRulerPosition( currentPositionRef.current, dimension, - rulerSize, + fallbackRulerSize, detail.direction, ); - if (Math.abs(nextPosition - currentPositionRef.current) < 0.001) { return false; } - + bandSizeRef.current = fallbackRulerSize; + setBandSize(fallbackRulerSize); setRulerPosition(nextPosition, true); return true; }; @@ -416,15 +797,34 @@ const ReadingRuler: React.FC = ({ return () => { eventDispatcher.offSync('reading-ruler-move', handleMove); }; - }, [bookKey, containerSize.height, containerSize.width, isVertical, rulerSize, setRulerPosition]); + }, [ + bookKey, + containerSize.height, + containerSize.width, + isVertical, + lines, + padding, + fallbackRulerSize, + supportsLineSnap, + isMultiColumn, + viewSettings.scrolled, + getView, + applyBlock, + setRulerPosition, + ]); const fadeOpacity = Math.min(0.9, opacity); - // Calculate dimensions based on orientation + // Calculate dimensions based on orientation. The band size is the measured + // text block + padding once known, otherwise the fixed fallback size. + const effectiveBandSize = bandSize > 0 ? bandSize : fallbackRulerSize; const containerDimension = isVertical ? containerSize.width : containerSize.height; - const rulerCenterPx = (currentPosition / 100) * containerDimension; - const rulerStartPx = Math.max(0, rulerCenterPx - rulerSize / 2); - const rulerEndPx = Math.min(containerDimension, rulerCenterPx + rulerSize / 2); + // Band position as a percentage from the left/top. For vertical-rl the stored + // position is distance-from-right, so flip it to position from the left. + const renderPosPct = isVertical && rtl ? 100 - currentPosition : currentPosition; + const rulerCenterPx = (renderPosPct / 100) * containerDimension; + const rulerStartPx = Math.max(0, rulerCenterPx - effectiveBandSize / 2); + const rulerEndPx = Math.min(containerDimension, rulerCenterPx + effectiveBandSize / 2); // Map color names to CSS filter values (compatible with iOS Safari) // Uses sepia as base, then hue-rotate to target color @@ -490,8 +890,8 @@ const ReadingRuler: React.FC = ({ color === 'transparent' ? 'border-base-content/55 border' : '', )} style={{ - left: `${currentPosition}%`, - width: `${rulerSize}px`, + left: `${renderPosPct}%`, + width: `${effectiveBandSize}px`, transform: 'translateX(-50%)', transition: getTransitionStyle('left'), ...(color === 'transparent' @@ -515,6 +915,94 @@ const ReadingRuler: React.FC = ({ ); } + // Column-aware horizontal ruler: the band spans a single column; the rest of + // the page (including the other column) is dimmed. + if (activeColumnRect) { + const W = containerSize.width; + const H = containerSize.height; + const centerPx = (currentPosition / 100) * H; + const bandTop = Math.max(0, centerPx - effectiveBandSize / 2); + const bandBottom = Math.min(H, centerPx + effectiveBandSize / 2); + const bandHeight = Math.max(0, bandBottom - bandTop); + // Same breathing room horizontally as vertically, so padding is equal all around. + const bandLeft = Math.max(0, activeColumnRect.left - padding); + const bandRight = Math.min(W, activeColumnRect.right + padding); + const bandWidth = Math.max(0, bandRight - bandLeft); + const ease = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'; + const bandTransition = shouldAnimate + ? `top 0.6s ${ease}, left 0.6s ${ease}, width 0.6s ${ease}, height 0.6s ${ease}` + : 'none'; + const dimTransition = shouldAnimate ? `top 0.6s ${ease}, height 0.6s ${ease}` : 'none'; + + return ( +

+ {/* Top dim */} +
+ {/* Bottom dim */} +
+ {/* Left dim (covers the inactive column to the left of the band) */} +
+ {/* Right dim (covers the inactive column to the right of the band) */} +
+ + {/* Column band */} +
+
+
+
+
+ ); + } + // Horizontal ruler (default - moves up/down) return (
= ({ )} style={{ top: `${currentPosition}%`, - height: `${rulerSize}px`, + height: `${effectiveBandSize}px`, transform: 'translateY(-50%)', transition: getTransitionStyle('top'), ...(color === 'transparent' diff --git a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts index ffae549c..995dcbc3 100644 --- a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts +++ b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts @@ -14,7 +14,7 @@ import { getParagraphActionForKey } from '@/utils/paragraphPresentation'; import { viewPagination } from './usePagination'; import useShortcuts from '@/hooks/useShortcuts'; import useBooksManager from './useBooksManager'; -import { getReadingRulerMoveDirection } from '../utils/readingRuler'; +import { getReadingRulerMoveDirection, isReadingRulerMoveKey } from '../utils/readingRuler'; interface UseBookShortcutsProps { sideBarBookKey: string | null; @@ -40,6 +40,8 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = const viewSettings = getViewSettings(sideBarBookKey); if (!viewSettings?.readingRulerEnabled) return false; + // In vertical layout, only Up/Down move the ruler; Left/Right turn pages. + if (!isReadingRulerMoveKey(side, !!viewSettings.vertical)) return false; return eventDispatcher.dispatchSync('reading-ruler-move', { bookKey: sideBarBookKey, diff --git a/apps/readest-app/src/app/reader/hooks/usePagination.ts b/apps/readest-app/src/app/reader/hooks/usePagination.ts index 8884497f..f4247953 100644 --- a/apps/readest-app/src/app/reader/hooks/usePagination.ts +++ b/apps/readest-app/src/app/reader/hooks/usePagination.ts @@ -49,6 +49,74 @@ const hasVerticalPanning = ( return isPanningView(view, viewSettings) && view.isOverflowY(); }; +// In scrolled mode, snap the page-scroll distance to whole lines so the new view +// tiles cleanly: forward, the new view's top aligns to the first line that wasn't +// fully visible (so no already-shown line repeats and lines aren't cut at the top); +// backward, the new view's bottom aligns to the last such line. `distance` is a +// positive scroll amount. Falls back to `distance` if the geometry is unavailable. +const snapScrolledDistanceToLines = ( + view: FoliateView, + distance: number, + forward: boolean, +): number => { + try { + const visible = view.renderer.getContents().find((c) => { + const f = c.doc?.defaultView?.frameElement?.getBoundingClientRect(); + return !!f && f.bottom > 1 && f.top < window.innerHeight; + }); + const frameEl = visible?.doc?.defaultView?.frameElement as HTMLElement | undefined; + if (!visible || !frameEl) return distance; + let container: Element | null = frameEl; + while (container && container.id !== 'container') container = container.parentElement; + if (!(container instanceof HTMLElement)) return distance; + + const cRect = container.getBoundingClientRect(); + const frameRect = frameEl.getBoundingClientRect(); + const size = cRect.height; + const scrollTop = container.scrollTop; + + const range = visible.doc.createRange(); + range.selectNodeContents(visible.doc.body); + const rects = Array.from(range.getClientRects()).filter((r) => r.width > 2 && r.height > 2); + if (rects.length < 2) return distance; + const heights = rects.map((r) => r.height).sort((a, b) => a - b); + const maxLineHeight = (heights[Math.floor(heights.length / 2)] ?? 0) * 1.8; + + // Line boxes in content (scroll) coordinates, sorted top-to-bottom (skip + // block/container boxes that are much taller than a line). + const toContent = (localY: number) => localY + frameRect.top - cRect.top + scrollTop; + const lines = rects + .filter((r) => r.height <= maxLineHeight) + .map((r) => ({ top: toContent(r.top), bottom: toContent(r.bottom) })) + .sort((a, b) => a.top - b.top); + + let snapped: number; + if (forward) { + // First line not fully visible at the bottom -> it becomes the new view's top. + const bottomEdge = scrollTop + size; + const next = lines.find((l) => l.bottom > bottomEdge + 1); + if (!next) return distance; + snapped = next.top - scrollTop; + } else { + // Last line not fully visible at the top -> it becomes the new view's bottom. + const topEdge = scrollTop; + let prev: { top: number; bottom: number } | undefined; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i]!.top < topEdge - 1) { + prev = lines[i]!; + break; + } + } + if (!prev) return distance; + snapped = scrollTop + size - prev.bottom; + } + // Guard against degenerate snaps; keep within roughly one page. + return snapped > size * 0.4 && snapped < size * 1.6 ? snapped : distance; + } catch { + return distance; + } +}; + export const viewPagination = ( view: FoliateView | null, viewSettings: ViewSettings | null | undefined, @@ -62,11 +130,13 @@ export const viewPagination = ( side = swapLeftRight(side); } if (renderer.scrolled) { + // `renderer.size` is already the visible content height: in scrolled mode the + // scroll container is inset by the header/footer (parent padding), so `size` + // shrinks when they're shown. Subtracting their heights again here would + // double-count and make consecutive views overlap. const { size } = renderer; - const showHeader = viewSettings.showHeader; - const showFooter = viewSettings.showFooter; const scrollingOverlap = viewSettings.scrollingOverlap; - const distance = size - scrollingOverlap - (showHeader ? 44 : 0) - (showFooter ? 44 : 0); + const distance = size - scrollingOverlap; switch (mode) { case 'section': if (side === 'left' || side === 'up') { @@ -76,8 +146,14 @@ export const viewPagination = ( } case 'pan': case 'page': - default: - return side === 'left' || side === 'up' ? view.prev(distance) : view.next(distance); + default: { + const forward = !(side === 'left' || side === 'up'); + // Snap so the view's bottom edge lands between lines (not for vertical flow). + const snapped = viewSettings.vertical + ? distance + : snapScrolledDistanceToLines(view, distance, forward); + return forward ? view.next(snapped) : view.prev(snapped); + } } } else if (mode === 'pan' && isPanningView(view, viewSettings)) { if (hasHorizontalPanning(view, viewSettings) && (side === 'left' || side === 'right')) { diff --git a/apps/readest-app/src/app/reader/utils/readingRuler.ts b/apps/readest-app/src/app/reader/utils/readingRuler.ts index aa82e233..513539bc 100644 --- a/apps/readest-app/src/app/reader/utils/readingRuler.ts +++ b/apps/readest-app/src/app/reader/utils/readingRuler.ts @@ -2,8 +2,171 @@ import { BookFormat, FIXED_LAYOUT_FORMATS, ViewSettings } from '@/types/book'; export const FIXED_LAYOUT_READING_RULER_LINE_HEIGHT = 28; +export interface ReadingRulerLineBox { + start: number; + end: number; +} + +/** A column of text with its horizontal extent and the line boxes inside it. */ +export interface ReadingRulerColumn { + left: number; + right: number; + lines: ReadingRulerLineBox[]; +} + +type RulerRect = { + top: number; + bottom: number; + left: number; + right: number; + width: number; + height: number; +}; + +type RulerContainerRect = { top: number; left: number; right: number }; + type ReadingRulerSettings = Pick; +/** + * `Range.getClientRects()` aggregates the border boxes of every fully-enclosed + * element, so multi-line `

`/container blocks show up as rects much taller + * (along the ruler axis) than a text line. Drop those so they don't get merged + * into a giant "line" that the snap would skip over. Line rects vastly + * outnumber block rects, so the median thickness is the real line height. + */ +const dropBlockRects = (rects: RulerRect[], isVertical: boolean): RulerRect[] => { + const valid = rects.filter((r) => r && r.width > 0 && r.height > 0); + if (valid.length < 3) return valid; + const thickness = (r: RulerRect) => (isVertical ? r.width : r.height); + const sorted = valid.map(thickness).sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)] ?? 0; + if (median <= 0) return valid; + const limit = median * 1.8; + return valid.filter((r) => thickness(r) <= limit); +}; + +/** + * Merge sorted-on-input spans into visual lines: spans that overlap by more + * than half of the smaller span are treated as one line. + */ +const mergeLineSpans = (spans: ReadingRulerLineBox[]): ReadingRulerLineBox[] => { + spans.sort((a, b) => a.start - b.start || a.end - b.end); + + const lines: ReadingRulerLineBox[] = []; + for (const span of spans) { + const current = lines[lines.length - 1]; + if (current) { + const overlap = Math.min(current.end, span.end) - Math.max(current.start, span.start); + const minHeight = Math.min(current.end - current.start, span.end - span.start); + if (overlap > 0.5 * minHeight) { + current.start = Math.min(current.start, span.start); + current.end = Math.max(current.end, span.end); + continue; + } + } + lines.push({ start: span.start, end: span.end }); + } + + return lines; +}; + +/** + * Convert per-fragment client rects into sorted visual-line spans along the + * ruler axis, in container coordinates. Used for the single-column and + * vertical-writing-mode paths (a full-width band). + */ +export const buildLineBoxes = ( + rects: RulerRect[], + isVertical: boolean, + rtl: boolean, + containerRect: RulerContainerRect, +): ReadingRulerLineBox[] => { + const spans: ReadingRulerLineBox[] = []; + for (const r of dropBlockRects(rects, isVertical)) { + let start: number; + let end: number; + if (isVertical) { + if (rtl) { + start = containerRect.right - r.right; + end = containerRect.right - r.left; + } else { + start = r.left - containerRect.left; + end = r.right - containerRect.left; + } + } else { + start = r.top - containerRect.top; + end = r.bottom - containerRect.top; + } + if (end < start) [start, end] = [end, start]; + spans.push({ start, end }); + } + + return mergeLineSpans(spans); +}; + +// Minimum visible fraction of a line for the band to be allowed to cover it. +const READING_RULER_MIN_VISIBLE_RATIO = 0.5; + +/** + * Keep only the line boxes that are at least half visible within `[0, dimension]` + * along the ruler axis. The visible range can include lines beyond the viewport + * edge; this confines the band to lines that are actually on screen, while still + * allowing it to cover a line that is half shown. + */ +export const filterVisibleLineBoxes = ( + lineBoxes: ReadingRulerLineBox[], + dimension: number, +): ReadingRulerLineBox[] => { + if (dimension <= 0) return lineBoxes; + return lineBoxes.filter((b) => { + const height = b.end - b.start; + if (height <= 0) return false; + const visible = Math.min(b.end, dimension) - Math.max(b.start, 0); + return visible >= READING_RULER_MIN_VISIBLE_RATIO * height; + }); +}; + +/** + * Group overlay-relative rects into columns (by x, using the rendered column + * count) and into vertical line boxes within each column. Columns are returned + * in reading order (left-to-right, or right-to-left when `rtl`). + */ +export const buildReadingRulerColumns = ( + rects: RulerRect[], + columnCount: number, + overlayWidth: number, + rtl: boolean, +): ReadingRulerColumn[] => { + const cols = Math.max(1, Math.floor(columnCount)); + if (overlayWidth <= 0) return []; + + const colWidth = overlayWidth / cols; + const buckets: RulerRect[][] = Array.from({ length: cols }, () => []); + for (const r of dropBlockRects(rects, false)) { + const center = (r.left + r.right) / 2; + const idx = Math.max(0, Math.min(cols - 1, Math.floor(center / colWidth))); + buckets[idx]!.push(r); + } + + const columns: ReadingRulerColumn[] = []; + for (const bucket of buckets) { + if (!bucket.length) continue; + let left = Infinity; + let right = -Infinity; + const spans: ReadingRulerLineBox[] = []; + for (const r of bucket) { + left = Math.min(left, r.left); + right = Math.max(right, r.right); + spans.push({ start: r.top, end: r.bottom }); + } + const lines = mergeLineSpans(spans); + if (lines.length) columns.push({ left, right, lines }); + } + + if (rtl) columns.reverse(); + return columns; +}; + export const calculateReadingRulerSize = ( lines: number, viewSettings: ReadingRulerSettings, @@ -18,6 +181,25 @@ export const calculateReadingRulerSize = ( return Math.round(lines * fontSize * lineHeight); }; +// Fraction of a line height used as breathing room on each side of the block. +const READING_RULER_PADDING_FACTOR = 0.3; + +/** + * Breathing room applied on each side of the text block, so the padding around + * the text is the same all around: round(fontSize * lineHeight * 0.3). + */ +export const calculateReadingRulerPadding = ( + viewSettings: ReadingRulerSettings, + bookFormat: BookFormat, +): number => { + if (FIXED_LAYOUT_FORMATS.has(bookFormat)) { + return Math.round(FIXED_LAYOUT_READING_RULER_LINE_HEIGHT * READING_RULER_PADDING_FACTOR); + } + const fontSize = viewSettings.defaultFontSize || 16; + const lineHeight = viewSettings.lineHeight || 1.5; + return Math.round(fontSize * lineHeight * READING_RULER_PADDING_FACTOR); +}; + export const clampReadingRulerPosition = ( position: number, dimension: number, @@ -51,6 +233,119 @@ export const stepReadingRulerPosition = ( ); }; +/** + * Snap to the next/previous block of `lines` real text lines and return that + * block's extent { start, end } along the ruler axis (px), or null when there + * is no next group in the given direction (the caller then flips the page). + * The caller pads this block symmetrically to size the band, so padding around + * the text is the same on both sides. + * + * `currentBlockStart`/`currentBlockEnd` describe the lines currently covered; + * pass -Infinity / -Infinity to get the first group from the top, or + * Infinity / Infinity to get the last group from the bottom. + */ +export const snapReadingRulerToLines = ( + currentBlockStart: number, + currentBlockEnd: number, + lines: number, + direction: 'backward' | 'forward', + lineBoxes: ReadingRulerLineBox[], +): ReadingRulerLineBox | null => { + if (lineBoxes.length === 0) return null; + + const count = Math.max(1, Math.floor(lines)); + const heights = lineBoxes + .map((b) => b.end - b.start) + .filter((h) => h > 0) + .sort((a, b) => a - b); + const medianHeight = heights[Math.floor(heights.length / 2)] ?? 0; + const eps = medianHeight * 0.3; + + const block = (startIdx: number, endIdx: number): ReadingRulerLineBox | null => { + const startBox = lineBoxes[startIdx]; + const endBox = lineBoxes[endIdx]; + if (!startBox || !endBox) return null; + return { start: startBox.start, end: endBox.end }; + }; + + if (direction === 'forward') { + const startIdx = lineBoxes.findIndex((b) => b.start >= currentBlockEnd - eps); + if (startIdx === -1) return null; + const endIdx = Math.min(startIdx + count - 1, lineBoxes.length - 1); + return block(startIdx, endIdx); + } + + let endIdx = -1; + for (let i = lineBoxes.length - 1; i >= 0; i--) { + if ((lineBoxes[i]?.end ?? Infinity) <= currentBlockStart + eps) { + endIdx = i; + break; + } + } + if (endIdx === -1) return null; + const startIdx = Math.max(endIdx - count + 1, 0); + return block(startIdx, endIdx); +}; + +/** + * Column-aware snap: advance within the active column; when there is no next + * line group in it, move to the first/last group of the next/previous column. + * Returns the target column index and the block extent { start, end } (px), or + * null when there is no next group anywhere (the caller then flips the page). + */ +export const snapReadingRulerColumns = ( + currentColumnIndex: number, + currentBlockStart: number, + currentBlockEnd: number, + lines: number, + direction: 'backward' | 'forward', + columns: ReadingRulerColumn[], +): { columnIndex: number; start: number; end: number } | null => { + if (columns.length === 0) return null; + + const idx = Math.max(0, Math.min(currentColumnIndex, columns.length - 1)); + const col = columns[idx]; + if (!col) return null; + + const within = snapReadingRulerToLines( + currentBlockStart, + currentBlockEnd, + lines, + direction, + col.lines, + ); + if (within) return { columnIndex: idx, start: within.start, end: within.end }; + + if (direction === 'forward') { + for (let j = idx + 1; j < columns.length; j++) { + const next = columns[j]; + if (!next) continue; + const first = snapReadingRulerToLines(-Infinity, -Infinity, lines, 'forward', next.lines); + if (first) return { columnIndex: j, start: first.start, end: first.end }; + } + } else { + for (let j = idx - 1; j >= 0; j--) { + const prev = columns[j]; + if (!prev) continue; + const last = snapReadingRulerToLines(Infinity, Infinity, lines, 'backward', prev.lines); + if (last) return { columnIndex: j, start: last.start, end: last.end }; + } + } + + return null; +}; + +/** + * Whether an arrow-key side should move the reading ruler in the current layout. + * In vertical writing mode only Up/Down move the ruler (Left/Right turn pages); + * in horizontal mode all four sides move the ruler. Applies to keyboard nav only + * — taps always move the ruler regardless of the tapped side. + */ +export const isReadingRulerMoveKey = ( + side: 'left' | 'right' | 'up' | 'down', + isVertical: boolean, +): boolean => (isVertical ? side === 'up' || side === 'down' : true); + export const getReadingRulerMoveDirection = ( side: 'left' | 'right' | 'up' | 'down', bookDir?: string,