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 }