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


src/view.ts (7420 bytes)

1 // The page view. It renders a page to fit the width of the viewer, draws
2 // the boxes of the mark, keeps the mark in view, and renders the page
3 // again when the width of the viewer or the pinch zoom changes.
4 
5 import type { Box } from './line-boxes';
6 import type { PdfDoc } from './pdf';
7 import { renderWidth } from './render-size';
8 import { scrollTarget, type Extent } from './scroll';
9 
10 /** The height of a page divided by its width before the first render: A4. */
11 const DEFAULT_ASPECT = 842 / 595;
12 
13 /** Milliseconds after the last change of the pinch zoom before the page renders again. */
14 const ZOOM_MS = 300;
15 
16 /** The least space around a box of the mark, in CSS pixels. See padBox in line-boxes.ts. */
17 const BOX_PAD = 2;
18 
19 /** After the user scrolls the viewer, the view does not scroll by itself for this many milliseconds. */
20 export const USER_SCROLL_MS = 3000;
21 
22 /** Milliseconds after the last resize before the page renders again. */
23 const RESIZE_MS = 200;
24 
25 /** Scroll events for this many milliseconds after a scroll by the view come from the view. */
26 const AUTO_SCROLL_MS = 1000;
27 
28 /** The mark of a page: the size of the page, and its boxes, in page pixels. */
29 export interface Marks {
30   width: number;
31   height: number;
32   /** The boxes, with at least `pad` page pixels of padding. */
33   boxes(pad: number): Box[];
34 }
35 
36 export interface PageViewElements {
37   viewer: HTMLElement;
38   page: HTMLElement;
39   overlay: HTMLElement;
40 }
41 
42 export interface PageView {
43   /** Renders page `index`, draws its mark, and scrolls to the mark, else to the top. */
44   show(pdf: PdfDoc, index: number): Promise<void>;
45   /**
46    * Draws the mark of the page again. When the mark left the view, and the
47    * user did not scroll lately, the viewer scrolls to it.
48    */
49   mark(): void;
50   /** The canvas of the page, or null before the first render. */
51   canvas(): HTMLCanvasElement | null;
52 }
53 
54 /** `marks(index)` gives the mark of page `index`, or null when it has none. */
55 export function createPageView(
56   ui: PageViewElements,
57   marks: (index: number) => Marks | null,
58 ): PageView {
59   let pdf: PdfDoc | null = null;
60   /** The page to show. */
61   let index = -1;
62   /** The page on the canvas, and the width of the canvas in device pixels. */
63   let shown = { pdf: null as PdfDoc | null, index: -1, width: 0 };
64   let seq = 0;
65   let autoUntil = 0;
66   let userAt = -Infinity;
67 
68   const canvas = (): HTMLCanvasElement | null => ui.page.querySelector('canvas');
69 
70   /**
71    * The width of the canvas for the page now: sharp for the screen and the
72    * pinch zoom, within the limits of renderWidth. The shape of the page on
73    * the canvas stands for the shape of the next page.
74    */
75   function targetWidth(): number {
76     const c = canvas();
77     return renderWidth({
78       cssWidth: ui.page.clientWidth,
79       dpr: window.devicePixelRatio || 1,
80       zoom: window.visualViewport?.scale ?? 1,
81       aspect: c && c.width > 0 ? c.height / c.width : DEFAULT_ASPECT,
82     });
83   }
84 
85   /** Draws the boxes of the mark. Gives their extent in the scroll space of the viewer, or null. */
86   function draw(): Extent | null {
87     ui.overlay.replaceChildren();
88     const c = canvas();
89     const m = shown.index === index ? marks(index) : null;
90     if (!c || !m) return null;
91     const rect = c.getBoundingClientRect();
92     if (rect.width === 0) return null;
93     const boxes = m.boxes((BOX_PAD * m.width) / rect.width);
94     if (boxes.length === 0) return null;
95     for (const box of boxes) {
96       const div = document.createElement('div');
97       div.className = 'box';
98       div.style.left = `${(100 * box.x0) / m.width}%`;
99       div.style.top = `${(100 * box.y0) / m.height}%`;
100       div.style.width = `${(100 * (box.x1 - box.x0)) / m.width}%`;
101       div.style.height = `${(100 * (box.y1 - box.y0)) / m.height}%`;
102       ui.overlay.append(div);
103     }
104     const top = Math.min(...boxes.map((b) => b.y0));
105     const bottom = Math.max(...boxes.map((b) => b.y1));
106     const offset = rect.top - ui.viewer.getBoundingClientRect().top + ui.viewer.scrollTop;
107     const scale = rect.height / m.height;
108     return { top: offset + top * scale, bottom: offset + bottom * scale };
109   }
110 
111   function scrollTo(top: number | null, smooth: boolean): void {
112     const v = ui.viewer;
113     if (top === null || Math.abs(v.scrollTop - top) < 1) return;
114     autoUntil = performance.now() + AUTO_SCROLL_MS;
115     v.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
116   }
117 
118   /** Scrolls to the mark when it is out of view, unless the user scrolled lately. */
119   function keepInView(extent: Extent | null, smooth: boolean): void {
120     if (!extent || performance.now() - userAt < USER_SCROLL_MS) return;
121     const v = ui.viewer;
122     const view = { top: v.scrollTop, height: v.clientHeight };
123     scrollTo(scrollTarget(extent, view, v.scrollHeight - v.clientHeight), smooth);
124   }
125 
126   /**
127    * Renders the page to show. When it is another page than the page on
128    * the canvas, the viewer scrolls to its mark, else to its top. Else the
129    * mark stays in view.
130    */
131   async function render(): Promise<void> {
132     const doc = pdf;
133     if (!doc || index < 0) return;
134     const my = ++seq;
135     const at = index;
136     const width = targetWidth();
137     const c = await doc.renderPage(at, width);
138     if (my !== seq) return; // A newer render replaced this one.
139     const turn = doc !== shown.pdf || at !== shown.index;
140     // A new canvas can change the height of the content, and the browser
141     // then moves the scroll position. That scroll does not come from the user.
142     autoUntil = performance.now() + AUTO_SCROLL_MS;
143     canvas()?.remove();
144     ui.page.prepend(c);
145     shown = { pdf: doc, index: at, width };
146     const extent = draw();
147     const v = ui.viewer;
148     if (turn) {
149       const view = { top: v.scrollTop, height: v.clientHeight };
150       const max = v.scrollHeight - v.clientHeight;
151       scrollTo(extent ? scrollTarget(extent, view, max, true) : 0, false);
152     } else {
153       keepInView(extent, false);
154     }
155   }
156 
157   // A scroll event that the view did not cause comes from the user.
158   ui.viewer.addEventListener(
159     'scroll',
160     () => {
161       if (performance.now() > autoUntil) userAt = performance.now();
162     },
163     { passive: true },
164   );
165   ui.viewer.addEventListener('scrollend', () => {
166     autoUntil = 0;
167   });
168   for (const type of ['wheel', 'touchmove']) {
169     ui.viewer.addEventListener(
170       type,
171       () => {
172         userAt = performance.now();
173         autoUntil = 0;
174       },
175       { passive: true },
176     );
177   }
178 
179   // Render again when the canvas needs another width: after a rotation, or
180   // when a pinch zoom settles. The canvas stretches at once. The new render
181   // makes it sharp. The marks are in page pixels, so they stay correct.
182   let resizeTimer: ReturnType<typeof setTimeout> | undefined;
183   function renderAgainSoon(ms: number): void {
184     clearTimeout(resizeTimer);
185     resizeTimer = setTimeout(() => {
186       if (shown.index < 0 || ui.page.clientWidth === 0 || targetWidth() === shown.width) return;
187       // A render of a closed document fails. The stretched canvas stays.
188       render().catch(() => undefined);
189     }, ms);
190   }
191   new ResizeObserver(() => renderAgainSoon(RESIZE_MS)).observe(ui.viewer);
192   window.visualViewport?.addEventListener('resize', () => renderAgainSoon(ZOOM_MS));
193 
194   return {
195     show(doc, i) {
196       pdf = doc;
197       index = i;
198       return render();
199     },
200     mark() {
201       keepInView(draw(), true);
202     },
203     canvas,
204   };
205 }