src/nav.ts (8080 bytes)
1 // Page turns by the user: the arrows of the top bar, the keys, a swipe on 2 // the viewer, and the page jump of the page label. Also the long press on 3 // the viewer, because it shares the pointer events of the swipe. The 4 // decisions are in paging.ts. 5 6 import { 7 arrowsOff, 8 arrowStep, 9 keyStep, 10 LONG_PRESS_MS, 11 movedTooFar, 12 parsePage, 13 swipeStep, 14 type Step, 15 } from './paging'; 16 import { say } from './status'; 17 18 /** A click this soon after the end of a swipe or a long press comes from it, and does no lookup. */ 19 const CLICK_AFTER_SWIPE_MS = 400; 20 21 /** A click this soon after the tap that cancels the page jump comes from that tap. */ 22 const CLICK_AFTER_CANCEL_MS = 1000; 23 24 /** A page zoomed in more than this is zoomed. */ 25 const ZOOMED = 1.01; 26 27 export interface NavElements { 28 viewer: HTMLElement; 29 pageLeft: HTMLButtonElement; 30 pageRight: HTMLButtonElement; 31 pageLabel: HTMLButtonElement; 32 pageInput: HTMLInputElement; 33 } 34 35 export interface NavState { 36 /** The count of pages, or 0 with no PDF. */ 37 pages(): number; 38 /** The index of the page shown. */ 39 current(): number; 40 /** True when the pages turn right to left. */ 41 rtl(): boolean; 42 /** True while the drawer covers the page. */ 43 drawerOpen(): boolean; 44 } 45 46 export interface Nav { 47 /** Shows the page label, and the state and the names of the arrows. */ 48 update(): void; 49 /** True while the page jump is open. */ 50 jumpOpen(): boolean; 51 /** Closes the page jump. */ 52 closeJump(): void; 53 } 54 55 function zoomed(): boolean { 56 return (window.visualViewport?.scale ?? 1) > ZOOMED; 57 } 58 59 /** 60 * `turnTo(index)` shows the page that the user asked for. `longPress(x, y)` 61 * gets a long press on the viewer, in client pixels, and gives true when 62 * it used it. Then the click that follows does nothing. 63 */ 64 export function setupNav( 65 ui: NavElements, 66 s: NavState, 67 turnTo: (index: number) => void, 68 longPress: (x: number, y: number) => boolean, 69 ): Nav { 70 function go(step: Step): void { 71 const to = s.current() + step; 72 if (to >= 0 && to < s.pages()) turnTo(to); 73 } 74 75 // --- The arrows --- 76 77 ui.pageLeft.addEventListener('click', () => go(arrowStep('left', s.rtl()))); 78 ui.pageRight.addEventListener('click', () => go(arrowStep('right', s.rtl()))); 79 80 function name(button: HTMLButtonElement, step: Step): void { 81 const text = step > 0 ? 'Next page' : 'Previous page'; 82 if (button.title === text) return; 83 button.title = text; 84 button.setAttribute('aria-label', text); 85 } 86 87 // --- The keys --- 88 89 document.addEventListener('keydown', (e) => { 90 if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || s.drawerOpen()) return; 91 const target = e.target; 92 if ( 93 target instanceof HTMLElement && 94 (target.closest('input, select, textarea') || target.isContentEditable) 95 ) { 96 return; 97 } 98 const step = keyStep(e.key, s.rtl()); 99 if (step === 0) return; 100 e.preventDefault(); 101 go(step); 102 }); 103 104 // --- The swipe --- 105 106 // The browser pans the page only vertically, so a sideways move stays 107 // with the swipe. A zoomed page pans in each direction. 108 const markZoom = (): void => { 109 document.body.classList.toggle('zoomed', zoomed()); 110 }; 111 window.visualViewport?.addEventListener('resize', markZoom); 112 markZoom(); 113 114 // One finger on the viewer gives a tap, a swipe or a long press, never 115 // two of them. A move of more than LONG_PRESS_MOVE stops the long press, 116 // and a long press is no swipe. 117 118 /** The finger on the viewer: where it went down, and true after its long press. */ 119 let start: { id: number; x: number; y: number; pressed: boolean } | null = null; 120 let pressTimer: ReturnType<typeof setTimeout> | undefined; 121 let swipedAt = -Infinity; 122 ui.viewer.addEventListener('pointerdown', (e) => { 123 clearTimeout(pressTimer); 124 // A second finger makes a pinch. The right mouse button opens the menu of the browser. 125 if (!e.isPrimary || e.button !== 0) { 126 start = null; 127 return; 128 } 129 const finger = { id: e.pointerId, x: e.clientX, y: e.clientY, pressed: false }; 130 start = finger; 131 pressTimer = setTimeout(() => { 132 if (start !== finger || s.drawerOpen()) return; 133 finger.pressed = longPress(finger.x, finger.y); 134 }, LONG_PRESS_MS); 135 }); 136 ui.viewer.addEventListener('pointermove', (e) => { 137 if ( 138 start && 139 e.pointerId === start.id && 140 movedTooFar(e.clientX - start.x, e.clientY - start.y) 141 ) { 142 clearTimeout(pressTimer); 143 } 144 }); 145 ui.viewer.addEventListener('pointercancel', () => { 146 clearTimeout(pressTimer); 147 start = null; 148 }); 149 // Android shows a menu for a long press on the page. The long press makes a card instead. 150 ui.viewer.addEventListener('contextmenu', (e) => { 151 if (start) e.preventDefault(); 152 }); 153 ui.viewer.addEventListener('pointerup', (e) => { 154 if (!start || e.pointerId !== start.id) return; 155 clearTimeout(pressTimer); 156 const dx = e.clientX - start.x; 157 const dy = e.clientY - start.y; 158 const pressed = start.pressed; 159 start = null; 160 // The click after a long press is not a tap on a word. 161 if (pressed) { 162 swipedAt = e.timeStamp; 163 return; 164 } 165 if (s.drawerOpen()) return; 166 const v = ui.viewer; 167 const step = swipeStep( 168 { dx, dy, zoomed: zoomed(), wide: v.scrollWidth > v.clientWidth + 1 }, 169 s.rtl(), 170 ); 171 if (step === 0) return; 172 swipedAt = e.timeStamp; 173 go(step); 174 }); 175 // The click after a swipe is not a tap on a word. 176 ui.viewer.addEventListener( 177 'click', 178 (e) => { 179 if (e.timeStamp - swipedAt < CLICK_AFTER_SWIPE_MS) { 180 swipedAt = -Infinity; 181 e.stopPropagation(); 182 e.preventDefault(); 183 } 184 }, 185 true, 186 ); 187 188 // --- The page jump --- 189 190 function openJump(): void { 191 if (s.pages() === 0) return; 192 ui.pageInput.value = String(s.current() + 1); 193 ui.pageInput.placeholder = `1-${s.pages()}`; 194 ui.pageLabel.hidden = true; 195 ui.pageInput.hidden = false; 196 ui.pageInput.focus(); 197 ui.pageInput.select(); 198 } 199 200 function closeJump(): void { 201 if (ui.pageInput.hidden) return; 202 ui.pageInput.hidden = true; 203 ui.pageLabel.hidden = false; 204 } 205 206 ui.pageLabel.addEventListener('click', openJump); 207 ui.pageInput.addEventListener('keydown', (e) => { 208 if (e.key === 'Escape') { 209 e.preventDefault(); 210 closeJump(); 211 ui.pageLabel.focus(); 212 } else if (e.key === 'Enter') { 213 e.preventDefault(); 214 const index = parsePage(ui.pageInput.value, s.pages()); 215 if (index === null) { 216 say(`Type a page number from 1 to ${s.pages()}.`); 217 return; 218 } 219 closeJump(); 220 ui.pageLabel.focus(); 221 if (index !== s.current()) turnTo(index); 222 } 223 }); 224 // Tab or another focus change cancels the jump. 225 ui.pageInput.addEventListener('blur', closeJump); 226 227 // A tap outside the open page jump only cancels it: the click that 228 // follows does nothing else. 229 let cancelledAt = -Infinity; 230 document.addEventListener( 231 'pointerdown', 232 (e) => { 233 if (ui.pageInput.hidden || e.target === ui.pageInput) return; 234 closeJump(); 235 cancelledAt = e.timeStamp; 236 }, 237 true, 238 ); 239 document.addEventListener( 240 'click', 241 (e) => { 242 if (e.timeStamp - cancelledAt < CLICK_AFTER_CANCEL_MS) { 243 cancelledAt = -Infinity; 244 e.stopPropagation(); 245 e.preventDefault(); 246 } 247 }, 248 true, 249 ); 250 251 return { 252 update() { 253 const pages = s.pages(); 254 const page = s.current(); 255 const rtl = s.rtl(); 256 ui.pageLabel.disabled = pages === 0; 257 ui.pageLabel.textContent = pages > 0 ? `${page + 1} / ${pages}` : 'No PDF'; 258 ui.pageLabel.setAttribute( 259 'aria-label', 260 pages > 0 ? `Page ${page + 1} of ${pages}. Go to a page.` : 'No PDF', 261 ); 262 const off = arrowsOff(page, pages, rtl); 263 ui.pageLeft.disabled = off.left; 264 ui.pageRight.disabled = off.right; 265 name(ui.pageLeft, arrowStep('left', rtl)); 266 name(ui.pageRight, arrowStep('right', rtl)); 267 if (pages === 0) closeJump(); 268 }, 269 jumpOpen: () => !ui.pageInput.hidden, 270 closeJump, 271 }; 272 }