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.
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/pageFlip.d.ts +137 -0
- package/dist/pageFlip.d.ts.map +1 -0
- package/dist/pageFlip.js +881 -0
- package/dist/pageFlip.js.map +1 -0
- package/dist/postext.d.ts +40 -0
- package/dist/postext.d.ts.map +1 -0
- package/dist/postext.js +194 -0
- package/dist/postext.js.map +1 -0
- package/dist/spreads.d.ts +15 -0
- package/dist/spreads.d.ts.map +1 -0
- package/dist/spreads.js +22 -0
- package/dist/spreads.js.map +1 -0
- package/dist/styles.d.ts +10 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +205 -0
- package/dist/styles.js.map +1 -0
- package/dist/viewer.d.ts +103 -0
- package/dist/viewer.d.ts.map +1 -0
- package/dist/viewer.js +565 -0
- package/dist/viewer.js.map +1 -0
- package/package.json +54 -3
package/dist/viewer.d.ts
ADDED
|
@@ -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
|