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 887db7e823a6b5ad811a5712a44b261a0d9ea489
truex <truex@equwal.com>
2026-09-26 14:46:45 -0700

Close the drawer with a tap outside it, and scroll only the cue list

With the drawer open, a tap on the page closed the drawer and also sent
a lookup, because the click handler of the page ran first. The new
module drawer.ts closes the drawer in the capture phase, and the tap
does nothing else. On a phone the drawer also closes after a PDF or a
subtitles file is chosen and after a cue click, so the user sees the
page.

The cue list has its own scroll box. A new cue scrolls only that box,
and only while the list shows. scrollIntoView scrolled the menu too, so
the settings went out of view on each cue. The menu button of the top
bar is hidden again on a desktop.

 src/drawer.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.ts   | 69 +++++++++++++++++++++++++++++++----------------------------
 src/style.css | 12 ++++++++++-
 3 files changed, 105 insertions(+), 34 deletions(-)
diff --git a/src/drawer.ts b/src/drawer.ts
new file mode 100644
index 0000000..4bb95b0
--- /dev/null
+++ b/src/drawer.ts
@@ -0,0 +1,58 @@
+// The menu. On a phone it is a drawer over the page. On a desktop it is a
+// side panel that always shows.
+
+/** The width from which the menu is a side panel. The same as the media query in style.css. */
+const desktop = window.matchMedia('(min-width: 900px)');
+
+export interface Drawer {
+  /** True while the drawer covers the page: a phone with the drawer open. */
+  covers(): boolean;
+  /** True while the menu shows: a desktop, or an open drawer. */
+  shows(): boolean;
+  /** Opens or closes the drawer. */
+  set(open: boolean): void;
+  /** Closes the drawer on a phone, so the user sees the page after an action in the menu. */
+  closeOnPhone(): void;
+}
+
+/** `onOpen` runs each time the drawer opens. */
+export function setupDrawer(
+  ui: { menu: HTMLButtonElement; panel: HTMLElement },
+  onOpen: () => void,
+): Drawer {
+  const open = (): boolean => document.body.classList.contains('menu-open');
+  const covers = (): boolean => !desktop.matches && open();
+
+  function set(value: boolean): void {
+    if (value === open()) return;
+    document.body.classList.toggle('menu-open', value);
+    ui.menu.setAttribute('aria-expanded', String(value));
+    if (value) onOpen();
+  }
+
+  ui.menu.addEventListener('click', () => set(!open()));
+
+  // With the drawer open, a tap outside it only closes it. The capture
+  // phase runs before the handlers of the page, so the tap does no lookup.
+  document.addEventListener(
+    'click',
+    (e) => {
+      if (!covers()) return;
+      const target = e.target as Node;
+      if (ui.panel.contains(target) || ui.menu.contains(target)) return;
+      e.stopPropagation();
+      e.preventDefault();
+      set(false);
+    },
+    true,
+  );
+
+  return {
+    covers,
+    shows: () => desktop.matches || open(),
+    set,
+    closeOnPhone() {
+      if (!desktop.matches) set(false);
+    },
+  };
+}
diff --git a/src/main.ts b/src/main.ts
index e850d4d..27486c4 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,11 +1,15 @@
-// UI wiring. The logic lives in the pure modules: align.ts, follower.ts,
-// hit-test.ts, line-boxes.ts, read-order.ts, text-layer.ts, player-state.ts,
-// play-clock.ts.
+// UI wiring: the state of the book, the subtitles and the follow. The
+// logic lives in the pure modules (align.ts, cue-pages.ts, follower.ts,
+// hit-test.ts, line-boxes.ts, messages.ts, paging.ts, read-order.ts,
+// scroll.ts, text-layer.ts, player-state.ts, play-clock.ts). The UI parts
+// are in drawer.ts (the menu), nav.ts (page turns by the user), status.ts
+// (the strip) and view.ts (the page).
 
 import { createAligner, type Aligner, type OcrToken, type TokenSpan } from './align';
 import { bookText } from './book-text';
 import { LocalAudioClock, OverlayClock, type ClockSource, type ClockState } from './clock-source';
 import { audioPage, cueParts, cueProgress, pageStartTime, type CueParts } from './cue-pages';
+import { setupDrawer } from './drawer';
 import { follow, type FollowEvent, type FollowMode } from './follower';
 import { lookupText, tapTolerance, tokenAt } from './hit-test';
 import { lineBoxes } from './line-boxes';
@@ -23,10 +27,10 @@ import { createOcr, type Ocr } from './ocr';
 import { defaultRtl } from './paging';
 import { loadPdf, type PdfDoc } from './pdf';
 import { nextPage } from './read-order';
+import { scrollTarget } from './scroll';
 import { say, showPlayer, showReading } from './status';
 import { isAndroid, SubRead } from './subread';
 import { lastCueAt, parseSubtitles, type Cue } from './subtitles';
