Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


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