@toneflix/flipbook 0.0.0-stage → 0.2.0
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 +29 -2
- package/dist/flipbook.iife.js +8 -0
- package/dist/index.cjs +1045 -0
- package/dist/index.d.cts +143 -0
- package/dist/index.d.mts +143 -0
- package/dist/index.mjs +1026 -0
- package/dist/style.css +869 -0
- package/dist/textures/grain-charcoal.webp +0 -0
- package/dist/textures/grain-fine.webp +0 -0
- package/dist/textures/grain-kraft.webp +0 -0
- package/dist/textures/paper-charcoal.webp +0 -0
- package/dist/textures/paper-fine.webp +0 -0
- package/dist/textures/paper-kraft.webp +0 -0
- package/package.json +52 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
import { BANDED_FRAMES, BookLayout, COVER_THEMES, DEFAULT_LABELS, FlipbookReader, Fold, PLACED_LAYOUTS, photoFrame } from "@toneflix/flipbook-core";
|
|
2
|
+
export * from "@toneflix/flipbook-core";
|
|
3
|
+
// src/dom.ts
|
|
4
|
+
/** Creates an element. Strings become text, so book content is never parsed as HTML. */
|
|
5
|
+
function h(tag, attrs = {}, ...children) {
|
|
6
|
+
const el = document.createElement(tag);
|
|
7
|
+
for (const [name, value] of Object.entries(attrs)) if (name === "style") Object.assign(el.style, value);
|
|
8
|
+
else if (value !== null && value !== void 0 && value !== false) el.setAttribute(name, value === true ? "" : String(value));
|
|
9
|
+
append(el, children);
|
|
10
|
+
return el;
|
|
11
|
+
}
|
|
12
|
+
function append(el, children) {
|
|
13
|
+
for (const child of children) if (child) el.append(child);
|
|
14
|
+
}
|
|
15
|
+
/** Builds trusted, static SVG markup (icons and ornaments shipped with the package). */
|
|
16
|
+
function svg(markup) {
|
|
17
|
+
const template = document.createElement("template");
|
|
18
|
+
template.innerHTML = markup.trim();
|
|
19
|
+
return template.content.firstElementChild;
|
|
20
|
+
}
|
|
21
|
+
// src/landscape.ts
|
|
22
|
+
/**
|
|
23
|
+
* Goes full screen and locks the screen to landscape where the browser allows it (Chrome on
|
|
24
|
+
* Android). iOS Safari supports neither for pages, so callers keep the rotate prompt there.
|
|
25
|
+
*/
|
|
26
|
+
async function enterLandscape(el) {
|
|
27
|
+
try {
|
|
28
|
+
if (!document.fullscreenElement && el.requestFullscreen) await el.requestFullscreen({ navigationUI: "hide" });
|
|
29
|
+
} catch {}
|
|
30
|
+
try {
|
|
31
|
+
const orientation = screen.orientation;
|
|
32
|
+
if (orientation?.lock) {
|
|
33
|
+
await orientation.lock("landscape");
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
/** Media queries that decide whether a phone is held upright. */
|
|
40
|
+
function watchOrientation(onChange) {
|
|
41
|
+
const portrait = matchMedia("(orientation: portrait)");
|
|
42
|
+
const touch = matchMedia("(pointer: coarse)");
|
|
43
|
+
const update = () => onChange({
|
|
44
|
+
portrait: portrait.matches,
|
|
45
|
+
touch: touch.matches
|
|
46
|
+
});
|
|
47
|
+
update();
|
|
48
|
+
portrait.addEventListener("change", update);
|
|
49
|
+
touch.addEventListener("change", update);
|
|
50
|
+
window.addEventListener("resize", update);
|
|
51
|
+
return () => {
|
|
52
|
+
portrait.removeEventListener("change", update);
|
|
53
|
+
touch.removeEventListener("change", update);
|
|
54
|
+
window.removeEventListener("resize", update);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// src/pages.ts
|
|
58
|
+
const LEAVES = [
|
|
59
|
+
14,
|
|
60
|
+
24,
|
|
61
|
+
34,
|
|
62
|
+
44,
|
|
63
|
+
54,
|
|
64
|
+
64,
|
|
65
|
+
74
|
|
66
|
+
].map((x, i) => ({
|
|
67
|
+
x,
|
|
68
|
+
angle: i % 2 ? 38 : -38,
|
|
69
|
+
length: 26 - i * 2
|
|
70
|
+
}));
|
|
71
|
+
const sprig = (corner) => svg(`<svg viewBox="0 0 100 40" class="fb-print-frame-sprig is-${corner}" fill="none" stroke="currentColor" stroke-linecap="round" aria-hidden="true">
|
|
72
|
+
<path d="M4 20 Q 50 17, 92 20" stroke-width="1.2"/>
|
|
73
|
+
${LEAVES.map(({ x, angle, length }) => `<g transform="translate(${x} 20) rotate(${angle}) scale(${length / 20})"><path d="M0 0 C 5 -5, 14 -5, 20 0 C 14 5, 5 5, 0 0 Z" stroke-width="1"/><path d="M2 0 L 16 0" stroke-width="0.6"/></g>`).join("")}
|
|
74
|
+
</svg>`);
|
|
75
|
+
const ornament = () => svg(`<svg viewBox="0 0 40 40" class="fb-ornament" aria-hidden="true">
|
|
76
|
+
<circle cx="20" cy="20" r="18.5" fill="none" stroke="currentColor" stroke-opacity="0.6"/>
|
|
77
|
+
<path d="M20 9 L 27 20 L 20 31 L 13 20 Z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
|
|
78
|
+
<circle cx="20" cy="20" r="2.2" fill="currentColor"/>
|
|
79
|
+
</svg>`);
|
|
80
|
+
const image = (photo, className) => h("img", {
|
|
81
|
+
src: photo.src,
|
|
82
|
+
alt: photo.alt ?? "",
|
|
83
|
+
draggable: "false",
|
|
84
|
+
decoding: "async",
|
|
85
|
+
width: photo.width ?? void 0,
|
|
86
|
+
height: photo.height ?? void 0,
|
|
87
|
+
class: className
|
|
88
|
+
});
|
|
89
|
+
const boxStyle = ({ x, y, width, height, rotate }) => ({
|
|
90
|
+
left: `${x}cqw`,
|
|
91
|
+
top: `${y}cqw`,
|
|
92
|
+
width: `${width}cqw`,
|
|
93
|
+
height: `${height}cqw`,
|
|
94
|
+
...rotate ? { transform: `rotate(${rotate}deg)` } : {}
|
|
95
|
+
});
|
|
96
|
+
/** Light and grain over a whole page, with the curve into the spine on a spread. */
|
|
97
|
+
const surface = (side) => h("div", {
|
|
98
|
+
class: `fb-page-surface ${side ? `is-${side}` : ""}`,
|
|
99
|
+
"aria-hidden": "true"
|
|
100
|
+
}, h("div", { class: "fb-page-surface-grain" }), side && h("div", { class: "fb-page-surface-gutter" }), side && h("div", { class: "fb-page-surface-sheen" }));
|
|
101
|
+
const serif = (text, style, italic = true, center = true) => h("p", {
|
|
102
|
+
class: `fb-serif ${italic ? "fb-italic" : ""} ${center ? "fb-center" : ""}`,
|
|
103
|
+
style
|
|
104
|
+
}, text);
|
|
105
|
+
/** A photo printed on a page inside its frame. */
|
|
106
|
+
function printedPhoto(photo, frame, fit = "cover", show = true, scale = 1) {
|
|
107
|
+
const ratio = photo?.width && photo.height ? photo.width / photo.height : 4 / 3;
|
|
108
|
+
const box = h("div", {
|
|
109
|
+
class: "fb-print-frame-box",
|
|
110
|
+
style: { fontSize: `${scale}cqw` }
|
|
111
|
+
});
|
|
112
|
+
const print = h("div", { class: `fb-print-frame fb-frame-${frame} ${fit === "contain" ? "is-contained" : ""}` });
|
|
113
|
+
if (fit === "contain") print.style.setProperty("--ratio", String(ratio));
|
|
114
|
+
if (BANDED_FRAMES[frame]) print.append(h("span", {
|
|
115
|
+
class: "fb-print-frame-band",
|
|
116
|
+
"aria-hidden": "true"
|
|
117
|
+
}));
|
|
118
|
+
print.append(photo && show ? image(photo, "fb-print-photo fb-print-frame-photo") : h("div", { class: "fb-print-frame-photo fb-placeholder" }));
|
|
119
|
+
if (frame === "corners") for (const c of [
|
|
120
|
+
"tl",
|
|
121
|
+
"tr",
|
|
122
|
+
"bl",
|
|
123
|
+
"br"
|
|
124
|
+
]) print.append(h("span", {
|
|
125
|
+
class: `fb-print-frame-corner is-${c}`,
|
|
126
|
+
"aria-hidden": "true"
|
|
127
|
+
}));
|
|
128
|
+
if (frame === "botanical") print.append(sprig("tl"), sprig("br"));
|
|
129
|
+
box.append(print);
|
|
130
|
+
return box;
|
|
131
|
+
}
|
|
132
|
+
/** The inside of one book page. The parent must have `container-type: size`. */
|
|
133
|
+
function renderPage(page, look, options = {}) {
|
|
134
|
+
const { layout, caption, photos } = page;
|
|
135
|
+
const { number, side, showImages = true, onSlotClick, activeSlot } = options;
|
|
136
|
+
const placed = PLACED_LAYOUTS[layout];
|
|
137
|
+
const root = h("div", { class: `fb-paper fb-paper-${look.paper} fb-page` });
|
|
138
|
+
const slot = (index, className = "", framed = false, style) => {
|
|
139
|
+
const photo = photos[index] ?? null;
|
|
140
|
+
const frame = photoFrame(page, photo, look.frame);
|
|
141
|
+
const el = h("div", {
|
|
142
|
+
class: [
|
|
143
|
+
"fb-slot",
|
|
144
|
+
className,
|
|
145
|
+
framed && "is-framed",
|
|
146
|
+
onSlotClick && "is-editable",
|
|
147
|
+
onSlotClick && activeSlot === index && "is-active"
|
|
148
|
+
].filter(Boolean).join(" "),
|
|
149
|
+
role: onSlotClick ? "button" : void 0,
|
|
150
|
+
"aria-label": onSlotClick ? `Photo slot ${index + 1}` : void 0,
|
|
151
|
+
style
|
|
152
|
+
});
|
|
153
|
+
if (onSlotClick) el.addEventListener("click", () => onSlotClick(index));
|
|
154
|
+
el.append(photo ? printedPhoto(photo, frame, framed ? "contain" : "cover", showImages) : h("div", { class: "fb-slot-empty" }, onSlotClick ? "Add a photo" : ""));
|
|
155
|
+
return el;
|
|
156
|
+
};
|
|
157
|
+
const column = (style, ...children) => h("div", {
|
|
158
|
+
class: "fb-column",
|
|
159
|
+
style
|
|
160
|
+
}, ...children);
|
|
161
|
+
const small = caption ? serif(caption, { fontSize: "3.8cqw" }) : null;
|
|
162
|
+
switch (layout) {
|
|
163
|
+
case "single":
|
|
164
|
+
root.append(slot(0, "fb-bleed"));
|
|
165
|
+
if (caption) root.append(h("div", { class: "fb-caption-over" }, caption));
|
|
166
|
+
break;
|
|
167
|
+
case "framed":
|
|
168
|
+
root.append(column({ padding: "10cqw 9cqw 6cqw" }, h("div", { class: "fb-grow" }, slot(0, "fb-fill", true)), serif(caption ?? "", {
|
|
169
|
+
marginTop: "5cqw",
|
|
170
|
+
fontSize: "4.2cqw",
|
|
171
|
+
minHeight: "6cqw",
|
|
172
|
+
lineHeight: "1.35"
|
|
173
|
+
})));
|
|
174
|
+
break;
|
|
175
|
+
case "pair":
|
|
176
|
+
root.append(column({
|
|
177
|
+
padding: "7cqw",
|
|
178
|
+
gap: "4cqw"
|
|
179
|
+
}, slot(0, "fb-grow"), slot(1, "fb-grow"), small));
|
|
180
|
+
break;
|
|
181
|
+
case "grid":
|
|
182
|
+
root.append(column({
|
|
183
|
+
padding: "7cqw",
|
|
184
|
+
gap: "3cqw"
|
|
185
|
+
}, h("div", {
|
|
186
|
+
class: "fb-grid fb-grow",
|
|
187
|
+
style: { gap: "3cqw" }
|
|
188
|
+
}, ...[
|
|
189
|
+
0,
|
|
190
|
+
1,
|
|
191
|
+
2,
|
|
192
|
+
3
|
|
193
|
+
].map((i) => slot(i))), small));
|
|
194
|
+
break;
|
|
195
|
+
case "collage":
|
|
196
|
+
root.append(column({
|
|
197
|
+
padding: "7cqw",
|
|
198
|
+
gap: "3cqw"
|
|
199
|
+
}, slot(0, "fb-grow-large"), h("div", {
|
|
200
|
+
class: "fb-row fb-grow",
|
|
201
|
+
style: { gap: "3cqw" }
|
|
202
|
+
}, slot(1, "fb-grow"), slot(2, "fb-grow")), small));
|
|
203
|
+
break;
|
|
204
|
+
case "text": root.append(h("div", {
|
|
205
|
+
class: "fb-column fb-center",
|
|
206
|
+
style: {
|
|
207
|
+
alignItems: "center",
|
|
208
|
+
justifyContent: "center",
|
|
209
|
+
padding: "14cqw 12cqw"
|
|
210
|
+
}
|
|
211
|
+
}, h("span", {
|
|
212
|
+
class: "fb-serif",
|
|
213
|
+
style: {
|
|
214
|
+
fontSize: "18cqw",
|
|
215
|
+
lineHeight: "0.6",
|
|
216
|
+
opacity: "0.3"
|
|
217
|
+
},
|
|
218
|
+
"aria-hidden": "true"
|
|
219
|
+
}, "“"), serif(caption || (onSlotClick ? "Write something in the caption box below." : ""), {
|
|
220
|
+
marginTop: "4cqw",
|
|
221
|
+
fontSize: "6cqw",
|
|
222
|
+
lineHeight: "1.4",
|
|
223
|
+
textWrap: "balance"
|
|
224
|
+
}, true, false)));
|
|
225
|
+
}
|
|
226
|
+
if (placed) {
|
|
227
|
+
root.append(...placed.slots.map((box, i) => slot(i, `fb-placed ${placed.loose ? "is-loose" : ""}`, false, boxStyle(box))));
|
|
228
|
+
if (caption) root.append(h("p", {
|
|
229
|
+
class: `fb-placed-caption fb-serif ${placed.prose ? "is-prose" : "fb-italic"}`,
|
|
230
|
+
style: boxStyle(placed.caption)
|
|
231
|
+
}, caption));
|
|
232
|
+
}
|
|
233
|
+
if (number !== void 0 && layout !== "single") root.append(h("span", {
|
|
234
|
+
class: "fb-page-number",
|
|
235
|
+
style: side === "left" ? { left: "0" } : { right: "0" }
|
|
236
|
+
}, String(number)));
|
|
237
|
+
root.append(surface(side));
|
|
238
|
+
return root;
|
|
239
|
+
}
|
|
240
|
+
/** The front cover: cloth with the title and an inset photo. */
|
|
241
|
+
function renderCover(book, showImages = true) {
|
|
242
|
+
const panel = h("div", { class: "fb-cover-panel" }, book.kicker && h("p", { class: "fb-kicker" }, book.kicker), h("h1", {
|
|
243
|
+
class: "fb-serif",
|
|
244
|
+
style: {
|
|
245
|
+
marginTop: "5cqw",
|
|
246
|
+
fontSize: "9cqw",
|
|
247
|
+
fontWeight: "500",
|
|
248
|
+
lineHeight: "1.05",
|
|
249
|
+
textWrap: "balance"
|
|
250
|
+
}
|
|
251
|
+
}, book.title));
|
|
252
|
+
if (book.cover) panel.append(h("div", { class: "fb-cover-photo" }, showImages ? image(book.cover, "fb-photo") : h("div", { class: "fb-placeholder" })));
|
|
253
|
+
else if (book.subtitle) panel.append(serif(book.subtitle, {
|
|
254
|
+
marginTop: "6cqw",
|
|
255
|
+
fontSize: "4cqw",
|
|
256
|
+
opacity: "0.8"
|
|
257
|
+
}, true, false));
|
|
258
|
+
return h("div", { class: `fb-cover fb-cover-${book.theme ?? "classic"}` }, h("div", { class: "fb-hinge is-left" }), panel);
|
|
259
|
+
}
|
|
260
|
+
function renderBackCover(book) {
|
|
261
|
+
return h("div", { class: `fb-cover fb-cover-${book.theme ?? "classic"}` }, h("div", { class: "fb-hinge is-right" }), h("div", { class: "fb-imprint" }, ornament(), book.imprint && h("p", {
|
|
262
|
+
class: "fb-kicker",
|
|
263
|
+
style: {
|
|
264
|
+
marginTop: "2cqw",
|
|
265
|
+
fontSize: "2.4cqw",
|
|
266
|
+
opacity: "0.6"
|
|
267
|
+
}
|
|
268
|
+
}, book.imprint)));
|
|
269
|
+
}
|
|
270
|
+
function renderTitlePage(book, look, side) {
|
|
271
|
+
const credit = [book.author, book.year].filter(Boolean).join(" · ");
|
|
272
|
+
return h("div", {
|
|
273
|
+
class: `fb-paper fb-paper-${look.paper} fb-page fb-center`,
|
|
274
|
+
style: {
|
|
275
|
+
alignItems: "center",
|
|
276
|
+
justifyContent: "center",
|
|
277
|
+
padding: "12cqw"
|
|
278
|
+
}
|
|
279
|
+
}, ornament(), h("h2", {
|
|
280
|
+
class: "fb-serif",
|
|
281
|
+
style: {
|
|
282
|
+
marginTop: "6cqw",
|
|
283
|
+
fontSize: "7.5cqw",
|
|
284
|
+
fontWeight: "400",
|
|
285
|
+
lineHeight: "1.1",
|
|
286
|
+
textWrap: "balance"
|
|
287
|
+
}
|
|
288
|
+
}, book.title), book.subtitle && serif(book.subtitle, {
|
|
289
|
+
marginTop: "4cqw",
|
|
290
|
+
fontSize: "4cqw",
|
|
291
|
+
lineHeight: "1.45"
|
|
292
|
+
}, true, false), h("div", { style: {
|
|
293
|
+
marginTop: "7cqw",
|
|
294
|
+
height: "1px",
|
|
295
|
+
width: "20cqw",
|
|
296
|
+
background: "currentColor",
|
|
297
|
+
opacity: "0.3"
|
|
298
|
+
} }), credit && h("p", { style: {
|
|
299
|
+
marginTop: "5cqw",
|
|
300
|
+
fontSize: "2.6cqw",
|
|
301
|
+
letterSpacing: "0.2em",
|
|
302
|
+
textTransform: "uppercase"
|
|
303
|
+
} }, credit), surface(side));
|
|
304
|
+
}
|
|
305
|
+
function renderColophon(book, look, side) {
|
|
306
|
+
return h("div", {
|
|
307
|
+
class: `fb-paper fb-paper-${look.paper} fb-page fb-center`,
|
|
308
|
+
style: {
|
|
309
|
+
alignItems: "center",
|
|
310
|
+
justifyContent: "flex-end",
|
|
311
|
+
padding: "12cqw",
|
|
312
|
+
opacity: "0.9"
|
|
313
|
+
}
|
|
314
|
+
}, book.colophon && serif(book.colophon, { fontSize: "3.6cqw" }, true, false), surface(side));
|
|
315
|
+
}
|
|
316
|
+
function renderBlank(look, side) {
|
|
317
|
+
return h("div", { class: `fb-paper fb-paper-${look.paper} fb-page` }, surface(side));
|
|
318
|
+
}
|
|
319
|
+
/** Any face of a book. Page numbers and the spine curve need `side`. */
|
|
320
|
+
function renderFace(face, book, side, numbered = true) {
|
|
321
|
+
const look = {
|
|
322
|
+
paper: book.paper ?? "ivory",
|
|
323
|
+
frame: book.frame ?? "none"
|
|
324
|
+
};
|
|
325
|
+
switch (face.kind) {
|
|
326
|
+
case "cover": return renderCover(book);
|
|
327
|
+
case "back": return renderBackCover(book);
|
|
328
|
+
case "title": return renderTitlePage(book, look, side);
|
|
329
|
+
case "colophon": return renderColophon(book, look, side);
|
|
330
|
+
case "blank": return renderBlank(look, side);
|
|
331
|
+
case "page": return renderPage(book.pages[face.index], look, {
|
|
332
|
+
number: numbered ? face.index + 1 : void 0,
|
|
333
|
+
side
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// src/sound.ts
|
|
338
|
+
let context = null;
|
|
339
|
+
/**
|
|
340
|
+
* A soft paper swish made from filtered noise, so no audio file is needed. Browsers only allow
|
|
341
|
+
* audio after a user gesture, which a page turn always is.
|
|
342
|
+
*/
|
|
343
|
+
function playPageTurn(volume = .18) {
|
|
344
|
+
try {
|
|
345
|
+
context ??= new AudioContext();
|
|
346
|
+
const buffer = context.createBuffer(1, Math.floor(context.sampleRate * .45), context.sampleRate);
|
|
347
|
+
const data = buffer.getChannelData(0);
|
|
348
|
+
for (let i = 0; i < data.length; i++) {
|
|
349
|
+
const t = i / data.length;
|
|
350
|
+
const envelope = Math.sin(Math.PI * Math.min(1, t * 2.2)) * (1 - t) ** 1.5;
|
|
351
|
+
data[i] = (Math.random() * 2 - 1) * envelope * (.6 + .4 * Math.sin(t * 40));
|
|
352
|
+
}
|
|
353
|
+
const source = context.createBufferSource();
|
|
354
|
+
const filter = context.createBiquadFilter();
|
|
355
|
+
const gain = context.createGain();
|
|
356
|
+
source.buffer = buffer;
|
|
357
|
+
filter.type = "bandpass";
|
|
358
|
+
filter.frequency.value = 2200;
|
|
359
|
+
filter.Q.value = .6;
|
|
360
|
+
gain.gain.value = volume;
|
|
361
|
+
source.connect(filter).connect(gain).connect(context.destination);
|
|
362
|
+
source.start();
|
|
363
|
+
} catch {}
|
|
364
|
+
}
|
|
365
|
+
// src/Flipbook.ts
|
|
366
|
+
/** Width / height of one page. */
|
|
367
|
+
const PAGE_ASPECT = 3 / 4;
|
|
368
|
+
const SOUND_KEY = "flipbook:sound";
|
|
369
|
+
const DEFAULTS = {
|
|
370
|
+
layout: "auto",
|
|
371
|
+
controls: true,
|
|
372
|
+
autoHide: 3200,
|
|
373
|
+
thumbnails: true,
|
|
374
|
+
soundButton: true,
|
|
375
|
+
sound: false,
|
|
376
|
+
fullscreenButton: true,
|
|
377
|
+
landscape: true,
|
|
378
|
+
keyboard: true,
|
|
379
|
+
stiffCovers: true,
|
|
380
|
+
shading: 1,
|
|
381
|
+
labels: DEFAULT_LABELS
|
|
382
|
+
};
|
|
383
|
+
const OVERLAYS = [
|
|
384
|
+
{
|
|
385
|
+
name: "under",
|
|
386
|
+
z: 2
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: "bend",
|
|
390
|
+
z: 4
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: "shadow",
|
|
394
|
+
z: 5
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
name: "curl",
|
|
398
|
+
z: 7
|
|
399
|
+
}
|
|
400
|
+
];
|
|
401
|
+
const polygon = (points, dx = 0, dy = 0) => points.length ? `polygon(${points.map((p) => `${(p.x + dx).toFixed(2)}px ${(p.y + dy).toFixed(2)}px`).join(", ")})` : "polygon(0 0)";
|
|
402
|
+
const icon = (d) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="${d}" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
403
|
+
const ICONS = {
|
|
404
|
+
prev: icon("M15 6l-6 6 6 6"),
|
|
405
|
+
next: icon("M9 6l6 6-6 6"),
|
|
406
|
+
expand: icon("M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"),
|
|
407
|
+
collapse: icon("M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"),
|
|
408
|
+
soundOn: icon("M4 10v4h3l5 4V6L7 10H4zM16 9a4 4 0 0 1 0 6M18.5 6.5a8 8 0 0 1 0 11"),
|
|
409
|
+
soundOff: icon("M4 10v4h3l5 4V6L7 10H4zM17 10l4 4M21 10l-4 4"),
|
|
410
|
+
grid: "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><rect x=\"3\" y=\"5\" width=\"8\" height=\"14\" rx=\"1\"/><rect x=\"13\" y=\"5\" width=\"8\" height=\"14\" rx=\"1\"/></svg>",
|
|
411
|
+
phone: "<svg viewBox=\"0 0 64 64\" class=\"fb-rotate-hint\" aria-hidden=\"true\"><rect x=\"20\" y=\"6\" width=\"24\" height=\"52\" rx=\"4\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"/><circle cx=\"32\" cy=\"51\" r=\"2\" fill=\"currentColor\"/></svg>"
|
|
412
|
+
};
|
|
413
|
+
const readStoredSound = () => {
|
|
414
|
+
try {
|
|
415
|
+
return localStorage.getItem(SOUND_KEY);
|
|
416
|
+
} catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
/**
|
|
421
|
+
* A photo book that opens across two pages, turned by dragging, flicking, tapping, buttons or
|
|
422
|
+
* keys. Mount it into any sized element; call `destroy()` when it goes away.
|
|
423
|
+
*/
|
|
424
|
+
var Flipbook = class Flipbook {
|
|
425
|
+
container;
|
|
426
|
+
root;
|
|
427
|
+
reader;
|
|
428
|
+
settings;
|
|
429
|
+
layout;
|
|
430
|
+
size = {
|
|
431
|
+
width: 0,
|
|
432
|
+
height: 0
|
|
433
|
+
};
|
|
434
|
+
orientation = {
|
|
435
|
+
portrait: false,
|
|
436
|
+
touch: false
|
|
437
|
+
};
|
|
438
|
+
portraitChoice = "ask";
|
|
439
|
+
soundOn;
|
|
440
|
+
stripOpen = false;
|
|
441
|
+
chromeVisible = true;
|
|
442
|
+
hideTimer;
|
|
443
|
+
lastPlace = "";
|
|
444
|
+
rendering = false;
|
|
445
|
+
dirty = false;
|
|
446
|
+
faces = /* @__PURE__ */ new Map();
|
|
447
|
+
overlays = /* @__PURE__ */ new Map();
|
|
448
|
+
cleanups = [];
|
|
449
|
+
els;
|
|
450
|
+
constructor(container, options) {
|
|
451
|
+
this.container = container;
|
|
452
|
+
this.settings = Flipbook.resolve(options);
|
|
453
|
+
this.soundOn = this.settings.sound;
|
|
454
|
+
this.layout = this.buildLayout();
|
|
455
|
+
this.reader = new FlipbookReader(this.layout, {
|
|
456
|
+
frame: (frame, turn) => this.paint(frame, turn),
|
|
457
|
+
change: () => this.render(),
|
|
458
|
+
flip: () => {
|
|
459
|
+
if (this.soundOn) playPageTurn();
|
|
460
|
+
this.settings.onFlip?.();
|
|
461
|
+
}
|
|
462
|
+
}, {
|
|
463
|
+
flipped: this.layout.flippedForPage(this.settings.startPage),
|
|
464
|
+
physics: options.physics,
|
|
465
|
+
grip: options.grip
|
|
466
|
+
});
|
|
467
|
+
this.root = h("div", { class: "fb-root" });
|
|
468
|
+
this.els = this.build();
|
|
469
|
+
container.append(this.root);
|
|
470
|
+
const stored = readStoredSound();
|
|
471
|
+
if (this.settings.soundButton && stored) this.soundOn = stored === "on";
|
|
472
|
+
this.listen();
|
|
473
|
+
this.poke();
|
|
474
|
+
this.render();
|
|
475
|
+
}
|
|
476
|
+
/** Changes options in place; the reader keeps their page. */
|
|
477
|
+
update(options) {
|
|
478
|
+
const bookChanged = options.book && options.book !== this.settings.book;
|
|
479
|
+
this.settings = Flipbook.resolve({
|
|
480
|
+
...this.settings,
|
|
481
|
+
...options,
|
|
482
|
+
labels: {
|
|
483
|
+
...this.settings.labels,
|
|
484
|
+
...options.labels
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
if (options.sound !== void 0) this.soundOn = options.sound;
|
|
488
|
+
if (options.physics) this.reader.engine.physics = {
|
|
489
|
+
...this.reader.engine.physics,
|
|
490
|
+
...options.physics
|
|
491
|
+
};
|
|
492
|
+
if (options.grip) this.reader.grip = {
|
|
493
|
+
...this.reader.grip,
|
|
494
|
+
...options.grip
|
|
495
|
+
};
|
|
496
|
+
if (bookChanged || options.stiffCovers !== void 0) this.clearFaces();
|
|
497
|
+
this.els.title.textContent = this.settings.book.title;
|
|
498
|
+
this.poke();
|
|
499
|
+
this.render();
|
|
500
|
+
}
|
|
501
|
+
next() {
|
|
502
|
+
this.reader.next();
|
|
503
|
+
}
|
|
504
|
+
prev() {
|
|
505
|
+
this.reader.prev();
|
|
506
|
+
}
|
|
507
|
+
/** Opens at a `flipped` value. */
|
|
508
|
+
go(flipped) {
|
|
509
|
+
this.reader.go(flipped);
|
|
510
|
+
}
|
|
511
|
+
/** Opens at a page (1-based). */
|
|
512
|
+
goToPage(page) {
|
|
513
|
+
this.reader.go(this.reader.layout.flippedForPage(page));
|
|
514
|
+
}
|
|
515
|
+
destroy() {
|
|
516
|
+
this.reader.destroy();
|
|
517
|
+
clearTimeout(this.hideTimer);
|
|
518
|
+
this.cleanups.forEach((cleanup) => cleanup());
|
|
519
|
+
this.root.remove();
|
|
520
|
+
}
|
|
521
|
+
static resolve(options) {
|
|
522
|
+
const defined = Object.fromEntries(Object.entries(options).filter(([, v]) => v !== void 0));
|
|
523
|
+
return {
|
|
524
|
+
...DEFAULTS,
|
|
525
|
+
...defined,
|
|
526
|
+
labels: {
|
|
527
|
+
...DEFAULT_LABELS,
|
|
528
|
+
...options.labels
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
get spread() {
|
|
533
|
+
const { layout, landscape } = this.settings;
|
|
534
|
+
const mobilePortrait = landscape && this.orientation.touch && this.orientation.portrait;
|
|
535
|
+
const stage = this.els?.stage.getBoundingClientRect();
|
|
536
|
+
const narrow = !!stage && stage.width > 0 && stage.width / stage.height < .95;
|
|
537
|
+
if (layout !== "auto") return layout === "spread";
|
|
538
|
+
return !(mobilePortrait && this.portraitChoice === "single") && !narrow;
|
|
539
|
+
}
|
|
540
|
+
buildLayout() {
|
|
541
|
+
return new BookLayout(this.settings.book.pages.length, {
|
|
542
|
+
spread: this.spread,
|
|
543
|
+
stiffCovers: this.settings.stiffCovers
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
build() {
|
|
547
|
+
const button = (label, markup, onClick) => {
|
|
548
|
+
const el = h("button", {
|
|
549
|
+
type: "button",
|
|
550
|
+
class: "fb-button",
|
|
551
|
+
"aria-label": label,
|
|
552
|
+
title: label
|
|
553
|
+
});
|
|
554
|
+
el.append(svg(markup));
|
|
555
|
+
el.addEventListener("click", onClick);
|
|
556
|
+
return el;
|
|
557
|
+
};
|
|
558
|
+
const title = h("h1", { class: "fb-title" }, this.settings.book.title);
|
|
559
|
+
const byline = h("p", { class: "fb-byline" });
|
|
560
|
+
const sound = button("", ICONS.soundOff, () => this.toggleSound());
|
|
561
|
+
const fullscreen = button("", ICONS.expand, () => this.toggleFullscreen());
|
|
562
|
+
const topRight = h("div", { class: "fb-bar-group" }, sound, fullscreen);
|
|
563
|
+
const top = h("div", { class: "fb-bar fb-bar-top" }, h("div", { class: "fb-bar-group" }, h("div", { class: "fb-heading" }, title, byline)), topRight);
|
|
564
|
+
const shadow = h("div", { class: "fb-book-shadow" });
|
|
565
|
+
const leftEdge = h("div", { class: "fb-edge" });
|
|
566
|
+
const rightEdge = h("div", { class: "fb-edge" });
|
|
567
|
+
const spine = h("div", { class: "fb-spine" });
|
|
568
|
+
const book = h("div", { class: "fb-book" }, shadow, leftEdge, rightEdge, spine);
|
|
569
|
+
const stage = h("div", { class: "fb-stage" }, book);
|
|
570
|
+
const prev = button("", ICONS.prev, () => this.reader.prev());
|
|
571
|
+
const next = button("", ICONS.next, () => this.reader.next());
|
|
572
|
+
const pages = h("button", {
|
|
573
|
+
type: "button",
|
|
574
|
+
class: "fb-pages-button",
|
|
575
|
+
"aria-live": "polite"
|
|
576
|
+
});
|
|
577
|
+
const bottom = h("div", { class: "fb-bar fb-bar-bottom" }, prev, pages, next);
|
|
578
|
+
pages.addEventListener("click", () => {
|
|
579
|
+
if (!this.settings.thumbnails) return;
|
|
580
|
+
this.stripOpen = !this.stripOpen;
|
|
581
|
+
this.render();
|
|
582
|
+
});
|
|
583
|
+
for (const { name, z } of OVERLAYS) {
|
|
584
|
+
const light = h("div", { class: "fb-light" });
|
|
585
|
+
const clip = h("div", {
|
|
586
|
+
class: "fb-overlay",
|
|
587
|
+
style: { zIndex: String(z) }
|
|
588
|
+
}, light);
|
|
589
|
+
this.overlays.set(name, {
|
|
590
|
+
clip,
|
|
591
|
+
light
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
this.root.append(top, stage, bottom);
|
|
595
|
+
return {
|
|
596
|
+
top,
|
|
597
|
+
title,
|
|
598
|
+
byline,
|
|
599
|
+
topRight,
|
|
600
|
+
sound,
|
|
601
|
+
fullscreen,
|
|
602
|
+
stage,
|
|
603
|
+
book,
|
|
604
|
+
shadow,
|
|
605
|
+
leftEdge,
|
|
606
|
+
rightEdge,
|
|
607
|
+
spine,
|
|
608
|
+
bottom,
|
|
609
|
+
prev,
|
|
610
|
+
pages,
|
|
611
|
+
next,
|
|
612
|
+
strip: null,
|
|
613
|
+
rotate: null
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
listen() {
|
|
617
|
+
const { stage } = this.els;
|
|
618
|
+
const on = (target, type, handler) => {
|
|
619
|
+
target.addEventListener(type, handler);
|
|
620
|
+
this.cleanups.push(() => target.removeEventListener(type, handler));
|
|
621
|
+
};
|
|
622
|
+
const observer = new ResizeObserver(() => this.render());
|
|
623
|
+
observer.observe(stage);
|
|
624
|
+
this.cleanups.push(() => observer.disconnect());
|
|
625
|
+
this.cleanups.push(watchOrientation((state) => {
|
|
626
|
+
this.orientation = state;
|
|
627
|
+
this.render();
|
|
628
|
+
}));
|
|
629
|
+
on(stage, "pointerdown", (e) => {
|
|
630
|
+
if (e.button === 0) this.reader.down(this.toBook(e), e.pointerId);
|
|
631
|
+
});
|
|
632
|
+
on(stage, "pointermove", (e) => {
|
|
633
|
+
const p = this.toBook(e);
|
|
634
|
+
this.poke();
|
|
635
|
+
if (e.pointerType === "mouse") this.reader.hover(p);
|
|
636
|
+
if (this.reader.move(p, e.pointerId) && !stage.hasPointerCapture(e.pointerId)) stage.setPointerCapture(e.pointerId);
|
|
637
|
+
});
|
|
638
|
+
on(stage, "pointerup", (e) => {
|
|
639
|
+
if (this.reader.up(this.toBook(e), e.pointerId) !== "tap") return;
|
|
640
|
+
const rect = stage.getBoundingClientRect();
|
|
641
|
+
const x = e.clientX - rect.left;
|
|
642
|
+
if (x > rect.width * 2 / 3) this.reader.next();
|
|
643
|
+
else if (x < rect.width / 3) this.reader.prev();
|
|
644
|
+
});
|
|
645
|
+
on(stage, "pointerleave", () => this.reader.leave());
|
|
646
|
+
on(stage, "pointercancel", () => this.reader.cancel());
|
|
647
|
+
on(this.root, "keydown", (e) => this.settings.keyboard === true && this.onKey(e));
|
|
648
|
+
on(window, "keydown", (e) => this.settings.keyboard === "global" && this.onKey(e));
|
|
649
|
+
on(document, "fullscreenchange", () => this.render());
|
|
650
|
+
}
|
|
651
|
+
onKey(e) {
|
|
652
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return;
|
|
653
|
+
if ([
|
|
654
|
+
"ArrowRight",
|
|
655
|
+
"PageDown",
|
|
656
|
+
" "
|
|
657
|
+
].includes(e.key)) {
|
|
658
|
+
e.preventDefault();
|
|
659
|
+
this.reader.next();
|
|
660
|
+
} else if (["ArrowLeft", "PageUp"].includes(e.key)) {
|
|
661
|
+
e.preventDefault();
|
|
662
|
+
this.reader.prev();
|
|
663
|
+
} else if (e.key === "Home") this.reader.go(0);
|
|
664
|
+
else if (e.key === "End") this.reader.go(this.reader.layout.length);
|
|
665
|
+
else if (e.key === "Escape" && this.stripOpen) {
|
|
666
|
+
this.stripOpen = false;
|
|
667
|
+
this.render();
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/** Pointer position from the top of the spine. */
|
|
671
|
+
toBook(e) {
|
|
672
|
+
const rect = this.els.book.getBoundingClientRect();
|
|
673
|
+
return {
|
|
674
|
+
x: e.clientX - (rect.left + (this.layout.spread ? rect.width / 2 : 0)),
|
|
675
|
+
y: e.clientY - rect.top
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
poke() {
|
|
679
|
+
const { autoHide } = this.settings;
|
|
680
|
+
clearTimeout(this.hideTimer);
|
|
681
|
+
if (!this.chromeVisible) {
|
|
682
|
+
this.chromeVisible = true;
|
|
683
|
+
this.renderChrome();
|
|
684
|
+
}
|
|
685
|
+
if (autoHide === false) return;
|
|
686
|
+
this.hideTimer = setTimeout(() => {
|
|
687
|
+
this.chromeVisible = false;
|
|
688
|
+
this.renderChrome();
|
|
689
|
+
}, autoHide);
|
|
690
|
+
}
|
|
691
|
+
toggleSound() {
|
|
692
|
+
this.soundOn = !this.soundOn;
|
|
693
|
+
if (this.soundOn) playPageTurn();
|
|
694
|
+
try {
|
|
695
|
+
localStorage.setItem(SOUND_KEY, this.soundOn ? "on" : "off");
|
|
696
|
+
} catch {}
|
|
697
|
+
this.renderChrome();
|
|
698
|
+
}
|
|
699
|
+
async toggleFullscreen() {
|
|
700
|
+
if (document.fullscreenElement) await document.exitFullscreen().catch(() => {});
|
|
701
|
+
else await enterLandscape(this.root);
|
|
702
|
+
}
|
|
703
|
+
clearFaces() {
|
|
704
|
+
this.faces.forEach((el) => el.remove());
|
|
705
|
+
this.faces.clear();
|
|
706
|
+
}
|
|
707
|
+
/** Brings the DOM in line with the reader. Re-entrant calls (from the reader) run once more at the end. */
|
|
708
|
+
render() {
|
|
709
|
+
if (this.rendering) {
|
|
710
|
+
this.dirty = true;
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
this.rendering = true;
|
|
714
|
+
do {
|
|
715
|
+
this.dirty = false;
|
|
716
|
+
this.renderOnce();
|
|
717
|
+
} while (this.dirty);
|
|
718
|
+
this.rendering = false;
|
|
719
|
+
}
|
|
720
|
+
renderOnce() {
|
|
721
|
+
const { settings, reader, els } = this;
|
|
722
|
+
const { book, controls } = settings;
|
|
723
|
+
const stage = els.stage.getBoundingClientRect();
|
|
724
|
+
const spread = this.spread;
|
|
725
|
+
if (spread !== this.layout.spread || book.pages.length !== this.layout.pageCount || settings.stiffCovers !== this.layout.stiffCovers) {
|
|
726
|
+
this.layout = this.buildLayout();
|
|
727
|
+
this.clearFaces();
|
|
728
|
+
}
|
|
729
|
+
const across = spread ? 2 : 1;
|
|
730
|
+
const marginY = !controls || stage.height < 500 ? 16 : Math.min(150, stage.height * .16);
|
|
731
|
+
const availW = Math.max(0, stage.width - (spread ? 64 : 24));
|
|
732
|
+
const availH = Math.max(0, stage.height - marginY);
|
|
733
|
+
const pageW = Math.floor(Math.min(availH * PAGE_ASPECT, availW / across));
|
|
734
|
+
const pageH = Math.floor(pageW / PAGE_ASPECT);
|
|
735
|
+
this.size = {
|
|
736
|
+
width: pageW,
|
|
737
|
+
height: pageH
|
|
738
|
+
};
|
|
739
|
+
reader.configure({
|
|
740
|
+
layout: this.layout,
|
|
741
|
+
width: pageW,
|
|
742
|
+
height: pageH
|
|
743
|
+
});
|
|
744
|
+
const { flipped, active, settled, atStart, atEnd } = reader;
|
|
745
|
+
const leaves = this.layout.leaves;
|
|
746
|
+
let shift = 0;
|
|
747
|
+
if (spread && atStart && settled) shift = -pageW / 2;
|
|
748
|
+
if (spread && atEnd && settled) shift = pageW / 2;
|
|
749
|
+
this.root.style.background = settings.background ?? Flipbook.stageTint(book);
|
|
750
|
+
this.root.tabIndex = settings.keyboard === true ? 0 : -1;
|
|
751
|
+
els.book.hidden = pageW <= 0;
|
|
752
|
+
Object.assign(els.book.style, {
|
|
753
|
+
width: `${pageW * across}px`,
|
|
754
|
+
height: `${pageH}px`,
|
|
755
|
+
transform: `translateX(${shift}px)`,
|
|
756
|
+
cursor: reader.engine.dragging ? "grabbing" : "grab",
|
|
757
|
+
clipPath: spread ? "" : "inset(-50% -50% -50% 0)"
|
|
758
|
+
});
|
|
759
|
+
Object.assign(els.shadow.style, {
|
|
760
|
+
left: spread && atStart && settled ? "52%" : "2%",
|
|
761
|
+
right: spread && atEnd && settled ? "52%" : "2%"
|
|
762
|
+
});
|
|
763
|
+
const open = spread && !atStart && !atEnd;
|
|
764
|
+
const edge = (count) => Math.min(10, Math.max(0, count) * .8);
|
|
765
|
+
const leftEdge = open && flipped > 1 ? edge(flipped - 1) : 0;
|
|
766
|
+
const rightEdge = (open || spread && atStart) && flipped < leaves.length - 1 ? edge(leaves.length - flipped - 1) : 0;
|
|
767
|
+
Object.assign(els.leftEdge.style, {
|
|
768
|
+
display: leftEdge ? "" : "none",
|
|
769
|
+
left: `${-leftEdge}px`,
|
|
770
|
+
width: `${leftEdge}px`
|
|
771
|
+
});
|
|
772
|
+
Object.assign(els.rightEdge.style, {
|
|
773
|
+
display: rightEdge ? "" : "none",
|
|
774
|
+
right: `${-rightEdge}px`,
|
|
775
|
+
width: `${rightEdge}px`
|
|
776
|
+
});
|
|
777
|
+
els.spine.style.left = `${spread ? pageW : 0}px`;
|
|
778
|
+
this.renderFaces(active, flipped, pageW, pageH);
|
|
779
|
+
this.renderOverlays(active, pageW, pageH);
|
|
780
|
+
this.renderChrome();
|
|
781
|
+
this.renderStrip();
|
|
782
|
+
this.renderRotatePrompt();
|
|
783
|
+
const place = `${spread}:${flipped}`;
|
|
784
|
+
if (place !== this.lastPlace) {
|
|
785
|
+
this.lastPlace = place;
|
|
786
|
+
settings.onPageChange?.({
|
|
787
|
+
flipped,
|
|
788
|
+
label: reader.label,
|
|
789
|
+
faces: this.layout.visible(flipped)
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/** Faces of the leaves around the opening. Each keeps its element while it moves between roles, so pages never reload mid-turn. */
|
|
794
|
+
renderFaces(active, flipped, pageW, pageH) {
|
|
795
|
+
const leaves = this.layout.leaves;
|
|
796
|
+
const turnLeaf = active?.leaf ?? null;
|
|
797
|
+
const turnDir = active?.dir ?? 1;
|
|
798
|
+
const rightLeaf = turnLeaf !== null ? turnLeaf + 1 : flipped;
|
|
799
|
+
const leftLeaf = turnLeaf !== null ? turnLeaf - 1 : flipped - 1;
|
|
800
|
+
const wanted = /* @__PURE__ */ new Set();
|
|
801
|
+
for (let k = Math.max(0, flipped - 2); k <= Math.min(leaves.length - 1, flipped + 1); k++) for (const side of ["front", "back"]) {
|
|
802
|
+
const key = `${k}:${side}`;
|
|
803
|
+
const role = k === turnLeaf ? side === "front" === (turnDir === 1) ? "turning" : "flap" : side === "front" ? k === rightLeaf ? "right" : "spare" : k === leftLeaf && this.layout.spread ? "left" : "spare";
|
|
804
|
+
const onLeft = role === "left" || role === "turning" && turnDir === -1 && !active?.stiff;
|
|
805
|
+
let el = this.faces.get(key);
|
|
806
|
+
if (!el) {
|
|
807
|
+
el = h("div", { class: "fb-face" });
|
|
808
|
+
el.append(renderFace(side === "front" ? leaves[k].front : leaves[k].back, this.settings.book, side === "front" ? "right" : "left"));
|
|
809
|
+
this.faces.set(key, el);
|
|
810
|
+
this.els.spine.append(el);
|
|
811
|
+
}
|
|
812
|
+
wanted.add(key);
|
|
813
|
+
Object.assign(el.style, {
|
|
814
|
+
left: `${onLeft ? -pageW : 0}px`,
|
|
815
|
+
width: `${pageW}px`,
|
|
816
|
+
height: `${pageH}px`,
|
|
817
|
+
zIndex: String({
|
|
818
|
+
right: 1,
|
|
819
|
+
left: 1,
|
|
820
|
+
turning: 3,
|
|
821
|
+
flap: 6,
|
|
822
|
+
spare: 0
|
|
823
|
+
}[role]),
|
|
824
|
+
visibility: role === "spare" ? "hidden" : "visible"
|
|
825
|
+
});
|
|
826
|
+
el.setAttribute("aria-hidden", String(role !== "left" && role !== "right"));
|
|
827
|
+
if (k !== turnLeaf) Object.assign(el.style, {
|
|
828
|
+
transform: "",
|
|
829
|
+
transformOrigin: "",
|
|
830
|
+
clipPath: ""
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
for (const [key, el] of this.faces) {
|
|
834
|
+
if (wanted.has(key)) continue;
|
|
835
|
+
el.remove();
|
|
836
|
+
this.faces.delete(key);
|
|
837
|
+
}
|
|
838
|
+
if (active) this.paint(this.reader.engine.frame, active);
|
|
839
|
+
}
|
|
840
|
+
renderOverlays(active, pageW, pageH) {
|
|
841
|
+
const show = !!active && !active.stiff;
|
|
842
|
+
const w = (share) => `${(pageW * share).toFixed(1)}px`;
|
|
843
|
+
const lights = {
|
|
844
|
+
under: `linear-gradient(to right, rgb(0 0 0 / 0.42), rgb(0 0 0 / 0.2) ${w(.05)}, rgb(0 0 0 / 0.06) ${w(.16)}, transparent ${w(.3)})`,
|
|
845
|
+
bend: `linear-gradient(to right, rgb(0 0 0 / 0.16), rgb(0 0 0 / 0.04) ${w(.05)}, transparent ${w(.1)})`,
|
|
846
|
+
shadow: "rgb(0 0 0 / 0.55)",
|
|
847
|
+
curl: `linear-gradient(to right, rgb(0 0 0 / 0.3), rgb(0 0 0 / 0.1) ${w(.035)}, rgb(255 255 255 / 0.2) ${w(.11)}, rgb(255 255 255 / 0.06) ${w(.25)}, rgb(0 0 0 / 0.05) ${w(.6)}, rgb(0 0 0 / 0.12) ${w(1)})`
|
|
848
|
+
};
|
|
849
|
+
for (const [name, { clip, light }] of this.overlays) {
|
|
850
|
+
if (!show) {
|
|
851
|
+
clip.remove();
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
const shadow = name === "shadow";
|
|
855
|
+
Object.assign(clip.style, {
|
|
856
|
+
left: `${-pageW}px`,
|
|
857
|
+
top: `${-pageH}px`,
|
|
858
|
+
width: `${pageW * 2}px`,
|
|
859
|
+
height: `${pageH * 3}px`,
|
|
860
|
+
filter: shadow ? `blur(${Math.max(3, pageW * .012)}px)` : ""
|
|
861
|
+
});
|
|
862
|
+
Object.assign(light.style, shadow ? {
|
|
863
|
+
inset: "0",
|
|
864
|
+
background: lights[name]
|
|
865
|
+
} : {
|
|
866
|
+
width: `${pageW * 3}px`,
|
|
867
|
+
height: `${pageH * 4}px`,
|
|
868
|
+
background: lights[name]
|
|
869
|
+
});
|
|
870
|
+
if (!clip.isConnected) this.els.spine.append(clip);
|
|
871
|
+
}
|
|
872
|
+
if (show && active) this.paint(this.reader.engine.frame, active);
|
|
873
|
+
}
|
|
874
|
+
paint(frame, turn) {
|
|
875
|
+
const { width: pageW, height: pageH } = this.size;
|
|
876
|
+
const s = turn.dir;
|
|
877
|
+
const front = this.faces.get(`${turn.leaf}:front`);
|
|
878
|
+
const back = this.faces.get(`${turn.leaf}:back`);
|
|
879
|
+
if (frame.stiff) {
|
|
880
|
+
const perspective = `perspective(${Math.max(1800, pageW * 5)}px) rotateY(${Fold.hinge(frame.origin, frame.point, s)}deg)`;
|
|
881
|
+
if (front) Object.assign(front.style, {
|
|
882
|
+
transformOrigin: "0 50%",
|
|
883
|
+
transform: perspective,
|
|
884
|
+
clipPath: ""
|
|
885
|
+
});
|
|
886
|
+
if (back) Object.assign(back.style, {
|
|
887
|
+
transformOrigin: "0 50%",
|
|
888
|
+
transform: `${perspective} translateX(${pageW}px) rotateY(180deg)`,
|
|
889
|
+
clipPath: ""
|
|
890
|
+
});
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const top = s === 1 ? front : back;
|
|
894
|
+
const under = s === 1 ? back : front;
|
|
895
|
+
const fold = new Fold(pageW, pageH, frame.origin, frame.point);
|
|
896
|
+
const strength = fold.shade * this.settings.shading;
|
|
897
|
+
if (top) Object.assign(top.style, {
|
|
898
|
+
transformOrigin: "",
|
|
899
|
+
transform: "",
|
|
900
|
+
clipPath: polygon(fold.flat.map((p) => ({
|
|
901
|
+
x: s === 1 ? p.x : pageW - p.x,
|
|
902
|
+
y: p.y
|
|
903
|
+
})))
|
|
904
|
+
});
|
|
905
|
+
if (under) Object.assign(under.style, {
|
|
906
|
+
transformOrigin: "0 0",
|
|
907
|
+
transform: fold.flapMatrix(s),
|
|
908
|
+
clipPath: polygon(fold.folded.map((p) => ({
|
|
909
|
+
x: s === 1 ? pageW - p.x : p.x,
|
|
910
|
+
y: p.y
|
|
911
|
+
})))
|
|
912
|
+
});
|
|
913
|
+
const layer = (name, region, toward, opacity) => {
|
|
914
|
+
const refs = this.overlays.get(name);
|
|
915
|
+
if (!refs) return;
|
|
916
|
+
const shape = polygon(region.map((p) => ({
|
|
917
|
+
x: s * p.x,
|
|
918
|
+
y: p.y
|
|
919
|
+
})), pageW, pageH);
|
|
920
|
+
refs.clip.style.opacity = String(opacity);
|
|
921
|
+
if (name === "shadow") {
|
|
922
|
+
refs.light.style.clipPath = shape;
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
const angle = Math.atan2(toward * fold.normal.y, s * toward * fold.normal.x);
|
|
926
|
+
refs.clip.style.clipPath = shape;
|
|
927
|
+
refs.light.style.transform = `translate(${s * fold.center.x + pageW}px, ${fold.center.y + pageH}px) rotate(${angle}rad) translateY(-50%)`;
|
|
928
|
+
};
|
|
929
|
+
layer("under", fold.folded, 1, strength);
|
|
930
|
+
layer("bend", fold.flat, -1, strength);
|
|
931
|
+
layer("shadow", fold.flap, -1, strength * .6);
|
|
932
|
+
layer("curl", fold.flap, -1, strength);
|
|
933
|
+
}
|
|
934
|
+
renderChrome() {
|
|
935
|
+
const { els, settings, reader } = this;
|
|
936
|
+
const { labels } = settings;
|
|
937
|
+
const shown = this.chromeVisible || this.stripOpen;
|
|
938
|
+
const fullscreen = document.fullscreenElement === this.root;
|
|
939
|
+
const setButton = (el, label, markup) => {
|
|
940
|
+
el.setAttribute("aria-label", label);
|
|
941
|
+
el.title = label;
|
|
942
|
+
if (markup) el.replaceChildren(svg(markup));
|
|
943
|
+
};
|
|
944
|
+
els.top.hidden = !settings.controls;
|
|
945
|
+
els.bottom.hidden = !settings.controls;
|
|
946
|
+
els.top.classList.toggle("is-hidden", !shown);
|
|
947
|
+
els.bottom.classList.toggle("is-hidden", !shown);
|
|
948
|
+
els.title.textContent = settings.book.title;
|
|
949
|
+
els.byline.textContent = settings.book.author ? labels.byline(settings.book.author) : "";
|
|
950
|
+
els.byline.hidden = !settings.book.author;
|
|
951
|
+
els.sound.hidden = !settings.soundButton;
|
|
952
|
+
els.fullscreen.hidden = !settings.fullscreenButton;
|
|
953
|
+
setButton(els.sound, this.soundOn ? labels.soundOff : labels.soundOn, this.soundOn ? ICONS.soundOn : ICONS.soundOff);
|
|
954
|
+
setButton(els.fullscreen, fullscreen ? labels.exitFullscreen : labels.fullscreen, fullscreen ? ICONS.collapse : ICONS.expand);
|
|
955
|
+
setButton(els.prev, labels.previous);
|
|
956
|
+
setButton(els.next, labels.next);
|
|
957
|
+
els.prev.disabled = reader.atStart;
|
|
958
|
+
els.next.disabled = reader.atEnd;
|
|
959
|
+
els.pages.replaceChildren(...settings.thumbnails ? [svg(ICONS.grid)] : [], reader.label);
|
|
960
|
+
els.pages.setAttribute("aria-expanded", String(this.stripOpen));
|
|
961
|
+
}
|
|
962
|
+
/** A strip of every spread (or page) to jump around the book. */
|
|
963
|
+
renderStrip() {
|
|
964
|
+
const open = this.stripOpen && this.settings.thumbnails;
|
|
965
|
+
const { book } = this.settings;
|
|
966
|
+
if (!open) {
|
|
967
|
+
this.els.strip?.remove();
|
|
968
|
+
this.els.strip = null;
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const list = h("div", { class: "fb-strip-list" });
|
|
972
|
+
for (const stop of this.layout.stops()) {
|
|
973
|
+
const button = h("button", {
|
|
974
|
+
type: "button",
|
|
975
|
+
class: "fb-strip-stop",
|
|
976
|
+
"aria-current": String(stop.flipped === this.reader.flipped),
|
|
977
|
+
"aria-label": `Go to ${BookLayout.describe(stop.faces)}`
|
|
978
|
+
});
|
|
979
|
+
for (const face of stop.faces) button.append(h("div", { class: "fb-strip-face" }, renderFace(face, book, void 0, false)));
|
|
980
|
+
button.addEventListener("click", () => {
|
|
981
|
+
this.stripOpen = false;
|
|
982
|
+
this.reader.go(stop.flipped);
|
|
983
|
+
this.render();
|
|
984
|
+
});
|
|
985
|
+
list.append(button);
|
|
986
|
+
}
|
|
987
|
+
const strip = h("div", { class: "fb-strip" }, list);
|
|
988
|
+
this.els.strip?.remove();
|
|
989
|
+
this.els.strip = strip;
|
|
990
|
+
this.els.bottom.before(strip);
|
|
991
|
+
list.querySelector("[aria-current='true']")?.scrollIntoView({
|
|
992
|
+
inline: "center",
|
|
993
|
+
block: "nearest"
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
/** Shown on phones held upright: books are read sideways. */
|
|
997
|
+
renderRotatePrompt() {
|
|
998
|
+
const { landscape, layout, labels } = this.settings;
|
|
999
|
+
if (!(landscape && layout !== "single" && this.orientation.touch && this.orientation.portrait && this.portraitChoice === "ask")) {
|
|
1000
|
+
this.els.rotate?.remove();
|
|
1001
|
+
this.els.rotate = null;
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (this.els.rotate) return;
|
|
1005
|
+
const primary = h("button", {
|
|
1006
|
+
type: "button",
|
|
1007
|
+
class: "fb-rotate-primary"
|
|
1008
|
+
}, labels.rotateFullscreen);
|
|
1009
|
+
const secondary = h("button", {
|
|
1010
|
+
type: "button",
|
|
1011
|
+
class: "fb-rotate-secondary"
|
|
1012
|
+
}, labels.rotateSingle);
|
|
1013
|
+
primary.addEventListener("click", () => enterLandscape(this.root));
|
|
1014
|
+
secondary.addEventListener("click", () => {
|
|
1015
|
+
this.portraitChoice = "single";
|
|
1016
|
+
this.render();
|
|
1017
|
+
});
|
|
1018
|
+
this.els.rotate = h("div", { class: "fb-rotate" }, svg(ICONS.phone), h("div", {}, h("p", { class: "fb-rotate-title" }, labels.rotateTitle), h("p", { class: "fb-rotate-body" }, labels.rotateBody)), primary, secondary);
|
|
1019
|
+
this.root.append(this.els.rotate);
|
|
1020
|
+
}
|
|
1021
|
+
static stageTint(book) {
|
|
1022
|
+
const [inner, middle, outer] = COVER_THEMES[book.theme ?? "classic"].stage;
|
|
1023
|
+
return `radial-gradient(ellipse at 50% 40%, ${inner} 0%, ${middle} 55%, ${outer} 100%)`;
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
export { Flipbook, enterLandscape, playPageTurn, printedPhoto, renderBackCover, renderBlank, renderColophon, renderCover, renderFace, renderPage, renderTitlePage, watchOrientation };
|