commit 9bf68a57c48ffbd6c19134e39e43b44235f064b5 truex <truex@equwal.com> 2026-09-26 14:25:59 -0700 Fit the page to the width, and keep the mark in view In landscape on a phone the page fitted the viewer height and used 27% of the width. The page now fits the viewer width in each orientation, up to 1000 CSS pixels on a wide screen. The new module view.ts renders the page, draws the mark and scrolls the viewer. After a page turn the viewer scrolls to the mark on the new page, else to the top. When a new mark is out of view, the viewer scrolls smoothly and puts the mark in the upper third, but not in the 3 seconds after the user scrolled. The pure function scrollTarget (scroll.ts) finds the position. A ResizeObserver on the viewer renders the page again when the width changes, so the canvas keeps the resolution of the device after a rotation.
src/main.ts | 54 ++++++---------- src/scroll.ts | 28 ++++++++ src/style.css | 22 +++---- src/view.ts | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++++ test/scroll.test.ts | 80 +++++++++++++++++++++++ 5 files changed, 319 insertions(+), 48 deletions(-)
diff --git a/src/main.ts b/src/main.ts index 49eab49..71b3f64 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,7 @@ import { nextPage } from './read-order'; import { say, showPlayer, showReading } from './status'; import { isAndroid, SubRead } from './subread'; import { cueIndexAt, parseSubtitles, type Cue } from './subtitles'; +import { createPageView, type Marks } from './view'; import { bookKey, clearPages, @@ -37,9 +38,6 @@ import { /** Page width in pixels for OCR and the text layer. Boxes are stored in this scale. */ const OCR_WIDTH = 1600; -/** The space around a box of the mark, in CSS pixels. */ -const BOX_PAD = 2; - /** Milliseconds to wait after a page is read before the cues are aligned again. */ const ALIGN_DEBOUNCE = 300; @@ -106,7 +104,6 @@ const state = { /** The cue whose box is drawn on the page, or -1. */ markedCue: -1, clock: null as ClockState | null, - renderSeq: 0, readSeq: 0, alignTimer: null as ReturnType<typeof setTimeout> | null, srt: null as string | null, @@ -114,6 +111,8 @@ const state = { lookupPaused: false, }; +const view = createPageView(ui, marks); + // --- Settings --- const settings = { @@ -144,7 +143,7 @@ function loadSettings(): void { ui.follow.addEventListener('change', () => { settings.set('follow', followMode()); state.markedCue = -1; - drawBoxes(); + view.mark(); if (state.clock) applyClock({ ...state.clock, seeked: true }); }); ui.pauseLookup.addEventListener('change', () => @@ -293,7 +292,7 @@ async function readBook(): Promise<void> { state.tokens.splice(at, 0, ...entry.tokens); const how = entry.source === 'text' ? 'text layer' : 'OCR'; showReading(readingText(read(), total, `page ${page + 1}: ${how}`)); - if (page === state.currentPage) drawBoxes(); + if (page === state.currentPage) view.mark(); scheduleAlign(); } const matched = align(); @@ -500,7 +499,7 @@ function applyClock(s: ClockState): void { if (!changed || followMode() === 'off') return; state.markedCue = out.highlightCue ?? -1; if (out.turnToPage !== null) void showPage(out.turnToPage); - else drawBoxes(); + else view.mark(); } const clock: ClockSource = isAndroid ? new OverlayClock(SubRead) : new LocalAudioClock(ui.audio); @@ -539,39 +538,24 @@ function updateNav(): void { ui.pageRight.disabled = !pdf || page >= pdf.numPages - 1; } +/** The mark of page `index`: one box per text line of the marked cue. */ +function marks(index: number): Marks | null { + const entry = state.pages[index]; + const span = state.spans[state.markedCue]; + if (!entry || !span) return null; + return { + width: entry.width, + height: entry.height, + boxes: (pad) => lineBoxes(state.tokens, span, index, pad), + }; +} + async function showPage(index: number): Promise<void> { const pdf = state.pdf; if (!pdf || index < 0 || index >= pdf.numPages) return; - const seq = ++state.renderSeq; state.currentPage = index; updateNav(); - // Render at device resolution, up to the OCR width, so zoom stays sharp. - const dpr = window.devicePixelRatio || 1; - const width = Math.round(Math.min(OCR_WIDTH, Math.max(300, ui.viewer.clientWidth - 16) * dpr)); - const canvas = await pdf.renderPage(index, width); - if (seq !== state.renderSeq) return; // A newer render replaced this one. - ui.page.querySelector('canvas')?.remove(); - ui.page.prepend(canvas); - drawBoxes(); -} - -/** Draw one box per text line for the marked cue on the current page. */ -function drawBoxes(): void { - ui.overlay.replaceChildren(); - const span = state.spans[state.markedCue]; - const size = state.pages[state.currentPage]; - const shown = ui.page.querySelector('canvas')?.getBoundingClientRect().width; - if (!span || !size || !shown) return; - const pad = (BOX_PAD * size.width) / shown; - for (const box of lineBoxes(state.tokens, span, state.currentPage, pad)) { - const div = document.createElement('div'); - div.className = 'box'; - div.style.left = `${(100 * box.x0) / size.width}%`; - div.style.top = `${(100 * box.y0) / size.height}%`; - div.style.width = `${(100 * (box.x1 - box.x0)) / size.width}%`; - div.style.height = `${(100 * (box.y1 - box.y0)) / size.height}%`; - ui.overlay.append(div); - } + await view.show(pdf, index); } ui.pageLeft.addEventListener('click', () => void showPage(state.currentPage - 1)); diff --git a/src/scroll.ts b/src/scroll.ts new file mode 100644 index 0000000..948772a --- /dev/null +++ b/src/scroll.ts @@ -0,0 +1,28 @@ +// Where a scroll box scrolls to show a mark. Pure: no DOM. + +/** The share of the view height above the mark after a scroll. The mark lands in the upper third. */ +export const LEAD = 0.25; + +/** A vertical extent, in the scroll space of the box: 0 is the top of its content. */ +export interface Extent { + top: number; + bottom: number; +} + +/** + * The scroll position that shows `mark`, or null when the mark is in view + * already. The view shows `height` pixels from the scroll position `top`. + * `max` is the largest scroll position. The mark goes LEAD of the height + * below the top of the view, so the lines after it show too. With + * `always`, there is a position also for a mark in view. + */ +export function scrollTarget( + mark: Extent, + view: { top: number; height: number }, + max: number, + always = false, +): number | null { + const inView = mark.top >= view.top && mark.bottom <= view.top + view.height; + if (inView && !always) return null; + return Math.round(Math.min(Math.max(0, mark.top - view.height * LEAD), Math.max(0, max))); +} diff --git a/src/style.css b/src/style.css index 8c4e291..0a0a3b9 100644 --- a/src/style.css +++ b/src/style.css @@ -283,6 +283,8 @@ body.menu-open #panel { flex: 1; min-height: 0; overflow: auto; + /* A scroll bar that comes and goes does not change the width of the page. */ + scrollbar-gutter: stable; padding: 0.5rem; } @@ -325,30 +327,24 @@ body.menu-open #panel { display: none; } +/* + * The page fits the viewer width in each orientation, up to 1000 CSS + * pixels on a wide screen. The viewer scrolls vertically. + */ #page { position: relative; margin: 0 auto; - width: fit-content; - max-width: 100%; + width: 100%; + max-width: 1000px; } -/* The page fits the viewer width. The viewer scrolls vertically. */ #page canvas { display: block; - max-width: 100%; - width: auto; + width: 100%; height: auto; box-shadow: 0 0 6px #0006; } -/* Landscape phone: the page fits the viewer height instead. */ -@media (orientation: landscape) and (max-width: 899px) { - #page canvas { - max-height: calc(100vh - var(--topbar) - 1rem); - max-height: calc(100dvh - var(--topbar) - 1rem); - } -} - /* The padding of a box can go past the edge of the page. The page clips it. */ #overlay { position: absolute; diff --git a/src/view.ts b/src/view.ts new file mode 100644 index 0000000..46e4f87 --- /dev/null +++ b/src/view.ts @@ -0,0 +1,183 @@ +// The page view. It renders a page to fit the width of the viewer, draws +// the boxes of the mark, keeps the mark in view, and renders the page +// again when the width of the viewer changes. + +import type { Box } from './line-boxes'; +import type { PdfDoc } from './pdf'; +import { scrollTarget, type Extent } from './scroll'; + +/** The widest canvas, in device pixels. It limits the memory of one page. */ +const MAX_RENDER = 2048; + +/** The space around a box of the mark, in CSS pixels. */ +const BOX_PAD = 2; + +/** After the user scrolls the viewer, the view does not scroll by itself for this many milliseconds. */ +export const USER_SCROLL_MS = 3000; + +/** Milliseconds after the last resize before the page renders again. */ +const RESIZE_MS = 200; + +/** Scroll events for this many milliseconds after a scroll by the view come from the view. */ +const AUTO_SCROLL_MS = 1000; + +/** The mark of a page: the size of the page, and its boxes, in page pixels. */ +export interface Marks { + width: number; + height: number; + /** The boxes, with `pad` page pixels of padding. */ + boxes(pad: number): Box[]; +} + +export interface PageViewElements { + viewer: HTMLElement; + page: HTMLElement; + overlay: HTMLElement; +} + +export interface PageView { + /** Renders page `index`, draws its mark, and scrolls to the mark, else to the top. */ + show(pdf: PdfDoc, index: number): Promise<void>; + /** + * Draws the mark of the page again. When the mark left the view, and the + * user did not scroll lately, the viewer scrolls to it. + */ + mark(): void; + /** The canvas of the page, or null before the first render. */ + canvas(): HTMLCanvasElement | null; +} + +/** `marks(index)` gives the mark of page `index`, or null when it has none. */ +export function createPageView( + ui: PageViewElements, + marks: (index: number) => Marks | null, +): PageView { + let pdf: PdfDoc | null = null; + /** The page to show. */ + let index = -1; + /** The page on the canvas, and the size that it was rendered for. */ + let shown = { pdf: null as PdfDoc | null, index: -1, cssWidth: 0, dpr: 0 }; + let seq = 0; + let autoUntil = 0; + let userAt = -Infinity; + + const canvas = (): HTMLCanvasElement | null => ui.page.querySelector('canvas'); + + /** Draws the boxes of the mark. Gives their extent in the scroll space of the viewer, or null. */ + function draw(): Extent | null { + ui.overlay.replaceChildren(); + const c = canvas(); + const m = shown.index === index ? marks(index) : null; + if (!c || !m) return null; + const rect = c.getBoundingClientRect(); + if (rect.width === 0) return null; + const boxes = m.boxes((BOX_PAD * m.width) / rect.width); + if (boxes.length === 0) return null; + for (const box of boxes) { + const div = document.createElement('div'); + div.className = 'box'; + div.style.left = `${(100 * box.x0) / m.width}%`; + div.style.top = `${(100 * box.y0) / m.height}%`; + div.style.width = `${(100 * (box.x1 - box.x0)) / m.width}%`; + div.style.height = `${(100 * (box.y1 - box.y0)) / m.height}%`; + ui.overlay.append(div); + } + const top = Math.min(...boxes.map((b) => b.y0)); + const bottom = Math.max(...boxes.map((b) => b.y1)); + const offset = rect.top - ui.viewer.getBoundingClientRect().top + ui.viewer.scrollTop; + const scale = rect.height / m.height; + return { top: offset + top * scale, bottom: offset + bottom * scale }; + } + + function scrollTo(top: number | null, smooth: boolean): void { + const v = ui.viewer; + if (top === null || Math.abs(v.scrollTop - top) < 1) return; + autoUntil = performance.now() + AUTO_SCROLL_MS; + v.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' }); + } + + /** Scrolls to the mark when it is out of view, unless the user scrolled lately. */ + function keepInView(extent: Extent | null, smooth: boolean): void { + if (!extent || performance.now() - userAt < USER_SCROLL_MS) return; + const v = ui.viewer; + const view = { top: v.scrollTop, height: v.clientHeight }; + scrollTo(scrollTarget(extent, view, v.scrollHeight - v.clientHeight), smooth); + } + + /** Renders the page. `turn`: a new page, so scroll to its mark or to its top. */ + async function render(turn: boolean): Promise<void> { + const doc = pdf; + if (!doc || index < 0) return; + const my = ++seq; + const at = index; + const cssWidth = ui.page.clientWidth; + const dpr = window.devicePixelRatio || 1; + const width = Math.round(Math.min(MAX_RENDER, Math.max(300, cssWidth * dpr))); + const c = await doc.renderPage(at, width); + if (my !== seq) return; // A newer render replaced this one. + // A new canvas can change the height of the content, and the browser + // then moves the scroll position. That scroll does not come from the user. + autoUntil = performance.now() + AUTO_SCROLL_MS; + canvas()?.remove(); + ui.page.prepend(c); + shown = { pdf: doc, index: at, cssWidth, dpr }; + const extent = draw(); + const v = ui.viewer; + if (turn) { + const view = { top: v.scrollTop, height: v.clientHeight }; + const max = v.scrollHeight - v.clientHeight; + scrollTo(extent ? scrollTarget(extent, view, max, true) : 0, false); + } else { + keepInView(extent, false); + } + } + + // A scroll event that the view did not cause comes from the user. + ui.viewer.addEventListener( + 'scroll', + () => { + if (performance.now() > autoUntil) userAt = performance.now(); + }, + { passive: true }, + ); + ui.viewer.addEventListener('scrollend', () => { + autoUntil = 0; + }); + for (const type of ['wheel', 'touchmove']) { + ui.viewer.addEventListener( + type, + () => { + userAt = performance.now(); + autoUntil = 0; + }, + { passive: true }, + ); + } + + // Render again when the width of the page changes, for example after a + // rotation. The canvas stretches at once; the new render makes it sharp. + let resizeTimer: ReturnType<typeof setTimeout> | undefined; + new ResizeObserver(() => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + const dpr = window.devicePixelRatio || 1; + const width = ui.page.clientWidth; + if (width > 0 && (Math.abs(width - shown.cssWidth) >= 1 || dpr !== shown.dpr)) { + void render(false); + } + }, RESIZE_MS); + }).observe(ui.viewer); + + return { + show(doc, i) { + const turn = doc !== shown.pdf || i !== shown.index; + pdf = doc; + index = i; + return render(turn); + }, + mark() { + keepInView(draw(), true); + }, + canvas, + }; +} diff --git a/test/scroll.test.ts b/test/scroll.test.ts new file mode 100644 index 0000000..2dd3192 --- /dev/null +++ b/test/scroll.test.ts @@ -0,0 +1,80 @@ +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { LEAD, scrollTarget } from '../src/scroll'; + +describe('scrollTarget', () => { + const view = { top: 500, height: 800 }; + + it('gives null for a mark in view', () => { + expect(scrollTarget({ top: 600, bottom: 640 }, view, 2000)).toBeNull(); + }); + + it('brings a mark above or below the view to the upper third', () => { + // The follower turned to page 2: the view stayed at 500, the mark was at -220. + expect(scrollTarget({ top: 280, bottom: 320 }, view, 2000)).toBe(280 - 800 * LEAD); + expect(scrollTarget({ top: 1400, bottom: 1440 }, view, 2000)).toBe(1400 - 800 * LEAD); + }); + + it('scrolls for a mark that is only partly in view', () => { + expect(scrollTarget({ top: 1280, bottom: 1330 }, view, 2000)).toBe(1280 - 800 * LEAD); + }); + + it('stays inside the content', () => { + expect(scrollTarget({ top: 50, bottom: 90 }, view, 2000)).toBe(0); + expect(scrollTarget({ top: 2700, bottom: 2740 }, view, 2000)).toBe(2000); + }); + + it('gives a position for a mark in view when asked', () => { + expect(scrollTarget({ top: 600, bottom: 640 }, view, 2000, true)).toBe(400); + }); + + const input = fc + .record({ height: fc.integer({ min: 100, max: 2000 }), max: fc.nat({ max: 5000 }) }) + .chain(({ height, max }) => + fc.record({ + height: fc.constant(height), + max: fc.constant(max), + viewTop: fc.integer({ min: 0, max }), + // A mark inside the content, which is max + height high. + top: fc.integer({ min: 0, max: max + height }), + size: fc.nat({ max: 200 }), + always: fc.boolean(), + }), + ); + + it('gives null only for a mark in view, and a position inside the content', () => { + fc.assert( + fc.property(input, ({ height, max, viewTop, top, size, always }) => { + const mark = { top, bottom: top + size }; + const view = { top: viewTop, height }; + const target = scrollTarget(mark, view, max, always); + const inView = mark.top >= viewTop && mark.bottom <= viewTop + height; + expect(target === null).toBe(inView && !always); + if (target !== null) { + expect(target).toBeGreaterThanOrEqual(0); + expect(target).toBeLessThanOrEqual(max); + } + }), + ); + }); + + it('shows the top of the mark after the scroll, in the upper third when it can', () => { + fc.assert( + fc.property(input, ({ height, max, viewTop, top, size, always }) => { + const target = scrollTarget( + { top, bottom: top + size }, + { top: viewTop, height }, + max, + always, + ); + if (target === null) return; + expect(top).toBeGreaterThanOrEqual(target); + expect(top).toBeLessThanOrEqual(target + height); + if (target > 0 && target < max) { + expect(Math.abs(top - target - height * LEAD)).toBeLessThanOrEqual(0.5); + expect(top - target).toBeLessThanOrEqual(height / 3); + } + }), + ); + }); +});