postext-folio 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ import { type PageSource } from "./pageFlip";
2
+ /** A page of the book: its source, or its source with a text alternative. */
3
+ export type FolioPage = PageSource | {
4
+ src: PageSource;
5
+ alt?: string;
6
+ };
7
+ export interface FolioLabels {
8
+ /** The viewer's accessible name. */
9
+ region: string;
10
+ prev: string;
11
+ next: string;
12
+ /** The pages on show, for the live count under the book: page indexes
13
+ * (0-based) and the page count. */
14
+ count: (pages: number[], total: number) => string;
15
+ }
16
+ export interface FolioOptions {
17
+ pages: FolioPage[];
18
+ /** Whether the first page is a recto (it opens alone, on the right of a
19
+ * left-bound book). Default true. */
20
+ firstPageRecto?: boolean;
21
+ /** The edge the book is bound on. A right-bound book lies mirrored: page
22
+ * 1 alone on the left, pairs [3 | 2], and its pages turn leftward. */
23
+ binding?: "left" | "right";
24
+ /** The page to open on (0-based). Default 0. */
25
+ at?: number;
26
+ /** `double` shows spreads and turns leaves in 3D; `single` one page at a
27
+ * time; `auto` (default) is single in a box narrower than 560 px. */
28
+ mode?: "auto" | "single" | "double";
29
+ /** A page's width / height, when the pages cannot tell it yet (blank
30
+ * until painted). Default: the first sized page's, else 1 : √2. */
31
+ aspect?: number;
32
+ /** The colour of blank pages (any CSS colour). Default white. */
33
+ paper?: string;
34
+ /** Turns leaves in 3D when WebGL2 is there and the reader has not asked
35
+ * for reduced motion. Default true. */
36
+ animate?: boolean;
37
+ /** Draws the ‹ › buttons and the page count. Default true. */
38
+ controls?: boolean;
39
+ /** Shows the page count under the book. Default true. When false it is
40
+ * still announced to screen readers (a visually hidden live region). */
41
+ showCount?: boolean;
42
+ labels?: Partial<FolioLabels>;
43
+ /** Told of every spread that settles: its index and the pages on show. */
44
+ onChange?: (state: FolioState) => void;
45
+ /** Told as soon as the book is sent to another spread (a button, a key,
46
+ * a page let go past halfway), before its leaves have landed. */
47
+ onTarget?: (state: FolioState) => void;
48
+ /** Told when the page slots change size: a host painting pages for the
49
+ * viewer paints them at `deviceWidth` × `deviceHeight` pixels, so they
50
+ * are shown 1:1 both at rest and on a turning leaf. */
51
+ onLayout?: (size: FolioPageSize) => void;
52
+ }
53
+ /** One page slot of the spread: its CSS size, and the device pixels it
54
+ * covers (the size a page bitmap is shown 1:1 at). */
55
+ export interface FolioPageSize {
56
+ width: number;
57
+ height: number;
58
+ deviceWidth: number;
59
+ deviceHeight: number;
60
+ }
61
+ export interface FolioState {
62
+ spread: number;
63
+ /** The page indexes on show (one or two). */
64
+ pages: number[];
65
+ }
66
+ export interface FolioViewer {
67
+ /** The viewer's root element. */
68
+ readonly element: HTMLElement;
69
+ /** Turns to the spread that shows page `index`; `instant` opens it
70
+ * there without turning the leaves (a link, a restored position). */
71
+ goToPage(index: number, options?: {
72
+ instant?: boolean;
73
+ }): void;
74
+ /** Turns to spread `index` (`instant`: opens it there). */
75
+ goToSpread(index: number, options?: {
76
+ instant?: boolean;
77
+ }): void;
78
+ next(): void;
79
+ prev(): void;
80
+ /** Replaces the pages (a relayout, or more pages painted), staying on
81
+ * the page on show unless `at` says otherwise. */
82
+ setPages(pages: FolioPage[], options?: Pick<FolioOptions, "firstPageRecto" | "binding" | "at" | "paper" | "mode">): void;
83
+ /** The page slot's size, as last laid out. */
84
+ readonly pageSize: FolioPageSize;
85
+ /** Swaps the labels (a host that changes language). */
86
+ setLabels(labels: Partial<FolioLabels>): void;
87
+ readonly state: FolioState;
88
+ dispose(): void;
89
+ }
90
+ /** Below this width (px) `mode: 'auto'` shows one page at a time. */
91
+ export declare const SINGLE_BELOW = 560;
92
+ /**
93
+ * A book on the page: the pages in spreads by the recto rule, turned with
94
+ * the ‹ › buttons, ←/→, a swipe, a click on a page, or taken by hand and
95
+ * dragged over, the leaves curling in three.js with their shadows. The
96
+ * WebGL canvas draws the book still and turning alike, so a page never
97
+ * changes look when it lands; the DOM pages under it (images or canvases)
98
+ * are its textures' sources and the pages' text alternatives. Without
99
+ * WebGL2, or with reduced motion, the DOM pages show and the spreads
100
+ * simply change.
101
+ */
102
+ export declare function createFolio(container: HTMLElement, options: FolioOptions): FolioViewer;
103
+ //# sourceMappingURL=viewer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewer.d.ts","sourceRoot":"","sources":["../src/viewer.ts"],"names":[],"mappings":"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"}
package/dist/viewer.js ADDED
@@ -0,0 +1,565 @@
1
+ import { canFlip, PageFlipper } from "./pageFlip";
2
+ import { spreadOfPage, spreadsOf } from "./spreads";
3
+ import { injectStyles } from "./styles";
4
+ const DEFAULT_LABELS = {
5
+ region: "Book",
6
+ prev: "Previous pages",
7
+ next: "Next pages",
8
+ count: (pages, total) => pages.length > 1 ? `Pages ${pages[0] + 1}–${pages[pages.length - 1] + 1} of ${total}` : `Page ${(pages[0] ?? 0) + 1} of ${total}`,
9
+ };
10
+ /** A press on a page that moves less than this (px) is a click: it turns
11
+ * the page (the recto forward, the verso back). */
12
+ const CLICK_SLOP = 6;
13
+ /** A horizontal swipe longer than this (px) turns the spread. */
14
+ const SWIPE = 40;
15
+ /** Below this width (px) `mode: 'auto'` shows one page at a time. */
16
+ export const SINGLE_BELOW = 560;
17
+ /** Space kept round the book (CSS px): beside it for the ‹ › buttons, and
18
+ * above and below it for the page count (and a lifted leaf's corner). */
19
+ const GAP = 12;
20
+ const MIN_MARGIN_Y = 20;
21
+ const CHEVRON = (dir) => `<svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${dir === "left" ? "m15 18-6-6 6-6" : "m9 18 6-6-6-6"}"/></svg>`;
22
+ const sourceOf = (page) => (page && typeof page === "object" && "src" in page ? page.src : (page ?? null));
23
+ const altOf = (page) => (page && typeof page === "object" && "src" in page ? page.alt : undefined);
24
+ /** The natural width / height of a page source, when it is known. */
25
+ function aspectOf(src) {
26
+ if (!src || typeof src === "string")
27
+ return null;
28
+ const w = src instanceof HTMLCanvasElement ? src.width : src.naturalWidth;
29
+ const h = src instanceof HTMLCanvasElement ? src.height : src.naturalHeight;
30
+ return w > 0 && h > 0 ? w / h : null;
31
+ }
32
+ /** A CSS colour as 0 … 1 channels. */
33
+ function rgbOf(color) {
34
+ const ctx = document.createElement("canvas").getContext("2d");
35
+ if (!ctx)
36
+ return null;
37
+ // Over white: a transparent page is white paper.
38
+ ctx.fillStyle = "#fff";
39
+ ctx.fillRect(0, 0, 1, 1);
40
+ ctx.fillStyle = color;
41
+ ctx.fillRect(0, 0, 1, 1);
42
+ const d = ctx.getImageData(0, 0, 1, 1).data;
43
+ return [d[0] / 255, d[1] / 255, d[2] / 255];
44
+ }
45
+ /**
46
+ * A book on the page: the pages in spreads by the recto rule, turned with
47
+ * the ‹ › buttons, ←/→, a swipe, a click on a page, or taken by hand and
48
+ * dragged over, the leaves curling in three.js with their shadows. The
49
+ * WebGL canvas draws the book still and turning alike, so a page never
50
+ * changes look when it lands; the DOM pages under it (images or canvases)
51
+ * are its textures' sources and the pages' text alternatives. Without
52
+ * WebGL2, or with reduced motion, the DOM pages show and the spreads
53
+ * simply change.
54
+ */
55
+ export function createFolio(container, options) {
56
+ injectStyles(container);
57
+ let labels = { ...DEFAULT_LABELS, ...options.labels };
58
+ let pages = options.pages;
59
+ let firstPageRecto = options.firstPageRecto ?? true;
60
+ let binding = options.binding ?? "left";
61
+ let mode = options.mode ?? "auto";
62
+ let paper = options.paper ?? "#fff";
63
+ const animate = options.animate ?? true;
64
+ const controls = options.controls ?? true;
65
+ const showCount = controls && (options.showCount ?? true);
66
+ const root = document.createElement("div");
67
+ root.className = "postext-folio";
68
+ root.tabIndex = 0;
69
+ root.setAttribute("role", "group");
70
+ root.setAttribute("aria-roledescription", "carousel");
71
+ const prevBtn = document.createElement("button");
72
+ const nextBtn = document.createElement("button");
73
+ for (const btn of [prevBtn, nextBtn]) {
74
+ btn.type = "button";
75
+ btn.className = "postext-folio-nav";
76
+ }
77
+ const spreadEl = document.createElement("div");
78
+ spreadEl.className = "postext-folio-spread";
79
+ const count = document.createElement("p");
80
+ count.className = "postext-folio-count";
81
+ count.setAttribute("aria-live", "polite");
82
+ prevBtn.classList.add("is-prev");
83
+ nextBtn.classList.add("is-next");
84
+ root.append(spreadEl, prevBtn, nextBtn);
85
+ if (controls)
86
+ root.append(count);
87
+ if (!showCount)
88
+ count.classList.add("is-unseen");
89
+ else
90
+ prevBtn.hidden = nextBtn.hidden = true;
91
+ container.append(root);
92
+ let single = false;
93
+ let spreads = [];
94
+ let current = 0;
95
+ let shown = 0;
96
+ let flipper = null;
97
+ let flipCanvas = null;
98
+ let holding = null;
99
+ let press = null;
100
+ let swipe = null;
101
+ let disposed = false;
102
+ let slot = { width: 0, height: 0, deviceWidth: 0, deviceHeight: 0 };
103
+ const rtl = () => binding === "right";
104
+ const pagesOf = (s) => (s ?? [null, null]).filter((i) => i !== null);
105
+ /** One page at a time on a WebGL book: the spine runs along the page's
106
+ * inner edge, the leaf turns over it and out of the box. */
107
+ const singleGl = () => single && !!flipper;
108
+ // In single mode every sheet carries one page: its back is blank paper
109
+ // (the page after it lies under it, not on its back).
110
+ const bookOf = () => spreads.map((s, k) => (single ? [k === 0 ? null : "", sourceOf(pages[s[1]])] : s.map((i) => (i === null ? null : sourceOf(pages[i])))));
111
+ function aspect() {
112
+ if (options.aspect)
113
+ return options.aspect;
114
+ for (const page of pages) {
115
+ const ar = aspectOf(sourceOf(page));
116
+ if (ar)
117
+ return ar;
118
+ }
119
+ return 1 / Math.SQRT2;
120
+ }
121
+ /**
122
+ * Fits the book into the box, as large as it goes: the ‹ › buttons and
123
+ * the count sit in the margins round it. The spread lies on the device
124
+ * pixel grid, its page slots a whole number of device pixels wide, so a
125
+ * page painted at that size is shown 1:1 in the DOM and on the canvas
126
+ * alike. The WebGL canvas covers the box, centred on the spread (the
127
+ * camera maps the spread onto it 1:1), with room for a lifted leaf.
128
+ */
129
+ function fit() {
130
+ const dpr = window.devicePixelRatio || 1;
131
+ const box = root.getBoundingClientRect();
132
+ const boxW = Math.floor(box.width * dpr);
133
+ const boxH = Math.floor(box.height * dpr);
134
+ const ar = aspect();
135
+ const gap = Math.round(GAP * dpr);
136
+ // The widest page up to `max` device px whose width and height are
137
+ // even: the spread's centre and the spine (the camera's centre in one
138
+ // or the other mode) then lie on the pixel grid.
139
+ const fitWidth = (max) => {
140
+ let w = Math.max(2, Math.floor(max));
141
+ while (w > 2 && (w % 2 || Math.round(w / ar) % 2))
142
+ w--;
143
+ return w;
144
+ };
145
+ let deviceWidth;
146
+ let left;
147
+ let top;
148
+ let across;
149
+ if (single) {
150
+ // One page across the box, the buttons and the count in a bar below.
151
+ const bar = controls ? Math.round((prevBtn.offsetWidth + 2 * GAP) * dpr) : gap;
152
+ const availW = Math.max(0, boxW - 2 * gap);
153
+ const availH = Math.max(0, boxH - gap - bar);
154
+ deviceWidth = fitWidth(Math.min(availW, availH > 0 ? availH * ar : Infinity));
155
+ const deviceHeight = Math.round(deviceWidth / ar);
156
+ const pageLeft = gap + Math.floor((availW - deviceWidth) / 2);
157
+ top = gap + Math.max(0, Math.floor((availH - deviceHeight) / 2));
158
+ if (flipper) {
159
+ // The WebGL book is a spread whose other page lies off the box: the
160
+ // spine on the page's inner edge (its left, or its right when the
161
+ // book is bound on the right).
162
+ across = 2;
163
+ left = rtl() ? pageLeft : pageLeft - deviceWidth;
164
+ }
165
+ else {
166
+ across = 1;
167
+ left = pageLeft;
168
+ }
169
+ }
170
+ else {
171
+ across = 2;
172
+ const marginX = Math.round((controls ? prevBtn.offsetWidth + 2 * GAP : GAP) * dpr);
173
+ const marginY = Math.round(Math.max(MIN_MARGIN_Y, showCount ? count.offsetHeight + 2 * GAP : GAP) * dpr);
174
+ const availW = Math.max(0, boxW - 2 * marginX);
175
+ const availH = Math.max(0, boxH - 2 * marginY);
176
+ deviceWidth = fitWidth(Math.min(availW / 2, availH > 0 ? availH * ar : Infinity));
177
+ left = Math.floor((boxW - 2 * deviceWidth) / 2);
178
+ top = Math.max(0, Math.floor((boxH - Math.round(deviceWidth / ar)) / 2));
179
+ }
180
+ const deviceHeight = Math.max(1, Math.round(deviceWidth / ar));
181
+ Object.assign(spreadEl.style, {
182
+ left: `${left / dpr}px`,
183
+ top: `${top / dpr}px`,
184
+ width: `${(across * deviceWidth) / dpr}px`,
185
+ height: `${deviceHeight / dpr}px`,
186
+ });
187
+ if (flipCanvas) {
188
+ // Centred on the spine (the camera maps the spread onto the canvas
189
+ // 1:1 about its centre) and reaching every edge of the box; what
190
+ // falls outside the box is clipped.
191
+ const cx = left + deviceWidth;
192
+ const cy = top + deviceHeight / 2;
193
+ const hw = Math.max(cx, boxW - cx);
194
+ const hh = Math.max(cy, boxH - cy);
195
+ Object.assign(flipCanvas.style, {
196
+ left: `${(cx - hw) / dpr}px`,
197
+ top: `${(cy - hh) / dpr}px`,
198
+ width: `${(2 * hw) / dpr}px`,
199
+ height: `${(2 * hh) / dpr}px`,
200
+ });
201
+ }
202
+ const next = { width: deviceWidth / dpr, height: deviceHeight / dpr, deviceWidth, deviceHeight };
203
+ if (next.deviceWidth !== slot.deviceWidth || next.deviceHeight !== slot.deviceHeight) {
204
+ slot = next;
205
+ options.onLayout?.(slot);
206
+ }
207
+ flipper?.redraw();
208
+ }
209
+ function pageEl(i, slot) {
210
+ const el = document.createElement("div");
211
+ el.className = "postext-folio-page";
212
+ if (!single || singleGl())
213
+ el.classList.add(slot === 0 ? "is-verso" : "is-recto");
214
+ const src = i === null ? null : sourceOf(pages[i]);
215
+ if (src === null) {
216
+ el.classList.add("is-empty");
217
+ el.setAttribute("aria-hidden", "true");
218
+ }
219
+ else if (src === "") {
220
+ el.classList.add("is-blank");
221
+ el.setAttribute("aria-hidden", "true");
222
+ }
223
+ else {
224
+ const alt = altOf(pages[i]) ?? "";
225
+ if (typeof src === "string") {
226
+ const img = document.createElement("img");
227
+ img.src = src;
228
+ img.alt = alt;
229
+ img.draggable = false;
230
+ img.decoding = "async";
231
+ el.append(img);
232
+ }
233
+ else {
234
+ // The page's own element (a canvas, or a decoded image), shown as is.
235
+ src.classList.add("postext-folio-surface");
236
+ if (src instanceof HTMLCanvasElement) {
237
+ src.setAttribute("role", "img");
238
+ if (alt)
239
+ src.setAttribute("aria-label", alt);
240
+ }
241
+ else {
242
+ src.alt = alt;
243
+ src.draggable = false;
244
+ }
245
+ el.append(src);
246
+ }
247
+ }
248
+ return el;
249
+ }
250
+ function render() {
251
+ const s = spreads[shown] ?? [null, null];
252
+ const slots = single && !singleGl() ? [s[0] ?? s[1]] : s;
253
+ spreadEl.replaceChildren(...slots.map((i, side) => pageEl(i, side)));
254
+ spreadEl.classList.toggle("is-solo", single && !singleGl());
255
+ spreadEl.classList.toggle("is-by-hand", !!flipper);
256
+ // The canvas draws the book, still or turning; the DOM pages stay as
257
+ // the textures' sources and the pages' text alternatives.
258
+ spreadEl.classList.toggle("is-gl", !!flipper);
259
+ const on = pagesOf(spreads[current]);
260
+ count.textContent = on.length ? labels.count(on, pages.length) : "";
261
+ prevBtn.disabled = current <= 0;
262
+ nextBtn.disabled = current >= spreads.length - 1;
263
+ // The settled spread, drawn at rest.
264
+ flipper?.clear();
265
+ }
266
+ function settle(index) {
267
+ shown = index;
268
+ render();
269
+ options.onChange?.({ spread: index, pages: pagesOf(spreads[index]) });
270
+ }
271
+ function go(index) {
272
+ const to = Math.max(0, Math.min(spreads.length - 1, index));
273
+ if (to === current && to === shown)
274
+ return;
275
+ current = to;
276
+ options.onTarget?.({ spread: to, pages: pagesOf(spreads[to]) });
277
+ if (flipper) {
278
+ flipper.go(to);
279
+ // The count and buttons follow the hand at once.
280
+ count.textContent = labels.count(pagesOf(spreads[to]), pages.length);
281
+ prevBtn.disabled = to <= 0;
282
+ nextBtn.disabled = to >= spreads.length - 1;
283
+ }
284
+ else
285
+ settle(to);
286
+ }
287
+ /** Opens spread `index` at once, no leaf turning. */
288
+ function jump(index) {
289
+ const to = Math.max(0, Math.min(spreads.length - 1, index));
290
+ if (to === current && to === shown)
291
+ return;
292
+ current = to;
293
+ options.onTarget?.({ spread: to, pages: pagesOf(spreads[to]) });
294
+ flipper?.setBook(bookOf(), to);
295
+ settle(to);
296
+ }
297
+ function setUpFlipper() {
298
+ flipper?.dispose();
299
+ flipper = null;
300
+ flipCanvas?.remove();
301
+ flipCanvas = null;
302
+ if (!animate || spreads.length < 2 || !canFlip())
303
+ return;
304
+ flipCanvas = document.createElement("canvas");
305
+ flipCanvas.className = "postext-folio-flip";
306
+ flipCanvas.setAttribute("aria-hidden", "true");
307
+ root.append(flipCanvas);
308
+ flipper = new PageFlipper(flipCanvas, spreadEl, bookOf(), shown, settle, (i) => {
309
+ current = i;
310
+ options.onTarget?.({ spread: i, pages: pagesOf(spreads[i]) });
311
+ count.textContent = labels.count(pagesOf(spreads[i]), pages.length);
312
+ }, binding,
313
+ // One page at a time the spine is the box's edge: the leaf cannot be
314
+ // carried past halfway, so a shorter pull turns it.
315
+ { persistent: true, turnAt: single ? 0.2 : 0.5 });
316
+ const rgb = rgbOf(paper);
317
+ if (rgb)
318
+ flipper.setPaper(...rgb);
319
+ }
320
+ /** Lays the book out again: spreads, mode, flipper. */
321
+ function rebuild(atPage) {
322
+ single = mode === "single" || (mode === "auto" && root.clientWidth > 0 && root.clientWidth < SINGLE_BELOW);
323
+ spreads = single ? pages.map((_, i) => [null, i]) : spreadsOf(pages.length, firstPageRecto);
324
+ current = shown = spreadOfPage(spreads, atPage);
325
+ root.dir = rtl() ? "rtl" : "ltr";
326
+ root.style.setProperty("--postext-folio-paper", paper);
327
+ root.classList.toggle("is-single", single);
328
+ prevBtn.innerHTML = CHEVRON(rtl() ? "right" : "left");
329
+ nextBtn.innerHTML = CHEVRON(rtl() ? "left" : "right");
330
+ setUpFlipper();
331
+ fit();
332
+ render();
333
+ }
334
+ function applyLabels() {
335
+ root.setAttribute("aria-label", labels.region);
336
+ prevBtn.setAttribute("aria-label", labels.prev);
337
+ nextBtn.setAttribute("aria-label", labels.next);
338
+ }
339
+ // ── Input ──
340
+ prevBtn.addEventListener("click", () => go(current - 1));
341
+ nextBtn.addEventListener("click", () => go(current + 1));
342
+ root.addEventListener("keydown", (event) => {
343
+ if (event.target !== root)
344
+ return;
345
+ const forward = rtl() ? "ArrowLeft" : "ArrowRight";
346
+ const back = rtl() ? "ArrowRight" : "ArrowLeft";
347
+ if (event.key === back || event.key === "PageUp")
348
+ go(current - 1);
349
+ else if (event.key === forward || event.key === "PageDown")
350
+ go(current + 1);
351
+ else if (event.key === "Home")
352
+ go(0);
353
+ else if (event.key === "End")
354
+ go(spreads.length - 1);
355
+ else
356
+ return;
357
+ event.preventDefault();
358
+ });
359
+ root.addEventListener("pointerdown", (event) => {
360
+ if (event.pointerType !== "mouse")
361
+ swipe = { x: event.clientX, y: event.clientY };
362
+ });
363
+ root.addEventListener("pointerup", (event) => {
364
+ const start = swipe;
365
+ swipe = null;
366
+ if (!start)
367
+ return;
368
+ const dx = event.clientX - start.x;
369
+ if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(event.clientY - start.y))
370
+ go(current + ((dx < 0) !== rtl() ? 1 : -1));
371
+ });
372
+ spreadEl.addEventListener("pointerdown", (event) => {
373
+ if (event.button !== 0 || holding)
374
+ return;
375
+ press = { x: event.clientX, y: event.clientY };
376
+ if (singleGl())
377
+ return void decide(event);
378
+ if (!flipper?.grab(event))
379
+ return;
380
+ event.preventDefault();
381
+ event.stopPropagation();
382
+ hold(event, press);
383
+ });
384
+ /**
385
+ * One page at a time, the page is taken only once the pointer shows
386
+ * which way it goes: towards the spine it is lifted and dragged over
387
+ * (from where it was pressed), away from it the book goes back a page (a
388
+ * swipe); a press that does not move is a tap and turns forward.
389
+ */
390
+ function decide(down) {
391
+ down.stopPropagation();
392
+ swipe = null;
393
+ const id = down.pointerId;
394
+ const start = { x: down.clientX, y: down.clientY };
395
+ const toSpine = rtl() ? 1 : -1;
396
+ const done = () => {
397
+ window.removeEventListener("pointermove", onMove);
398
+ window.removeEventListener("pointerup", onUp, true);
399
+ window.removeEventListener("pointercancel", onCancel);
400
+ };
401
+ const onMove = (e) => {
402
+ if (e.pointerId !== id)
403
+ return;
404
+ const dx = e.clientX - start.x;
405
+ const dy = e.clientY - start.y;
406
+ if (Math.hypot(dx, dy) < CLICK_SLOP || Math.abs(dy) > Math.abs(dx))
407
+ return;
408
+ if (Math.sign(dx) !== toSpine)
409
+ return; // a swipe back: settled on release
410
+ done();
411
+ if (!flipper?.grab(down))
412
+ return;
413
+ hold(down, start);
414
+ flipper.drag(e);
415
+ };
416
+ const onUp = (e) => {
417
+ if (e.pointerId !== id)
418
+ return;
419
+ done();
420
+ press = null;
421
+ const dx = e.clientX - start.x;
422
+ const dy = e.clientY - start.y;
423
+ if (Math.hypot(dx, dy) < CLICK_SLOP)
424
+ go(current + 1);
425
+ else if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(dy) && Math.sign(dx) === -toSpine)
426
+ go(current - 1);
427
+ e.stopPropagation();
428
+ };
429
+ const onCancel = (e) => {
430
+ if (e.pointerId === id)
431
+ done();
432
+ };
433
+ window.addEventListener("pointermove", onMove);
434
+ window.addEventListener("pointerup", onUp, true);
435
+ window.addEventListener("pointercancel", onCancel);
436
+ }
437
+ /** Holds the page taken at `event` until the pointer lets go. */
438
+ function hold(event, start) {
439
+ // The hold follows the pointer anywhere: the window hears it even if
440
+ // the capture is lost, and a move with the button up means the release
441
+ // was missed.
442
+ const id = event.pointerId;
443
+ const onMove = (e) => {
444
+ if (e.pointerId !== id)
445
+ return;
446
+ if ((e.buttons & 1) === 0)
447
+ end(false);
448
+ else
449
+ flipper?.drag(e);
450
+ };
451
+ const onUp = (e) => {
452
+ if (e.pointerId === id)
453
+ end(Math.hypot(e.clientX - start.x, e.clientY - start.y) < CLICK_SLOP);
454
+ };
455
+ const onCancel = (e) => {
456
+ if (e.pointerId === id)
457
+ end(false);
458
+ };
459
+ const onBlur = () => end(false);
460
+ const end = (click) => {
461
+ window.removeEventListener("pointermove", onMove);
462
+ window.removeEventListener("pointerup", onUp, true);
463
+ window.removeEventListener("pointercancel", onCancel);
464
+ window.removeEventListener("blur", onBlur);
465
+ spreadEl.classList.remove("is-held");
466
+ holding = null;
467
+ press = null;
468
+ flipper?.release(click);
469
+ };
470
+ window.addEventListener("pointermove", onMove);
471
+ // Captured, so the stage's swipe does not hear the hold's release.
472
+ window.addEventListener("pointerup", onUp, true);
473
+ window.addEventListener("pointercancel", onCancel);
474
+ window.addEventListener("blur", onBlur);
475
+ holding = end;
476
+ swipe = null;
477
+ spreadEl.classList.add("is-held");
478
+ try {
479
+ spreadEl.setPointerCapture(id);
480
+ }
481
+ catch {
482
+ // The window listeners carry the hold without it.
483
+ }
484
+ }
485
+ spreadEl.addEventListener("pointerup", (event) => {
486
+ if (holding || singleGl())
487
+ return;
488
+ const start = press;
489
+ press = null;
490
+ const click = !!start && Math.hypot(event.clientX - start.x, event.clientY - start.y) < CLICK_SLOP;
491
+ if (!click || !event.target.closest(".postext-folio-page:not(.is-empty)"))
492
+ return;
493
+ // A click on the recto turns forward, on the verso back (pages already
494
+ // in the air: it joins them).
495
+ const rect = spreadEl.getBoundingClientRect();
496
+ swipe = null;
497
+ if (single)
498
+ go(current + 1);
499
+ else
500
+ go(current + ((event.clientX > rect.left + rect.width / 2) !== rtl() ? 1 : -1));
501
+ });
502
+ let lastWidth = -1;
503
+ const observer = new ResizeObserver(() => {
504
+ if (disposed)
505
+ return;
506
+ const w = root.clientWidth;
507
+ const wasSingle = single;
508
+ const nowSingle = mode === "single" || (mode === "auto" && w > 0 && w < SINGLE_BELOW);
509
+ if (lastWidth >= 0 && nowSingle !== wasSingle)
510
+ rebuild(pagesOf(spreads[shown])[0] ?? 0);
511
+ else
512
+ fit();
513
+ lastWidth = w;
514
+ });
515
+ observer.observe(root);
516
+ applyLabels();
517
+ rebuild(options.at ?? 0);
518
+ return {
519
+ element: root,
520
+ goToPage: (index, opts) => (opts?.instant ? jump : go)(spreadOfPage(spreads, index)),
521
+ goToSpread: (index, opts) => (opts?.instant ? jump : go)(index),
522
+ next: () => go(current + 1),
523
+ prev: () => go(current - 1),
524
+ setLabels(next) {
525
+ labels = { ...labels, ...next };
526
+ applyLabels();
527
+ render();
528
+ },
529
+ get pageSize() {
530
+ return slot;
531
+ },
532
+ get state() {
533
+ return { spread: current, pages: pagesOf(spreads[current]) };
534
+ },
535
+ setPages(next, opts = {}) {
536
+ const atPage = opts.at ?? pagesOf(spreads[shown])[0] ?? 0;
537
+ const relayout = next.length !== pages.length ||
538
+ (opts.firstPageRecto ?? firstPageRecto) !== firstPageRecto ||
539
+ (opts.binding ?? binding) !== binding ||
540
+ (opts.mode ?? mode) !== mode ||
541
+ (opts.paper ?? paper) !== paper;
542
+ pages = next;
543
+ firstPageRecto = opts.firstPageRecto ?? firstPageRecto;
544
+ binding = opts.binding ?? binding;
545
+ mode = opts.mode ?? mode;
546
+ paper = opts.paper ?? paper;
547
+ if (relayout)
548
+ return rebuild(atPage);
549
+ // Same spreads: the leaves take their new pages where they lie.
550
+ flipper?.setBook(bookOf(), opts.at !== undefined ? spreadOfPage(spreads, opts.at) : undefined);
551
+ if (opts.at !== undefined)
552
+ current = shown = spreadOfPage(spreads, opts.at);
553
+ fit();
554
+ render();
555
+ },
556
+ dispose() {
557
+ disposed = true;
558
+ observer.disconnect();
559
+ holding?.(false);
560
+ flipper?.dispose();
561
+ root.remove();
562
+ },
563
+ };
564
+ }
565
+ //# sourceMappingURL=viewer.js.map