-import { createPageView, type Marks } from './view';
 import {
   bookKey,
   clearPages,
@@ -37,6 +41,7 @@ import {
   putSrt,
   type PageEntry,
 } from './token-cache';
+import { createPageView, type Marks } from './view';
 
 /** Page width in pixels for OCR and the text layer. Boxes are stored in this scale. */
 const OCR_WIDTH = 1600;
@@ -122,7 +127,8 @@ const state = {
 
 const view = createPageView(ui, marks);
 
-const desktop = window.matchMedia('(min-width: 900px)');
+// The cue list can show the cue of now only when the drawer opens.
+const drawer = setupDrawer(ui, () => scrollCueList(state.activeCue));
 
 const nav = setupNav(
   ui,
@@ -130,7 +136,7 @@ const nav = setupNav(
     pages: () => state.pdf?.numPages ?? 0,
     current: () => state.currentPage,
     rtl: () => ui.rtl.checked,
-    drawerOpen: () => !desktop.matches && document.body.classList.contains('menu-open'),
+    drawerOpen: drawer.covers,
   },
   turnByUser,
 );
@@ -202,27 +208,6 @@ function sayError(err: unknown): void {
   say(errorMessage(err, isAndroid));
 }
 
-// --- Drawer ---
-
-function setMenu(open: boolean): void {
-  document.body.classList.toggle('menu-open', open);
-  ui.menu.setAttribute('aria-expanded', String(open));
-}
-
-ui.menu.addEventListener('click', () => setMenu(!document.body.classList.contains('menu-open')));
-
-// A tap outside the drawer closes the drawer.
-document.addEventListener('click', (e) => {
-  const target = e.target as Node;
-  if (
-    document.body.classList.contains('menu-open') &&
-    !ui.panel.contains(target) &&
-    !ui.menu.contains(target)
-  ) {
-    setMenu(false);
-  }
-});
-
 // --- The PDF ---
 
 ui.openPdf.addEventListener('click', () => ui.pdfFile.click());
@@ -230,6 +215,7 @@ ui.openPdf.addEventListener('click', () => ui.pdfFile.click());
 ui.pdfFile.addEventListener('change', async () => {
   const file = ui.pdfFile.files?.[0];
   if (!file) return;
+  drawer.closeOnPhone();
   try {
     state.readSeq++;
     await state.pdf?.destroy();
@@ -382,6 +368,7 @@ function loadSubtitles(text: string, source: string): void {
 ui.subFile.addEventListener('change', async () => {
   const file = ui.subFile.files?.[0];
   if (!file) return;
+  drawer.closeOnPhone();
   loadSubtitles(await file.text(), file.name);
 });
 
@@ -479,20 +466,36 @@ function renderCueList(): void {
       const span = state.spans[i];
       if (span && !span.matched) li.classList.add('unmatched');
       if (i === state.activeCue) li.classList.add('active');
-      li.addEventListener('click', () => seek(cue.start * 1000));
+      li.addEventListener('click', () => {
+        drawer.closeOnPhone();
+        seek(cue.start * 1000);
+      });
       return li;
     }),
   );
+  scrollCueList(state.activeCue);
 }
 
 function markCueInList(i: number): void {
   const items = ui.cues.children;
   items[state.activeCue]?.classList.remove('active');
-  const item = items[i];
-  if (item) {
-    item.classList.add('active');
-    item.scrollIntoView({ block: 'nearest' });
-  }
+  items[i]?.classList.add('active');
+  scrollCueList(i);
+}
+
+/**
+ * Scrolls the cue list so that cue `i` shows, while the list shows. Only
+ * the list scrolls: scrollIntoView also scrolled the menu, and the
+ * settings went out of view.
+ */
+function scrollCueList(i: number): void {
+  const item = ui.cues.children[i];
+  if (!(item instanceof HTMLElement) || !drawer.shows()) return;
+  const list = ui.cues;
+  const mark = { top: item.offsetTop, bottom: item.offsetTop + item.offsetHeight };
+  const shown = { top: list.scrollTop, height: list.clientHeight };
+  const top = scrollTarget(mark, shown, list.scrollHeight - list.clientHeight);
+  if (top !== null) list.scrollTop = top;
 }
 
 // --- The follow ---
diff --git a/src/style.css b/src/style.css
index f47e8db..30ee1e7 100644
--- a/src/style.css
+++ b/src/style.css
@@ -277,10 +277,20 @@ body.menu-open #panel {
   width: 100%;
 }
 
+/*
+ * The cue list has its own scroll box: it takes the free height of the
+ * menu, at least 12rem. It scrolls to the cue of now, and the settings
+ * above it stay.
+ */
 #cues {
+  position: relative;
+  flex: 1 0 12rem;
+  min-height: 12rem;
+  overflow-y: auto;
   padding-left: 1.5rem;
   margin: 0;
   font-size: 0.9rem;
+  border-top: 1px solid #8884;
 }
 
 #cues li {
@@ -405,7 +415,7 @@ body.zoomed #viewer {
     grid-template-rows: var(--topbar) auto 1fr;
   }
 
-  #menu {
+  #topbar #menu {
     display: none;
   }