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/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 }