@openpageflip/core 0.0.1 → 0.1.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/README.md +26 -3
- package/dist/index.d.ts +282 -1
- package/dist/index.iife.js +1 -36
- package/dist/index.iife.js.map +1 -1
- package/dist/index.js +1506 -1
- package/dist/index.js.map +1 -1
- package/dist/styles.css +16 -8
- package/package.json +5 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
|
+
//#region src/animation.ts
|
|
2
|
+
const browserClock = {
|
|
3
|
+
now: () => performance.now(),
|
|
4
|
+
requestFrame: (callback) => requestAnimationFrame(callback),
|
|
5
|
+
cancelFrame: (handle) => cancelAnimationFrame(handle)
|
|
6
|
+
};
|
|
7
|
+
/** A single time-based animation. Frames are requested only while it runs. */
|
|
8
|
+
function startTween(clock, spec) {
|
|
9
|
+
const startedAt = clock.now();
|
|
10
|
+
let handle = null;
|
|
11
|
+
let done = false;
|
|
12
|
+
const end = () => {
|
|
13
|
+
if (done) return;
|
|
14
|
+
done = true;
|
|
15
|
+
if (handle !== null) clock.cancelFrame(handle);
|
|
16
|
+
handle = null;
|
|
17
|
+
spec.onFrame(1);
|
|
18
|
+
spec.onEnd();
|
|
19
|
+
};
|
|
20
|
+
const tick = (time) => {
|
|
21
|
+
handle = null;
|
|
22
|
+
if (done) return;
|
|
23
|
+
const t = spec.duration <= 0 ? 1 : Math.min(1, (time - startedAt) / spec.duration);
|
|
24
|
+
if (t >= 1) {
|
|
25
|
+
end();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
spec.onFrame(spec.easing(t));
|
|
29
|
+
handle = clock.requestFrame(tick);
|
|
30
|
+
};
|
|
31
|
+
if (spec.duration <= 0) end();
|
|
32
|
+
else handle = clock.requestFrame(tick);
|
|
33
|
+
return {
|
|
34
|
+
finish: end,
|
|
35
|
+
cancel: () => {
|
|
36
|
+
done = true;
|
|
37
|
+
if (handle !== null) clock.cancelFrame(handle);
|
|
38
|
+
handle = null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
1
43
|
//#region src/options.ts
|
|
2
44
|
/**
|
|
3
45
|
* Public option vocabularies. Plain `as const` objects instead of enums so they survive
|
|
@@ -24,7 +66,1470 @@ const PageDensity = {
|
|
|
24
66
|
soft: "soft",
|
|
25
67
|
hard: "hard"
|
|
26
68
|
};
|
|
69
|
+
/** Which way a page is turning: `forward` reads on, `back` returns to the previous spread. */
|
|
70
|
+
const FlipDirection = {
|
|
71
|
+
forward: "forward",
|
|
72
|
+
back: "back"
|
|
73
|
+
};
|
|
74
|
+
/** What the book is showing: one page (`portrait`) or a two-page spread (`landscape`). */
|
|
75
|
+
const Orientation = {
|
|
76
|
+
portrait: "portrait",
|
|
77
|
+
landscape: "landscape"
|
|
78
|
+
};
|
|
79
|
+
/** What the book is doing right now. */
|
|
80
|
+
const FlipState = {
|
|
81
|
+
/** Nothing in motion. */
|
|
82
|
+
read: "read",
|
|
83
|
+
/** A corner is lifted because the pointer hovers over it. */
|
|
84
|
+
foldCorner: "fold_corner",
|
|
85
|
+
/** The user is dragging a corner. */
|
|
86
|
+
userFold: "user_fold",
|
|
87
|
+
/** A flip animation is running. */
|
|
88
|
+
flipping: "flipping"
|
|
89
|
+
};
|
|
90
|
+
/** How the book sizes itself. */
|
|
91
|
+
const SizeMode = {
|
|
92
|
+
/** Pages are exactly `width` x `height` CSS pixels. */
|
|
93
|
+
fixed: "fixed",
|
|
94
|
+
/** Pages scale to the container, keeping the `width:height` ratio, between `minWidth` and `maxWidth`. */
|
|
95
|
+
stretch: "stretch"
|
|
96
|
+
};
|
|
97
|
+
/** When a click or tap turns the page. */
|
|
98
|
+
const ClickMode = {
|
|
99
|
+
anywhere: "anywhere",
|
|
100
|
+
corners: "corners",
|
|
101
|
+
off: "off"
|
|
102
|
+
};
|
|
103
|
+
const DEFAULTS = {
|
|
104
|
+
size: SizeMode.fixed,
|
|
105
|
+
minWidth: 100,
|
|
106
|
+
maxWidth: 2e3,
|
|
107
|
+
layout: Layout.auto,
|
|
108
|
+
cover: false,
|
|
109
|
+
startPage: 0,
|
|
110
|
+
flipDuration: 1e3,
|
|
111
|
+
easing: (t) => t,
|
|
112
|
+
shadows: true,
|
|
113
|
+
shadowOpacity: 1,
|
|
114
|
+
autoSize: true,
|
|
115
|
+
click: ClickMode.anywhere,
|
|
116
|
+
drag: true,
|
|
117
|
+
swipe: true,
|
|
118
|
+
swipeDistance: 30,
|
|
119
|
+
hoverCorners: true,
|
|
120
|
+
ignoreDragOn: "a, button, input, textarea, select, [data-opf-no-flip]"
|
|
121
|
+
};
|
|
122
|
+
function isOneOf(vocabulary, value) {
|
|
123
|
+
return Object.values(vocabulary).includes(value);
|
|
124
|
+
}
|
|
125
|
+
/** Fill in defaults and reject options that could only produce a broken book. */
|
|
126
|
+
function resolveOptions(user) {
|
|
127
|
+
const { pages: _pages, ...rest } = user;
|
|
128
|
+
const options = {
|
|
129
|
+
...DEFAULTS,
|
|
130
|
+
...rest
|
|
131
|
+
};
|
|
132
|
+
const positive = (name) => {
|
|
133
|
+
const value = options[name];
|
|
134
|
+
if (!(Number.isFinite(value) && value > 0)) throw new TypeError(`@openpageflip/core: "${name}" must be a positive number, got ${String(value)}`);
|
|
135
|
+
};
|
|
136
|
+
positive("width");
|
|
137
|
+
positive("height");
|
|
138
|
+
positive("flipDuration");
|
|
139
|
+
positive("minWidth");
|
|
140
|
+
positive("maxWidth");
|
|
141
|
+
if (options.maxWidth < options.minWidth) throw new TypeError(`@openpageflip/core: "maxWidth" (${options.maxWidth}) is below "minWidth" (${options.minWidth})`);
|
|
142
|
+
if (!isOneOf(SizeMode, options.size)) throw new TypeError(`@openpageflip/core: unknown "size" ${String(options.size)}`);
|
|
143
|
+
if (!isOneOf(Layout, options.layout)) throw new TypeError(`@openpageflip/core: unknown "layout" ${String(options.layout)}`);
|
|
144
|
+
if (!isOneOf(ClickMode, options.click)) throw new TypeError(`@openpageflip/core: unknown "click" ${String(options.click)}`);
|
|
145
|
+
if (!(options.shadowOpacity >= 0 && options.shadowOpacity <= 1)) throw new TypeError(`@openpageflip/core: "shadowOpacity" must be within 0..1, got ${options.shadowOpacity}`);
|
|
146
|
+
if (!Number.isInteger(options.startPage) || options.startPage < 0) throw new TypeError(`@openpageflip/core: "startPage" must be a non-negative integer, got ${options.startPage}`);
|
|
147
|
+
return options;
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/coords.ts
|
|
151
|
+
/**
|
|
152
|
+
* Three coordinate spaces meet here: the container (where pointer events land), the book rect,
|
|
153
|
+
* and the active page. Page space has its origin at the page's outer top corner with x growing
|
|
154
|
+
* toward the spine, so a backward flip mirrors x.
|
|
155
|
+
*/
|
|
156
|
+
function containerToBook(pos, rect) {
|
|
157
|
+
return {
|
|
158
|
+
x: pos.x - rect.left,
|
|
159
|
+
y: pos.y - rect.top
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function containerToPage(pos, rect, direction) {
|
|
163
|
+
return {
|
|
164
|
+
x: direction === FlipDirection.forward ? pos.x - rect.left - rect.width / 2 : rect.width / 2 - pos.x + rect.left,
|
|
165
|
+
y: pos.y - rect.top
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function pageToContainer(pos, rect, direction) {
|
|
169
|
+
return {
|
|
170
|
+
x: direction === FlipDirection.forward ? pos.x + rect.left + rect.width / 2 : rect.width / 2 - pos.x + rect.left,
|
|
171
|
+
y: pos.y + rect.top
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/geometry/point.ts
|
|
176
|
+
function distance(a, b) {
|
|
177
|
+
return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
|
|
178
|
+
}
|
|
179
|
+
/** Angle between two lines in radians, via the dot product of their normals. */
|
|
180
|
+
function angleBetweenLines(one, two) {
|
|
181
|
+
const a1 = one[0].y - one[1].y;
|
|
182
|
+
const a2 = two[0].y - two[1].y;
|
|
183
|
+
const b1 = one[1].x - one[0].x;
|
|
184
|
+
const b2 = two[1].x - two[0].x;
|
|
185
|
+
return Math.acos((a1 * a2 + b1 * b2) / (Math.sqrt(a1 * a1 + b1 * b1) * Math.sqrt(a2 * a2 + b2 * b2)));
|
|
186
|
+
}
|
|
187
|
+
/** Inclusive containment test. */
|
|
188
|
+
function isPointInRect(rect, point) {
|
|
189
|
+
return point.x >= rect.left && point.x <= rect.left + rect.width && point.y >= rect.top && point.y <= rect.top + rect.height;
|
|
190
|
+
}
|
|
191
|
+
/** Rotate `point` by `angle` radians (clockwise in screen space) and translate by `origin`. */
|
|
192
|
+
function rotatePoint(point, origin, angle) {
|
|
193
|
+
const cos = Math.cos(angle);
|
|
194
|
+
const sin = Math.sin(angle);
|
|
195
|
+
return {
|
|
196
|
+
x: point.x * cos + point.y * sin + origin.x,
|
|
197
|
+
y: point.y * cos - point.x * sin + origin.y
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Keep `point` inside the circle around `center`. Returns the very same object when it is
|
|
202
|
+
* already inside, so callers can detect clamping by identity.
|
|
203
|
+
*
|
|
204
|
+
* Quirk kept from the original: when the point is left of the y axis the whole x result is
|
|
205
|
+
* negated (not mirrored around the center), and a degenerate line falls back to `y = radius`.
|
|
206
|
+
*/
|
|
207
|
+
function clampToCircle(center, radius, point) {
|
|
208
|
+
if (distance(center, point) <= radius) return point;
|
|
209
|
+
const a = center.x;
|
|
210
|
+
const b = center.y;
|
|
211
|
+
const n = point.x;
|
|
212
|
+
const m = point.y;
|
|
213
|
+
let x = Math.sqrt(radius ** 2 * (a - n) ** 2 / ((a - n) ** 2 + (b - m) ** 2)) + a;
|
|
214
|
+
if (point.x < 0) x *= -1;
|
|
215
|
+
let y = (x - a) * (b - m) / (a - n) + b;
|
|
216
|
+
if (a - n + b === 0) y = radius;
|
|
217
|
+
return {
|
|
218
|
+
x,
|
|
219
|
+
y
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/** Two lines are the same line, so they have no single intersection point. */
|
|
223
|
+
const Collinear = Symbol("collinear");
|
|
224
|
+
/**
|
|
225
|
+
* Intersection of two infinite lines: a point, `null` when parallel, or `Collinear` when they
|
|
226
|
+
* coincide. The fold calculation treats `Collinear` as "this pointer position is degenerate".
|
|
227
|
+
*/
|
|
228
|
+
function intersectLines(one, two) {
|
|
229
|
+
const a1 = one[0].y - one[1].y;
|
|
230
|
+
const a2 = two[0].y - two[1].y;
|
|
231
|
+
const b1 = one[1].x - one[0].x;
|
|
232
|
+
const b2 = two[1].x - two[0].x;
|
|
233
|
+
const c1 = one[0].x * one[1].y - one[1].x * one[0].y;
|
|
234
|
+
const c2 = two[0].x * two[1].y - two[1].x * two[0].y;
|
|
235
|
+
const x = -((c1 * b2 - c2 * b1) / (a1 * b2 - a2 * b1));
|
|
236
|
+
const y = -((a1 * c2 - a2 * c1) / (a1 * b2 - a2 * b1));
|
|
237
|
+
if (Number.isFinite(x) && Number.isFinite(y)) return {
|
|
238
|
+
x,
|
|
239
|
+
y
|
|
240
|
+
};
|
|
241
|
+
const det1 = a1 * c2 - a2 * c1;
|
|
242
|
+
const det2 = b1 * c2 - b2 * c1;
|
|
243
|
+
return Math.abs(det1 - det2) < .1 ? Collinear : null;
|
|
244
|
+
}
|
|
245
|
+
/** `intersectLines`, additionally dropping a hit that lands outside `bounds`. */
|
|
246
|
+
function intersectLinesWithin(bounds, one, two) {
|
|
247
|
+
const hit = intersectLines(one, two);
|
|
248
|
+
if (hit === null || hit === Collinear) return hit;
|
|
249
|
+
return isPointInRect(bounds, hit) ? hit : null;
|
|
250
|
+
}
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/geometry/fold.ts
|
|
253
|
+
/**
|
|
254
|
+
* The fold: where a page lands when its corner is dragged to a point.
|
|
255
|
+
*
|
|
256
|
+
* A pure function of the drag point and the page size. This is the kernel that StPageFlip
|
|
257
|
+
* users came for; it derives from `FlipCalculation` (MIT, Oleg Litovski) with the same output
|
|
258
|
+
* for the same input, checked by the parity tests. Differences are deliberate: numbers instead
|
|
259
|
+
* of strings for the page size (so fractional layouts stay exact), a result instead of thrown
|
|
260
|
+
* errors for degenerate positions, and no `null` entries inside clip polygons.
|
|
261
|
+
*/
|
|
262
|
+
/**
|
|
263
|
+
* Returns `null` when the point is degenerate (the corner is at rest, or the fold line would
|
|
264
|
+
* coincide with a page edge). Callers keep the previous fold for that frame, as the original did.
|
|
265
|
+
*/
|
|
266
|
+
function computeFold(input) {
|
|
267
|
+
const { direction, corner, pageWidth, pageHeight } = input;
|
|
268
|
+
const positioned = resolvePosition(input);
|
|
269
|
+
if (positioned === null) return null;
|
|
270
|
+
const { position, angle, rect } = positioned;
|
|
271
|
+
const intersections = intersect(input, position, rect);
|
|
272
|
+
if (intersections === null) return null;
|
|
273
|
+
const { top, side, bottom } = intersections;
|
|
274
|
+
const flippingClip = [rect.topLeft];
|
|
275
|
+
if (top) flippingClip.push(top);
|
|
276
|
+
let clipBottom = false;
|
|
277
|
+
if (side === null) clipBottom = true;
|
|
278
|
+
else flippingClip.push(side);
|
|
279
|
+
if (bottom) flippingClip.push(bottom);
|
|
280
|
+
if (clipBottom || corner === FlipCorner.bottom) flippingClip.push(rect.bottomLeft);
|
|
281
|
+
const bottomClip = [];
|
|
282
|
+
if (top) bottomClip.push(top);
|
|
283
|
+
if (corner === FlipCorner.top) bottomClip.push({
|
|
284
|
+
x: pageWidth,
|
|
285
|
+
y: 0
|
|
286
|
+
});
|
|
287
|
+
else {
|
|
288
|
+
if (top !== null) bottomClip.push({
|
|
289
|
+
x: pageWidth,
|
|
290
|
+
y: 0
|
|
291
|
+
});
|
|
292
|
+
bottomClip.push({
|
|
293
|
+
x: pageWidth,
|
|
294
|
+
y: pageHeight
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
if (side !== null) {
|
|
298
|
+
if (top === null || distance(side, top) >= 10) bottomClip.push(side);
|
|
299
|
+
} else if (corner === FlipCorner.top) bottomClip.push({
|
|
300
|
+
x: pageWidth,
|
|
301
|
+
y: pageHeight
|
|
302
|
+
});
|
|
303
|
+
if (bottom) bottomClip.push(bottom);
|
|
304
|
+
if (top) bottomClip.push(top);
|
|
305
|
+
const shadowStart = corner === FlipCorner.top ? top : side ?? top;
|
|
306
|
+
const shadowEnd = shadowStart !== side && side !== null ? side : bottom;
|
|
307
|
+
let shadow = null;
|
|
308
|
+
if (shadowStart !== null && shadowEnd !== null) {
|
|
309
|
+
const raw = angleBetweenLines([shadowStart, shadowEnd], [{
|
|
310
|
+
x: 0,
|
|
311
|
+
y: 0
|
|
312
|
+
}, {
|
|
313
|
+
x: pageWidth,
|
|
314
|
+
y: 0
|
|
315
|
+
}]);
|
|
316
|
+
shadow = {
|
|
317
|
+
start: shadowStart,
|
|
318
|
+
angle: direction === FlipDirection.forward ? raw : Math.PI - raw
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
angle: direction === FlipDirection.forward ? -angle : angle,
|
|
323
|
+
position,
|
|
324
|
+
progress: Math.abs((position.x - pageWidth) / (2 * pageWidth) * 100),
|
|
325
|
+
rect,
|
|
326
|
+
intersections,
|
|
327
|
+
flippingClip,
|
|
328
|
+
bottomClip,
|
|
329
|
+
activeCorner: direction === FlipDirection.forward ? rect.topLeft : rect.topRight,
|
|
330
|
+
bottomPagePosition: direction === FlipDirection.back ? {
|
|
331
|
+
x: pageWidth,
|
|
332
|
+
y: 0
|
|
333
|
+
} : {
|
|
334
|
+
x: 0,
|
|
335
|
+
y: 0
|
|
336
|
+
},
|
|
337
|
+
shadow
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/** Clamp the drag point to what the paper allows and derive the page rotation from it. */
|
|
341
|
+
function resolvePosition(input) {
|
|
342
|
+
const { corner, pageWidth, pageHeight } = input;
|
|
343
|
+
let position = input.point;
|
|
344
|
+
let geometry = angleAndRect(input, position);
|
|
345
|
+
if (geometry === null) return null;
|
|
346
|
+
const spineNear = corner === FlipCorner.top ? {
|
|
347
|
+
x: 0,
|
|
348
|
+
y: 0
|
|
349
|
+
} : {
|
|
350
|
+
x: 0,
|
|
351
|
+
y: pageHeight
|
|
352
|
+
};
|
|
353
|
+
const spineFar = corner === FlipCorner.top ? {
|
|
354
|
+
x: 0,
|
|
355
|
+
y: pageHeight
|
|
356
|
+
} : {
|
|
357
|
+
x: 0,
|
|
358
|
+
y: 0
|
|
359
|
+
};
|
|
360
|
+
const clamped = clampToCircle(spineNear, pageWidth, position);
|
|
361
|
+
if (clamped !== position) {
|
|
362
|
+
position = clamped;
|
|
363
|
+
geometry = angleAndRect(input, position);
|
|
364
|
+
if (geometry === null) return null;
|
|
365
|
+
}
|
|
366
|
+
const crossed = corner === FlipCorner.top ? geometry.rect.bottomRight : geometry.rect.topRight;
|
|
367
|
+
const opposite = corner === FlipCorner.top ? geometry.rect.topLeft : geometry.rect.bottomLeft;
|
|
368
|
+
if (crossed.x <= 0) {
|
|
369
|
+
position = clampToCircle(spineFar, Math.sqrt(pageWidth ** 2 + pageHeight ** 2), opposite);
|
|
370
|
+
geometry = angleAndRect(input, position);
|
|
371
|
+
if (geometry === null) return null;
|
|
372
|
+
}
|
|
373
|
+
if (Math.abs(position.x - pageWidth) < 1 && Math.abs(position.y) < 1) return null;
|
|
374
|
+
return {
|
|
375
|
+
position,
|
|
376
|
+
...geometry
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function angleAndRect(input, position) {
|
|
380
|
+
const angle = foldAngle(input, position);
|
|
381
|
+
if (angle === null) return null;
|
|
382
|
+
return {
|
|
383
|
+
angle,
|
|
384
|
+
rect: pageRect(input, position, angle)
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
/** Rotation that puts the page corner at `position`, folded around the crease. */
|
|
388
|
+
function foldAngle(input, position) {
|
|
389
|
+
const { corner, pageWidth, pageHeight } = input;
|
|
390
|
+
const left = pageWidth - position.x + 1;
|
|
391
|
+
const top = corner === FlipCorner.bottom ? pageHeight - position.y : position.y;
|
|
392
|
+
let angle = 2 * Math.acos(left / Math.sqrt(top * top + left * left));
|
|
393
|
+
if (top < 0) angle = -angle;
|
|
394
|
+
const flat = Math.PI - angle;
|
|
395
|
+
if (!Number.isFinite(angle) || flat >= 0 && flat < .003) return null;
|
|
396
|
+
return corner === FlipCorner.bottom ? -angle : angle;
|
|
397
|
+
}
|
|
398
|
+
function pageRect(input, position, angle) {
|
|
399
|
+
const { corner, pageWidth, pageHeight } = input;
|
|
400
|
+
const dy = corner === FlipCorner.top ? 0 : -pageHeight;
|
|
401
|
+
return {
|
|
402
|
+
topLeft: rotatePoint({
|
|
403
|
+
x: 0,
|
|
404
|
+
y: dy
|
|
405
|
+
}, position, angle),
|
|
406
|
+
topRight: rotatePoint({
|
|
407
|
+
x: pageWidth,
|
|
408
|
+
y: dy
|
|
409
|
+
}, position, angle),
|
|
410
|
+
bottomLeft: rotatePoint({
|
|
411
|
+
x: 0,
|
|
412
|
+
y: dy + pageHeight
|
|
413
|
+
}, position, angle),
|
|
414
|
+
bottomRight: rotatePoint({
|
|
415
|
+
x: pageWidth,
|
|
416
|
+
y: dy + pageHeight
|
|
417
|
+
}, position, angle)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/** Where the fold line and the page's far edge cross the page borders. `null` when degenerate. */
|
|
421
|
+
function intersect(input, position, rect) {
|
|
422
|
+
const { corner, pageWidth, pageHeight } = input;
|
|
423
|
+
const bounds = {
|
|
424
|
+
left: -1,
|
|
425
|
+
top: -1,
|
|
426
|
+
width: pageWidth + 2,
|
|
427
|
+
height: pageHeight + 2
|
|
428
|
+
};
|
|
429
|
+
const topEdge = [{
|
|
430
|
+
x: 0,
|
|
431
|
+
y: 0
|
|
432
|
+
}, {
|
|
433
|
+
x: pageWidth,
|
|
434
|
+
y: 0
|
|
435
|
+
}];
|
|
436
|
+
const rightEdge = [{
|
|
437
|
+
x: pageWidth,
|
|
438
|
+
y: 0
|
|
439
|
+
}, {
|
|
440
|
+
x: pageWidth,
|
|
441
|
+
y: pageHeight
|
|
442
|
+
}];
|
|
443
|
+
const bottomEdge = [{
|
|
444
|
+
x: 0,
|
|
445
|
+
y: pageHeight
|
|
446
|
+
}, {
|
|
447
|
+
x: pageWidth,
|
|
448
|
+
y: pageHeight
|
|
449
|
+
}];
|
|
450
|
+
const top = corner === FlipCorner.top ? intersectLinesWithin(bounds, [position, rect.topRight], topEdge) : intersectLinesWithin(bounds, [rect.topLeft, rect.topRight], topEdge);
|
|
451
|
+
const side = corner === FlipCorner.top ? intersectLinesWithin(bounds, [position, rect.bottomLeft], rightEdge) : intersectLinesWithin(bounds, [position, rect.topLeft], rightEdge);
|
|
452
|
+
const bottom = intersectLinesWithin(bounds, [rect.bottomLeft, rect.bottomRight], bottomEdge);
|
|
453
|
+
if (top === Collinear || side === Collinear || bottom === Collinear) return null;
|
|
454
|
+
return {
|
|
455
|
+
top,
|
|
456
|
+
side,
|
|
457
|
+
bottom
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region src/pagination.ts
|
|
462
|
+
function buildSpreads(pageCount, orientation, cover) {
|
|
463
|
+
const hardByPosition = /* @__PURE__ */ new Set();
|
|
464
|
+
const landscape = [];
|
|
465
|
+
let start = 0;
|
|
466
|
+
if (cover && pageCount > 0) {
|
|
467
|
+
hardByPosition.add(0);
|
|
468
|
+
landscape.push([0]);
|
|
469
|
+
start = 1;
|
|
470
|
+
}
|
|
471
|
+
for (let i = start; i < pageCount; i += 2) if (i < pageCount - 1) landscape.push([i, i + 1]);
|
|
472
|
+
else {
|
|
473
|
+
landscape.push([i]);
|
|
474
|
+
hardByPosition.add(i);
|
|
475
|
+
}
|
|
476
|
+
const portrait = Array.from({ length: pageCount }, (_, i) => [i]);
|
|
477
|
+
return {
|
|
478
|
+
spreads: orientation === Orientation.portrait ? portrait : landscape,
|
|
479
|
+
hardByPosition
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function spreadIndexOfPage(spreads, page) {
|
|
483
|
+
const index = spreads.findIndex((spread) => spread[0] === page || spread[1] === page);
|
|
484
|
+
return index === -1 ? null : index;
|
|
485
|
+
}
|
|
486
|
+
/** Pages lying flat on the left and right for a spread. */
|
|
487
|
+
function staticPages(spreads, orientation, spreadIndex, pageCount) {
|
|
488
|
+
const spread = spreads[spreadIndex];
|
|
489
|
+
if (spread === void 0) return {
|
|
490
|
+
left: null,
|
|
491
|
+
right: null
|
|
492
|
+
};
|
|
493
|
+
if (spread.length === 2) return {
|
|
494
|
+
left: spread[0],
|
|
495
|
+
right: spread[1]
|
|
496
|
+
};
|
|
497
|
+
if (orientation === Orientation.landscape && spread[0] === pageCount - 1) return {
|
|
498
|
+
left: spread[0],
|
|
499
|
+
right: null
|
|
500
|
+
};
|
|
501
|
+
return {
|
|
502
|
+
left: null,
|
|
503
|
+
right: spread[0]
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The page that lifts (its back face is what the viewer sees mid-flip) and the page revealed
|
|
508
|
+
* underneath it. `null` when there is no spread in that direction.
|
|
509
|
+
*/
|
|
510
|
+
function flipPages(spreads, orientation, spreadIndex, direction) {
|
|
511
|
+
const forward = direction === FlipDirection.forward;
|
|
512
|
+
if (orientation === Orientation.portrait) {
|
|
513
|
+
const current = spreads[spreadIndex]?.[0];
|
|
514
|
+
const other = spreads[forward ? spreadIndex + 1 : spreadIndex - 1]?.[0];
|
|
515
|
+
if (current === void 0 || other === void 0) return null;
|
|
516
|
+
return forward ? {
|
|
517
|
+
flipping: current,
|
|
518
|
+
bottom: other
|
|
519
|
+
} : {
|
|
520
|
+
flipping: other,
|
|
521
|
+
bottom: other
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const target = spreads[forward ? spreadIndex + 1 : spreadIndex - 1];
|
|
525
|
+
if (target === void 0) return null;
|
|
526
|
+
if (target.length === 1) return {
|
|
527
|
+
flipping: target[0],
|
|
528
|
+
bottom: target[0]
|
|
529
|
+
};
|
|
530
|
+
return forward ? {
|
|
531
|
+
flipping: target[0],
|
|
532
|
+
bottom: target[1]
|
|
533
|
+
} : {
|
|
534
|
+
flipping: target[1],
|
|
535
|
+
bottom: target[0]
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
//#endregion
|
|
539
|
+
//#region src/controller.ts
|
|
540
|
+
/**
|
|
541
|
+
* The headless heart of the book: which spread is open, what a pointer is doing to a corner,
|
|
542
|
+
* and where the flip animation is. It knows nothing about the DOM; it hands `Frame`s to a
|
|
543
|
+
* renderer. The behaviour (corner detection, hover fold, drop thresholds, animation paths)
|
|
544
|
+
* is the original's, so the book feels the same.
|
|
545
|
+
*/
|
|
546
|
+
/** Pointer travel before a press counts as a drag rather than a click. */
|
|
547
|
+
const DRAG_THRESHOLD = 5;
|
|
548
|
+
/** How far a hovered corner lifts. */
|
|
549
|
+
const HOVER_LIFT = 50;
|
|
550
|
+
/** Animation paths longer than this take the full `flipDuration`; shorter ones scale down. */
|
|
551
|
+
const FULL_FLIP_LENGTH = 1e3;
|
|
552
|
+
var FlipController = class {
|
|
553
|
+
pages;
|
|
554
|
+
spreads = [];
|
|
555
|
+
spreadIndex = 0;
|
|
556
|
+
currentPage = 0;
|
|
557
|
+
orientation;
|
|
558
|
+
rect;
|
|
559
|
+
left = null;
|
|
560
|
+
right = null;
|
|
561
|
+
state = FlipState.read;
|
|
562
|
+
session = null;
|
|
563
|
+
tween = null;
|
|
564
|
+
/** Settles the promise of the running animation when it is cut short. */
|
|
565
|
+
settleTween = null;
|
|
566
|
+
pressStart = null;
|
|
567
|
+
dragged = false;
|
|
568
|
+
options;
|
|
569
|
+
clock;
|
|
570
|
+
hooks;
|
|
571
|
+
constructor(options, clock, hooks, pages, layout) {
|
|
572
|
+
this.options = options;
|
|
573
|
+
this.clock = clock;
|
|
574
|
+
this.hooks = hooks;
|
|
575
|
+
this.pages = pages;
|
|
576
|
+
this.orientation = layout.orientation;
|
|
577
|
+
this.rect = layout.rect;
|
|
578
|
+
this.rebuildSpreads();
|
|
579
|
+
}
|
|
580
|
+
get page() {
|
|
581
|
+
return this.currentPage;
|
|
582
|
+
}
|
|
583
|
+
get pageCount() {
|
|
584
|
+
return this.pages.length;
|
|
585
|
+
}
|
|
586
|
+
get currentState() {
|
|
587
|
+
return this.state;
|
|
588
|
+
}
|
|
589
|
+
get currentOrientation() {
|
|
590
|
+
return this.orientation;
|
|
591
|
+
}
|
|
592
|
+
get bookRect() {
|
|
593
|
+
return this.rect;
|
|
594
|
+
}
|
|
595
|
+
setPages(pages) {
|
|
596
|
+
this.endSession();
|
|
597
|
+
this.pages = pages;
|
|
598
|
+
this.rebuildSpreads();
|
|
599
|
+
this.showPage(Math.min(this.currentPage, Math.max(0, pages.length - 1)));
|
|
600
|
+
}
|
|
601
|
+
/** Returns true when the orientation changed, which re-paginates the book. */
|
|
602
|
+
setLayout(layout) {
|
|
603
|
+
this.rect = layout.rect;
|
|
604
|
+
const orientationChanged = layout.orientation !== this.orientation;
|
|
605
|
+
if (orientationChanged) {
|
|
606
|
+
this.endSession();
|
|
607
|
+
this.orientation = layout.orientation;
|
|
608
|
+
this.rebuildSpreads();
|
|
609
|
+
}
|
|
610
|
+
this.showPage(this.currentPage);
|
|
611
|
+
return orientationChanged;
|
|
612
|
+
}
|
|
613
|
+
rebuildSpreads() {
|
|
614
|
+
const { spreads, hardByPosition } = buildSpreads(this.pages.length, this.orientation, this.options.cover);
|
|
615
|
+
this.spreads = spreads;
|
|
616
|
+
for (const [index, page] of this.pages.entries()) if (hardByPosition.has(index)) {
|
|
617
|
+
page.density = PageDensity.hard;
|
|
618
|
+
page.drawingDensity = PageDensity.hard;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
showPage(page) {
|
|
622
|
+
const index = spreadIndexOfPage(this.spreads, page);
|
|
623
|
+
if (index === null) return;
|
|
624
|
+
this.spreadIndex = index;
|
|
625
|
+
this.showSpread();
|
|
626
|
+
}
|
|
627
|
+
showNext() {
|
|
628
|
+
if (this.spreadIndex < this.spreads.length - 1) {
|
|
629
|
+
this.spreadIndex++;
|
|
630
|
+
this.showSpread();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
showPrev() {
|
|
634
|
+
if (this.spreadIndex > 0) {
|
|
635
|
+
this.spreadIndex--;
|
|
636
|
+
this.showSpread();
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
showSpread() {
|
|
640
|
+
const { left, right } = staticPages(this.spreads, this.orientation, this.spreadIndex, this.pages.length);
|
|
641
|
+
this.left = left;
|
|
642
|
+
this.right = right;
|
|
643
|
+
const spread = this.spreads[this.spreadIndex];
|
|
644
|
+
const page = spread === void 0 ? this.currentPage : spread[0];
|
|
645
|
+
const changed = page !== this.currentPage;
|
|
646
|
+
this.currentPage = page;
|
|
647
|
+
this.render();
|
|
648
|
+
if (changed) this.hooks.onPage(page);
|
|
649
|
+
}
|
|
650
|
+
flipNext(corner) {
|
|
651
|
+
return this.flipFrom({
|
|
652
|
+
x: this.rect.left + this.rect.pageWidth * 2 - 10,
|
|
653
|
+
y: corner === FlipCorner.top ? 1 : this.rect.height - 2
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
flipPrev(corner) {
|
|
657
|
+
return this.flipFrom({
|
|
658
|
+
x: this.rect.left + 10,
|
|
659
|
+
y: corner === FlipCorner.top ? 1 : this.rect.height - 2
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Jumps to the spread beside the target without animation, then animates the last turn.
|
|
664
|
+
* The static pages keep showing the current spread until that turn lands.
|
|
665
|
+
*/
|
|
666
|
+
flipTo(page, corner) {
|
|
667
|
+
const target = spreadIndexOfPage(this.spreads, page);
|
|
668
|
+
if (target === null || target === this.spreadIndex) return Promise.resolve(false);
|
|
669
|
+
if (target > this.spreadIndex) {
|
|
670
|
+
this.spreadIndex = target - 1;
|
|
671
|
+
this.syncCurrentPage();
|
|
672
|
+
return this.flipNext(corner);
|
|
673
|
+
}
|
|
674
|
+
this.spreadIndex = target + 1;
|
|
675
|
+
this.syncCurrentPage();
|
|
676
|
+
return this.flipPrev(corner);
|
|
677
|
+
}
|
|
678
|
+
syncCurrentPage() {
|
|
679
|
+
const spread = this.spreads[this.spreadIndex];
|
|
680
|
+
if (spread !== void 0) this.currentPage = spread[0];
|
|
681
|
+
}
|
|
682
|
+
/** Full animated flip starting at a container point, as a click would. */
|
|
683
|
+
flipFrom(containerPos) {
|
|
684
|
+
if (this.session !== null) this.tween?.finish();
|
|
685
|
+
const session = this.start(containerPos);
|
|
686
|
+
if (session === null) return Promise.resolve(false);
|
|
687
|
+
this.setState(FlipState.flipping);
|
|
688
|
+
const { pageWidth, pageHeight } = session;
|
|
689
|
+
const margin = pageHeight / 10;
|
|
690
|
+
const yStart = session.corner === FlipCorner.bottom ? pageHeight - margin : margin;
|
|
691
|
+
const yDest = session.corner === FlipCorner.bottom ? pageHeight : 0;
|
|
692
|
+
const from = {
|
|
693
|
+
x: pageWidth - margin,
|
|
694
|
+
y: yStart
|
|
695
|
+
};
|
|
696
|
+
this.applyFold(from);
|
|
697
|
+
return this.animateTo(from, {
|
|
698
|
+
x: -pageWidth,
|
|
699
|
+
y: yDest
|
|
700
|
+
}, true, true);
|
|
701
|
+
}
|
|
702
|
+
/** Let go of a dragged corner: complete the turn if it crossed the spine, otherwise drop it back. */
|
|
703
|
+
release() {
|
|
704
|
+
const session = this.session;
|
|
705
|
+
if (session === null || session.fold === null) return Promise.resolve(false);
|
|
706
|
+
const pos = session.fold.position;
|
|
707
|
+
const y = session.corner === FlipCorner.bottom ? session.pageHeight : 0;
|
|
708
|
+
return pos.x <= 0 ? this.animateTo(pos, {
|
|
709
|
+
x: -session.pageWidth,
|
|
710
|
+
y
|
|
711
|
+
}, true, true) : this.animateTo(pos, {
|
|
712
|
+
x: session.pageWidth,
|
|
713
|
+
y
|
|
714
|
+
}, false, true);
|
|
715
|
+
}
|
|
716
|
+
/** Resolves with whether the page turned. A cancelled animation resolves with `false`. */
|
|
717
|
+
animateTo(from, to, turn, reset) {
|
|
718
|
+
this.tween?.finish();
|
|
719
|
+
const dx = to.x - from.x;
|
|
720
|
+
const dy = to.y - from.y;
|
|
721
|
+
const duration = Math.min(1, Math.max(Math.abs(dx), Math.abs(dy)) / FULL_FLIP_LENGTH) * this.options.flipDuration;
|
|
722
|
+
return new Promise((resolve) => {
|
|
723
|
+
this.settleTween = resolve;
|
|
724
|
+
this.tween = startTween(this.clock, {
|
|
725
|
+
duration,
|
|
726
|
+
easing: this.options.easing,
|
|
727
|
+
onFrame: (t) => this.applyFold({
|
|
728
|
+
x: from.x + dx * t,
|
|
729
|
+
y: from.y + dy * t
|
|
730
|
+
}),
|
|
731
|
+
onEnd: () => {
|
|
732
|
+
this.tween = null;
|
|
733
|
+
this.settleTween = null;
|
|
734
|
+
const session = this.session;
|
|
735
|
+
if (session === null) {
|
|
736
|
+
resolve(false);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (turn) {
|
|
740
|
+
if (session.direction === FlipDirection.back) this.showPrev();
|
|
741
|
+
else this.showNext();
|
|
742
|
+
}
|
|
743
|
+
if (reset) {
|
|
744
|
+
this.endSession();
|
|
745
|
+
this.setState(FlipState.read);
|
|
746
|
+
this.render();
|
|
747
|
+
}
|
|
748
|
+
resolve(turn);
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
/** Mouse moving over the book without a button down. */
|
|
754
|
+
hover(containerPos) {
|
|
755
|
+
if (this.state !== FlipState.read && this.state !== FlipState.foldCorner) return;
|
|
756
|
+
const { pageWidth, height } = this.rect;
|
|
757
|
+
if (!this.isOnCorner(containerPos)) {
|
|
758
|
+
if (this.session === null) return;
|
|
759
|
+
this.setState(FlipState.read);
|
|
760
|
+
this.tween?.finish();
|
|
761
|
+
this.release();
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (this.session !== null) {
|
|
765
|
+
this.applyFold(containerToPage(containerPos, this.rect, this.session.direction));
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
const session = this.start(containerPos);
|
|
769
|
+
if (session === null) return;
|
|
770
|
+
this.setState(FlipState.foldCorner);
|
|
771
|
+
this.applyFold({
|
|
772
|
+
x: pageWidth - 1,
|
|
773
|
+
y: 1
|
|
774
|
+
});
|
|
775
|
+
const yStart = session.corner === FlipCorner.bottom ? height - 1 : 1;
|
|
776
|
+
const yDest = session.corner === FlipCorner.bottom ? height - HOVER_LIFT : HOVER_LIFT;
|
|
777
|
+
this.animateTo({
|
|
778
|
+
x: pageWidth - 1,
|
|
779
|
+
y: yStart
|
|
780
|
+
}, {
|
|
781
|
+
x: pageWidth - HOVER_LIFT,
|
|
782
|
+
y: yDest
|
|
783
|
+
}, false, false);
|
|
784
|
+
}
|
|
785
|
+
/** The mouse left the book: drop any hovered corner. */
|
|
786
|
+
hoverEnd() {
|
|
787
|
+
if (this.state !== FlipState.foldCorner) return;
|
|
788
|
+
this.setState(FlipState.read);
|
|
789
|
+
this.tween?.finish();
|
|
790
|
+
this.release();
|
|
791
|
+
}
|
|
792
|
+
pointerDown(containerPos) {
|
|
793
|
+
this.pressStart = containerPos;
|
|
794
|
+
this.dragged = false;
|
|
795
|
+
}
|
|
796
|
+
/** A pressed pointer moved. Starts a drag once it travels past the click threshold. */
|
|
797
|
+
pointerDrag(containerPos) {
|
|
798
|
+
if (this.pressStart === null || !this.options.drag) return;
|
|
799
|
+
if (!this.dragged && distance(this.pressStart, containerPos) <= DRAG_THRESHOLD) return;
|
|
800
|
+
this.dragged = true;
|
|
801
|
+
const session = this.session ?? this.start(this.pressStart);
|
|
802
|
+
if (session === null) return;
|
|
803
|
+
this.setState(FlipState.userFold);
|
|
804
|
+
this.applyFold(containerToPage(containerPos, this.rect, session.direction));
|
|
805
|
+
}
|
|
806
|
+
/** The pointer was released. A press without a drag is a click. */
|
|
807
|
+
pointerUp(containerPos) {
|
|
808
|
+
if (this.pressStart === null) return;
|
|
809
|
+
this.pressStart = null;
|
|
810
|
+
if (this.dragged) {
|
|
811
|
+
this.release();
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
this.click(containerPos);
|
|
815
|
+
}
|
|
816
|
+
/** The browser took the pointer (a scroll, for instance): drop the corner, no click. */
|
|
817
|
+
pointerCancel() {
|
|
818
|
+
if (this.pressStart === null) return;
|
|
819
|
+
this.pressStart = null;
|
|
820
|
+
if (this.dragged) this.release();
|
|
821
|
+
}
|
|
822
|
+
/** A quick horizontal swipe: turn the page the swipe points at. */
|
|
823
|
+
swipe(direction, corner) {
|
|
824
|
+
this.pressStart = null;
|
|
825
|
+
const session = this.session;
|
|
826
|
+
if (session !== null && session.fold !== null) {
|
|
827
|
+
if (session.direction !== direction) return this.release();
|
|
828
|
+
const y = corner === FlipCorner.bottom ? session.pageHeight : 0;
|
|
829
|
+
return this.animateTo(session.fold.position, {
|
|
830
|
+
x: -session.pageWidth,
|
|
831
|
+
y
|
|
832
|
+
}, true, true);
|
|
833
|
+
}
|
|
834
|
+
return direction === FlipDirection.forward ? this.flipNext(corner) : this.flipPrev(corner);
|
|
835
|
+
}
|
|
836
|
+
click(containerPos) {
|
|
837
|
+
if (this.options.click === ClickMode.off) return;
|
|
838
|
+
if (this.options.click === ClickMode.corners && !this.isOnCorner(containerPos)) return;
|
|
839
|
+
this.flipFrom(containerPos);
|
|
840
|
+
}
|
|
841
|
+
/** Decide direction and corner from where the pointer is, and pick the pages that move. */
|
|
842
|
+
start(containerPos) {
|
|
843
|
+
this.endSession();
|
|
844
|
+
const bookPos = containerToBook(containerPos, this.rect);
|
|
845
|
+
const direction = this.directionAt(bookPos);
|
|
846
|
+
const corner = bookPos.y >= this.rect.height / 2 ? FlipCorner.bottom : FlipCorner.top;
|
|
847
|
+
if (!(direction === FlipDirection.forward ? this.currentPage < this.pages.length - 1 : this.currentPage >= 1)) return null;
|
|
848
|
+
const pair = flipPages(this.spreads, this.orientation, this.spreadIndex, direction);
|
|
849
|
+
if (pair === null) return null;
|
|
850
|
+
if (this.orientation === Orientation.landscape) {
|
|
851
|
+
const flipping = this.pages[pair.flipping];
|
|
852
|
+
const neighbour = this.pages[direction === FlipDirection.back ? pair.flipping + 1 : pair.flipping - 1];
|
|
853
|
+
if (flipping !== void 0 && neighbour !== void 0 && flipping.density !== neighbour.density) {
|
|
854
|
+
flipping.drawingDensity = PageDensity.hard;
|
|
855
|
+
neighbour.drawingDensity = PageDensity.hard;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
this.session = {
|
|
859
|
+
direction,
|
|
860
|
+
corner,
|
|
861
|
+
flipping: pair.flipping,
|
|
862
|
+
bottom: pair.bottom,
|
|
863
|
+
pageWidth: this.rect.pageWidth,
|
|
864
|
+
pageHeight: this.rect.height,
|
|
865
|
+
fold: null,
|
|
866
|
+
progress: 0,
|
|
867
|
+
hardAngle: 0,
|
|
868
|
+
shadow: null
|
|
869
|
+
};
|
|
870
|
+
return this.session;
|
|
871
|
+
}
|
|
872
|
+
endSession() {
|
|
873
|
+
this.tween?.cancel();
|
|
874
|
+
this.tween = null;
|
|
875
|
+
this.settleTween?.(false);
|
|
876
|
+
this.settleTween = null;
|
|
877
|
+
this.session = null;
|
|
878
|
+
for (const page of this.pages) page.drawingDensity = page.density;
|
|
879
|
+
}
|
|
880
|
+
/** Move the lifted corner to a page-space point. Degenerate points keep the previous fold. */
|
|
881
|
+
applyFold(pagePos) {
|
|
882
|
+
const session = this.session;
|
|
883
|
+
if (session === null) return;
|
|
884
|
+
const fold = computeFold({
|
|
885
|
+
direction: session.direction,
|
|
886
|
+
corner: session.corner,
|
|
887
|
+
pageWidth: session.pageWidth,
|
|
888
|
+
pageHeight: session.pageHeight,
|
|
889
|
+
point: pagePos
|
|
890
|
+
});
|
|
891
|
+
if (fold === null) return;
|
|
892
|
+
const { progress } = fold;
|
|
893
|
+
session.fold = fold;
|
|
894
|
+
session.progress = progress;
|
|
895
|
+
session.hardAngle = (session.direction === FlipDirection.forward ? 90 : -90) * ((200 - progress * 2) / 100);
|
|
896
|
+
session.shadow = this.options.shadows && fold.shadow !== null ? {
|
|
897
|
+
pos: fold.shadow.start,
|
|
898
|
+
angle: fold.shadow.angle,
|
|
899
|
+
width: session.pageWidth * 3 / 4 * (progress / 100),
|
|
900
|
+
opacity: (100 - progress) * (100 * this.options.shadowOpacity) / 100 / 100,
|
|
901
|
+
direction: session.direction,
|
|
902
|
+
progress: progress * 2
|
|
903
|
+
} : null;
|
|
904
|
+
this.render();
|
|
905
|
+
}
|
|
906
|
+
directionAt(bookPos) {
|
|
907
|
+
if (this.orientation === Orientation.portrait) return bookPos.x - this.rect.pageWidth <= this.rect.width / 5 ? FlipDirection.back : FlipDirection.forward;
|
|
908
|
+
return bookPos.x < this.rect.width / 2 ? FlipDirection.back : FlipDirection.forward;
|
|
909
|
+
}
|
|
910
|
+
isOnCorner(containerPos) {
|
|
911
|
+
const { pageWidth, height, width } = this.rect;
|
|
912
|
+
const reach = Math.sqrt(pageWidth ** 2 + height ** 2) / 5;
|
|
913
|
+
const p = containerToBook(containerPos, this.rect);
|
|
914
|
+
return p.x > 0 && p.y > 0 && p.x < width && p.y < height && (p.x < reach || p.x > width - reach) && (p.y < reach || p.y > height - reach);
|
|
915
|
+
}
|
|
916
|
+
setState(state) {
|
|
917
|
+
if (this.state === state) return;
|
|
918
|
+
this.state = state;
|
|
919
|
+
this.hooks.onState(state);
|
|
920
|
+
}
|
|
921
|
+
frame() {
|
|
922
|
+
const session = this.session;
|
|
923
|
+
return {
|
|
924
|
+
rect: this.rect,
|
|
925
|
+
orientation: this.orientation,
|
|
926
|
+
left: this.left,
|
|
927
|
+
right: this.right,
|
|
928
|
+
flip: session !== null && session.fold !== null ? {
|
|
929
|
+
direction: session.direction,
|
|
930
|
+
corner: session.corner,
|
|
931
|
+
flipping: session.flipping,
|
|
932
|
+
bottom: session.bottom,
|
|
933
|
+
fold: session.fold,
|
|
934
|
+
progress: session.progress,
|
|
935
|
+
hardAngle: session.hardAngle,
|
|
936
|
+
shadow: session.shadow
|
|
937
|
+
} : null
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
render() {
|
|
941
|
+
this.hooks.onFrame(this.frame());
|
|
942
|
+
}
|
|
943
|
+
destroy() {
|
|
944
|
+
this.endSession();
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
//#endregion
|
|
948
|
+
//#region src/events.ts
|
|
949
|
+
/** Minimal typed event emitter. `on` returns the unsubscribe function. */
|
|
950
|
+
function createEmitter() {
|
|
951
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
952
|
+
const off = (name, listener) => {
|
|
953
|
+
listeners.get(name)?.delete(listener);
|
|
954
|
+
};
|
|
955
|
+
return {
|
|
956
|
+
on(name, listener, options) {
|
|
957
|
+
let set = listeners.get(name);
|
|
958
|
+
if (set === void 0) {
|
|
959
|
+
set = /* @__PURE__ */ new Set();
|
|
960
|
+
listeners.set(name, set);
|
|
961
|
+
}
|
|
962
|
+
set.add(listener);
|
|
963
|
+
const unsubscribe = () => off(name, listener);
|
|
964
|
+
options?.signal?.addEventListener("abort", unsubscribe, { once: true });
|
|
965
|
+
return unsubscribe;
|
|
966
|
+
},
|
|
967
|
+
off,
|
|
968
|
+
emit(name, event) {
|
|
969
|
+
const set = listeners.get(name);
|
|
970
|
+
if (set === void 0) return;
|
|
971
|
+
for (const listener of [...set]) listener(event);
|
|
972
|
+
},
|
|
973
|
+
clear() {
|
|
974
|
+
listeners.clear();
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
//#endregion
|
|
979
|
+
//#region src/input.ts
|
|
980
|
+
/** A press shorter than this that travels `swipeDistance` is a swipe rather than a drag. */
|
|
981
|
+
const SWIPE_TIMEOUT = 250;
|
|
982
|
+
function attachInput(container, controller, options) {
|
|
983
|
+
let press = null;
|
|
984
|
+
const local = (event) => {
|
|
985
|
+
const bounds = container.getBoundingClientRect();
|
|
986
|
+
return {
|
|
987
|
+
x: event.clientX - bounds.left,
|
|
988
|
+
y: event.clientY - bounds.top
|
|
989
|
+
};
|
|
990
|
+
};
|
|
991
|
+
const onDown = (event) => {
|
|
992
|
+
if (press !== null) return;
|
|
993
|
+
if (event.pointerType === "mouse" && event.button !== 0) return;
|
|
994
|
+
if (event.target instanceof Element && event.target.closest(options.ignoreDragOn) !== null) return;
|
|
995
|
+
const start = local(event);
|
|
996
|
+
press = {
|
|
997
|
+
id: event.pointerId,
|
|
998
|
+
start,
|
|
999
|
+
startedAt: event.timeStamp
|
|
1000
|
+
};
|
|
1001
|
+
try {
|
|
1002
|
+
container.setPointerCapture(event.pointerId);
|
|
1003
|
+
} catch {}
|
|
1004
|
+
controller.pointerDown(start);
|
|
1005
|
+
if (event.pointerType === "mouse") event.preventDefault();
|
|
1006
|
+
};
|
|
1007
|
+
const onMove = (event) => {
|
|
1008
|
+
if (press !== null) {
|
|
1009
|
+
if (event.pointerId === press.id) controller.pointerDrag(local(event));
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (event.pointerType === "mouse" && options.hoverCorners) controller.hover(local(event));
|
|
1013
|
+
};
|
|
1014
|
+
const onUp = (event) => {
|
|
1015
|
+
if (press === null || event.pointerId !== press.id) return;
|
|
1016
|
+
const { start, startedAt } = press;
|
|
1017
|
+
press = null;
|
|
1018
|
+
const end = local(event);
|
|
1019
|
+
const dx = end.x - start.x;
|
|
1020
|
+
const dy = end.y - start.y;
|
|
1021
|
+
const quick = event.timeStamp - startedAt < SWIPE_TIMEOUT;
|
|
1022
|
+
if (options.swipe && quick && Math.abs(dx) > options.swipeDistance && Math.abs(dy) < options.swipeDistance * 2) {
|
|
1023
|
+
const rect = controller.bookRect;
|
|
1024
|
+
const corner = start.y - rect.top < rect.height / 2 ? FlipCorner.top : FlipCorner.bottom;
|
|
1025
|
+
controller.swipe(dx > 0 ? FlipDirection.back : FlipDirection.forward, corner);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
controller.pointerUp(end);
|
|
1029
|
+
};
|
|
1030
|
+
const onCancel = (event) => {
|
|
1031
|
+
if (press === null || event.pointerId !== press.id) return;
|
|
1032
|
+
press = null;
|
|
1033
|
+
controller.pointerCancel();
|
|
1034
|
+
};
|
|
1035
|
+
const onLeave = (event) => {
|
|
1036
|
+
if (press === null && event.pointerType === "mouse") controller.hoverEnd();
|
|
1037
|
+
};
|
|
1038
|
+
container.addEventListener("pointerdown", onDown);
|
|
1039
|
+
container.addEventListener("pointermove", onMove, { passive: true });
|
|
1040
|
+
container.addEventListener("pointerup", onUp);
|
|
1041
|
+
container.addEventListener("pointercancel", onCancel);
|
|
1042
|
+
container.addEventListener("pointerleave", onLeave);
|
|
1043
|
+
return () => {
|
|
1044
|
+
container.removeEventListener("pointerdown", onDown);
|
|
1045
|
+
container.removeEventListener("pointermove", onMove);
|
|
1046
|
+
container.removeEventListener("pointerup", onUp);
|
|
1047
|
+
container.removeEventListener("pointercancel", onCancel);
|
|
1048
|
+
container.removeEventListener("pointerleave", onLeave);
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
//#endregion
|
|
1052
|
+
//#region src/layout.ts
|
|
1053
|
+
/**
|
|
1054
|
+
* Page size and orientation for a container. Same arithmetic as the original, so the book lands
|
|
1055
|
+
* on the same pixels; `layout` only overrides the "is the container too narrow" decision.
|
|
1056
|
+
*/
|
|
1057
|
+
function computeLayout(containerWidth, containerHeight, options) {
|
|
1058
|
+
const middle = {
|
|
1059
|
+
x: containerWidth / 2,
|
|
1060
|
+
y: containerHeight / 2
|
|
1061
|
+
};
|
|
1062
|
+
const ratio = options.width / options.height;
|
|
1063
|
+
const portraitIf = (narrow) => options.layout === Layout.single || options.layout === Layout.auto && narrow ? Orientation.portrait : Orientation.landscape;
|
|
1064
|
+
let orientation;
|
|
1065
|
+
let pageWidth = options.width;
|
|
1066
|
+
let pageHeight = options.height;
|
|
1067
|
+
if (options.size === SizeMode.stretch) {
|
|
1068
|
+
orientation = portraitIf(containerWidth < options.minWidth * 2);
|
|
1069
|
+
pageWidth = orientation === Orientation.portrait ? containerWidth : containerWidth / 2;
|
|
1070
|
+
if (pageWidth > options.maxWidth) pageWidth = options.maxWidth;
|
|
1071
|
+
pageHeight = pageWidth / ratio;
|
|
1072
|
+
if (pageHeight > containerHeight) {
|
|
1073
|
+
pageHeight = containerHeight;
|
|
1074
|
+
pageWidth = pageHeight * ratio;
|
|
1075
|
+
}
|
|
1076
|
+
} else orientation = portraitIf(containerWidth < pageWidth * 2);
|
|
1077
|
+
const left = orientation === Orientation.portrait ? middle.x - pageWidth / 2 - pageWidth : middle.x - pageWidth;
|
|
1078
|
+
return {
|
|
1079
|
+
orientation,
|
|
1080
|
+
rect: {
|
|
1081
|
+
left,
|
|
1082
|
+
top: middle.y - pageHeight / 2,
|
|
1083
|
+
width: pageWidth * 2,
|
|
1084
|
+
height: pageHeight,
|
|
1085
|
+
pageWidth
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
//#endregion
|
|
1090
|
+
//#region src/pages.ts
|
|
1091
|
+
function createPages(elements, hardByPosition) {
|
|
1092
|
+
return elements.map((element, index) => {
|
|
1093
|
+
const density = hardByPosition.has(index) || element.dataset["density"] === PageDensity.hard ? PageDensity.hard : PageDensity.soft;
|
|
1094
|
+
return {
|
|
1095
|
+
element,
|
|
1096
|
+
density,
|
|
1097
|
+
drawingDensity: density
|
|
1098
|
+
};
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
//#endregion
|
|
1102
|
+
//#region src/render/dom.ts
|
|
1103
|
+
const Z = {
|
|
1104
|
+
flat: 1,
|
|
1105
|
+
bottom: 3,
|
|
1106
|
+
hardShadow: 4,
|
|
1107
|
+
flipping: 5,
|
|
1108
|
+
hardInnerShadow: 5,
|
|
1109
|
+
shadow: 10
|
|
1110
|
+
};
|
|
1111
|
+
const CLASS = {
|
|
1112
|
+
book: "opf-book",
|
|
1113
|
+
page: "opf-page",
|
|
1114
|
+
left: "opf-page--left",
|
|
1115
|
+
right: "opf-page--right",
|
|
1116
|
+
flat: "opf-page--flat",
|
|
1117
|
+
soft: "opf-page--soft",
|
|
1118
|
+
hard: "opf-page--hard",
|
|
1119
|
+
shadow: "opf-shadow"
|
|
1120
|
+
};
|
|
1121
|
+
/**
|
|
1122
|
+
* The inline properties this renderer owns on a page element. Every draw sets all of them
|
|
1123
|
+
* (clearing the ones it does not use) and touches nothing else, so a page keeps whatever other
|
|
1124
|
+
* inline style its author or framework gave it.
|
|
1125
|
+
*/
|
|
1126
|
+
const PAGE_STYLE = [
|
|
1127
|
+
"display",
|
|
1128
|
+
"position",
|
|
1129
|
+
"zIndex",
|
|
1130
|
+
"left",
|
|
1131
|
+
"top",
|
|
1132
|
+
"width",
|
|
1133
|
+
"height",
|
|
1134
|
+
"transformOrigin",
|
|
1135
|
+
"transform",
|
|
1136
|
+
"clipPath",
|
|
1137
|
+
"backfaceVisibility"
|
|
1138
|
+
];
|
|
1139
|
+
function applyPageStyle(el, style) {
|
|
1140
|
+
for (const key of PAGE_STYLE) el.style[key] = style[key] ?? "";
|
|
1141
|
+
}
|
|
1142
|
+
var DomRenderer = class {
|
|
1143
|
+
shadows;
|
|
1144
|
+
pages = [];
|
|
1145
|
+
saved = /* @__PURE__ */ new Map();
|
|
1146
|
+
/**
|
|
1147
|
+
* In portrait a page lifts away from itself: the flat page stays and a mirrored copy folds
|
|
1148
|
+
* over it. The copy is inert, has no ids, and lives only for the duration of the flip.
|
|
1149
|
+
*/
|
|
1150
|
+
clone = null;
|
|
1151
|
+
container;
|
|
1152
|
+
options;
|
|
1153
|
+
constructor(container, options) {
|
|
1154
|
+
this.container = container;
|
|
1155
|
+
this.options = options;
|
|
1156
|
+
container.classList.add(CLASS.book);
|
|
1157
|
+
const shadow = (name) => {
|
|
1158
|
+
const el = document.createElement("div");
|
|
1159
|
+
el.className = `${CLASS.shadow} ${CLASS.shadow}--${name}`;
|
|
1160
|
+
el.style.display = "none";
|
|
1161
|
+
container.append(el);
|
|
1162
|
+
return el;
|
|
1163
|
+
};
|
|
1164
|
+
this.shadows = {
|
|
1165
|
+
outer: shadow("outer"),
|
|
1166
|
+
inner: shadow("inner"),
|
|
1167
|
+
hardOuter: shadow("hard-outer"),
|
|
1168
|
+
hardInner: shadow("hard-inner")
|
|
1169
|
+
};
|
|
1170
|
+
this.applyContainerSizing();
|
|
1171
|
+
}
|
|
1172
|
+
setPages(pages) {
|
|
1173
|
+
for (const page of this.pages) if (!pages.some((next) => next.element === page.element)) this.restore(page.element);
|
|
1174
|
+
this.pages = pages;
|
|
1175
|
+
for (const page of pages) {
|
|
1176
|
+
if (this.saved.has(page.element)) continue;
|
|
1177
|
+
this.saved.set(page.element, {
|
|
1178
|
+
cssText: page.element.style.cssText,
|
|
1179
|
+
className: page.element.className
|
|
1180
|
+
});
|
|
1181
|
+
page.element.classList.add(CLASS.page);
|
|
1182
|
+
if (page.element.parentElement !== this.container) this.container.append(page.element);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
/** Aspect ratio and width limits on the container, when the book sizes itself. */
|
|
1186
|
+
applyContainerSizing(orientation = Orientation.landscape) {
|
|
1187
|
+
const { autoSize, size, width, height, minWidth, maxWidth, layout } = this.options;
|
|
1188
|
+
if (!autoSize) return;
|
|
1189
|
+
const pagesAcross = layout === "spread" ? 2 : 1;
|
|
1190
|
+
const style = this.container.style;
|
|
1191
|
+
style.width = "100%";
|
|
1192
|
+
style.minWidth = `${(size === "fixed" ? width : minWidth) * pagesAcross}px`;
|
|
1193
|
+
style.maxWidth = `${(size === "fixed" ? width : maxWidth) * 2}px`;
|
|
1194
|
+
style.aspectRatio = orientation === Orientation.portrait ? `${width} / ${height}` : `${width * 2} / ${height}`;
|
|
1195
|
+
}
|
|
1196
|
+
render(frame) {
|
|
1197
|
+
const { rect, flip } = frame;
|
|
1198
|
+
const active = /* @__PURE__ */ new Set([
|
|
1199
|
+
frame.left,
|
|
1200
|
+
frame.right,
|
|
1201
|
+
flip?.flipping,
|
|
1202
|
+
flip?.bottom
|
|
1203
|
+
]);
|
|
1204
|
+
for (const [index, page] of this.pages.entries()) {
|
|
1205
|
+
if (!active.has(index)) applyPageStyle(page.element, { display: "none" });
|
|
1206
|
+
page.element.classList.toggle(CLASS.hard, page.drawingDensity === PageDensity.hard);
|
|
1207
|
+
page.element.classList.toggle(CLASS.soft, page.drawingDensity === PageDensity.soft);
|
|
1208
|
+
}
|
|
1209
|
+
const flippingHard = flip !== null && this.pages[flip.flipping]?.drawingDensity === PageDensity.hard;
|
|
1210
|
+
if (frame.orientation !== Orientation.portrait && frame.left !== null) {
|
|
1211
|
+
if (flip !== null && flip.direction === FlipDirection.back && flippingHard) this.drawHard(frame.left, "left", 180 + flip.hardAngle, Z.flipping, rect);
|
|
1212
|
+
else this.drawFlat(frame.left, "left", rect);
|
|
1213
|
+
}
|
|
1214
|
+
if (frame.right !== null) {
|
|
1215
|
+
if (flip !== null && flip.direction === FlipDirection.forward && flippingHard) this.drawHard(frame.right, "right", 180 + flip.hardAngle, Z.flipping, rect);
|
|
1216
|
+
else this.drawFlat(frame.right, "right", rect);
|
|
1217
|
+
}
|
|
1218
|
+
if (flip === null) {
|
|
1219
|
+
this.dropClone();
|
|
1220
|
+
this.hideShadows();
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
const liftsFromItself = !flippingHard && flip.flipping === frame.right;
|
|
1224
|
+
if (!liftsFromItself) this.dropClone();
|
|
1225
|
+
const bottomSide = flip.direction === FlipDirection.back ? "left" : "right";
|
|
1226
|
+
if (!(frame.orientation === Orientation.portrait && flip.direction === FlipDirection.back)) {
|
|
1227
|
+
if (flippingHard) this.drawHard(flip.bottom, bottomSide, 0, Z.bottom, rect);
|
|
1228
|
+
else this.drawSoft(flip.bottom, bottomSide, flip.fold.bottomClip, flip.fold.bottomPagePosition, 0, flip.direction, Z.bottom, rect);
|
|
1229
|
+
}
|
|
1230
|
+
const flippingSide = flip.direction === FlipDirection.forward && frame.orientation !== Orientation.portrait ? "left" : "right";
|
|
1231
|
+
if (flippingHard) this.drawHard(flip.flipping, flippingSide, flip.hardAngle, Z.flipping, rect);
|
|
1232
|
+
else this.drawSoft(flip.flipping, flippingSide, flip.fold.flippingClip, flip.fold.activeCorner, flip.fold.angle, flip.direction, Z.flipping, rect, liftsFromItself);
|
|
1233
|
+
if (flip.shadow === null) this.hideShadows();
|
|
1234
|
+
else if (flippingHard) {
|
|
1235
|
+
this.hideSoftShadows();
|
|
1236
|
+
this.drawHardShadows(flip.shadow, rect);
|
|
1237
|
+
} else {
|
|
1238
|
+
this.hideHardShadows();
|
|
1239
|
+
this.drawSoftShadows(flip.shadow, flip.fold.rect, rect);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
element(index, side, asClone = false) {
|
|
1243
|
+
const page = this.pages[index];
|
|
1244
|
+
if (page === void 0) return null;
|
|
1245
|
+
const el = asClone ? this.cloneOf(page.element) : page.element;
|
|
1246
|
+
el.classList.toggle(CLASS.left, side === "left");
|
|
1247
|
+
el.classList.toggle(CLASS.right, side === "right");
|
|
1248
|
+
return el;
|
|
1249
|
+
}
|
|
1250
|
+
cloneOf(source) {
|
|
1251
|
+
if (this.clone?.source === source) return this.clone.element;
|
|
1252
|
+
this.dropClone();
|
|
1253
|
+
const element = source.cloneNode(true);
|
|
1254
|
+
element.removeAttribute("id");
|
|
1255
|
+
for (const el of element.querySelectorAll("[id]")) el.removeAttribute("id");
|
|
1256
|
+
element.setAttribute("aria-hidden", "true");
|
|
1257
|
+
element.inert = true;
|
|
1258
|
+
element.dataset["opfClone"] = "";
|
|
1259
|
+
source.after(element);
|
|
1260
|
+
this.clone = {
|
|
1261
|
+
source,
|
|
1262
|
+
element
|
|
1263
|
+
};
|
|
1264
|
+
return element;
|
|
1265
|
+
}
|
|
1266
|
+
dropClone() {
|
|
1267
|
+
this.clone?.element.remove();
|
|
1268
|
+
this.clone = null;
|
|
1269
|
+
}
|
|
1270
|
+
drawFlat(index, side, rect) {
|
|
1271
|
+
const el = this.element(index, side);
|
|
1272
|
+
if (el === null) return;
|
|
1273
|
+
el.classList.add(CLASS.flat);
|
|
1274
|
+
const left = side === "right" ? rect.left + rect.pageWidth : rect.left;
|
|
1275
|
+
applyPageStyle(el, {
|
|
1276
|
+
position: "absolute",
|
|
1277
|
+
display: "block",
|
|
1278
|
+
height: `${rect.height}px`,
|
|
1279
|
+
left: `${left}px`,
|
|
1280
|
+
top: `${rect.top}px`,
|
|
1281
|
+
width: `${rect.pageWidth}px`,
|
|
1282
|
+
zIndex: String(Z.flat)
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
drawSoft(index, side, area, position, angle, direction, zIndex, rect, asClone = false) {
|
|
1286
|
+
const el = this.element(index, side, asClone);
|
|
1287
|
+
if (el === null) return;
|
|
1288
|
+
el.classList.remove(CLASS.flat);
|
|
1289
|
+
const at = pageToContainer(position, rect, direction);
|
|
1290
|
+
const polygon = area.map((p) => {
|
|
1291
|
+
const g = rotatePoint(direction === FlipDirection.back ? {
|
|
1292
|
+
x: -p.x + position.x,
|
|
1293
|
+
y: p.y - position.y
|
|
1294
|
+
} : {
|
|
1295
|
+
x: p.x - position.x,
|
|
1296
|
+
y: p.y - position.y
|
|
1297
|
+
}, {
|
|
1298
|
+
x: 0,
|
|
1299
|
+
y: 0
|
|
1300
|
+
}, angle);
|
|
1301
|
+
return `${g.x}px ${g.y}px`;
|
|
1302
|
+
}).join(", ");
|
|
1303
|
+
applyPageStyle(el, {
|
|
1304
|
+
position: "absolute",
|
|
1305
|
+
display: "block",
|
|
1306
|
+
zIndex: String(zIndex),
|
|
1307
|
+
left: "0",
|
|
1308
|
+
top: "0",
|
|
1309
|
+
width: `${rect.pageWidth}px`,
|
|
1310
|
+
height: `${rect.height}px`,
|
|
1311
|
+
transformOrigin: "0 0",
|
|
1312
|
+
clipPath: `polygon(${polygon})`,
|
|
1313
|
+
transform: `translate3d(${at.x}px, ${at.y}px, 0) rotate(${angle}rad)`
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
drawHard(index, side, angle, zIndex, rect) {
|
|
1317
|
+
const el = this.element(index, side);
|
|
1318
|
+
if (el === null) return;
|
|
1319
|
+
el.classList.remove(CLASS.flat);
|
|
1320
|
+
const spine = rect.left + rect.width / 2;
|
|
1321
|
+
applyPageStyle(el, {
|
|
1322
|
+
position: "absolute",
|
|
1323
|
+
display: "block",
|
|
1324
|
+
zIndex: String(zIndex),
|
|
1325
|
+
left: "0",
|
|
1326
|
+
top: "0",
|
|
1327
|
+
width: `${rect.pageWidth}px`,
|
|
1328
|
+
height: `${rect.height}px`,
|
|
1329
|
+
backfaceVisibility: "hidden",
|
|
1330
|
+
clipPath: "none",
|
|
1331
|
+
transformOrigin: side === "left" ? `${rect.pageWidth}px 0` : "0 0",
|
|
1332
|
+
transform: side === "left" ? `translate3d(${rect.left}px, ${rect.top}px, 0) rotateY(${angle}deg)` : `translate3d(${spine}px, ${rect.top}px, 0) rotateY(${angle}deg)`
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
drawSoftShadows(shadow, pageRect, rect) {
|
|
1336
|
+
const forward = shadow.direction === FlipDirection.forward;
|
|
1337
|
+
const at = pageToContainer(shadow.pos, rect, shadow.direction);
|
|
1338
|
+
const angle = shadow.angle + 3 * Math.PI / 2;
|
|
1339
|
+
const polygon = (points, translate) => points.map((p) => {
|
|
1340
|
+
const g = rotatePoint(forward ? {
|
|
1341
|
+
x: p.x - shadow.pos.x,
|
|
1342
|
+
y: p.y - shadow.pos.y
|
|
1343
|
+
} : {
|
|
1344
|
+
x: -p.x + shadow.pos.x,
|
|
1345
|
+
y: p.y - shadow.pos.y
|
|
1346
|
+
}, {
|
|
1347
|
+
x: translate,
|
|
1348
|
+
y: 100
|
|
1349
|
+
}, angle);
|
|
1350
|
+
return `${g.x}px ${g.y}px`;
|
|
1351
|
+
}).join(", ");
|
|
1352
|
+
const outerTranslate = forward ? 0 : shadow.width;
|
|
1353
|
+
const outerClip = polygon([
|
|
1354
|
+
{
|
|
1355
|
+
x: 0,
|
|
1356
|
+
y: 0
|
|
1357
|
+
},
|
|
1358
|
+
{
|
|
1359
|
+
x: rect.pageWidth,
|
|
1360
|
+
y: 0
|
|
1361
|
+
},
|
|
1362
|
+
{
|
|
1363
|
+
x: rect.pageWidth,
|
|
1364
|
+
y: rect.height
|
|
1365
|
+
},
|
|
1366
|
+
{
|
|
1367
|
+
x: 0,
|
|
1368
|
+
y: rect.height
|
|
1369
|
+
}
|
|
1370
|
+
], outerTranslate);
|
|
1371
|
+
this.shadows.outer.style.cssText = `display: block; z-index: ${Z.shadow}; width: ${shadow.width}px; height: ${rect.height * 2}px; background: linear-gradient(${forward ? "to right" : "to left"}, rgba(0, 0, 0, ${shadow.opacity}), rgba(0, 0, 0, 0)); transform-origin: ${outerTranslate}px 100px; transform: translate3d(${at.x - outerTranslate}px, ${at.y - 100}px, 0) rotate(${angle}rad); clip-path: polygon(${outerClip});`;
|
|
1372
|
+
const innerWidth = shadow.width * 3 / 4;
|
|
1373
|
+
const innerTranslate = forward ? innerWidth : 0;
|
|
1374
|
+
const innerClip = polygon([
|
|
1375
|
+
pageRect.topLeft,
|
|
1376
|
+
pageRect.topRight,
|
|
1377
|
+
pageRect.bottomRight,
|
|
1378
|
+
pageRect.bottomLeft
|
|
1379
|
+
], innerTranslate);
|
|
1380
|
+
this.shadows.inner.style.cssText = `display: block; z-index: ${Z.shadow}; width: ${innerWidth}px; height: ${rect.height * 2}px; background: linear-gradient(${forward ? "to left" : "to right"}, rgba(0, 0, 0, ${shadow.opacity}) 5%, rgba(0, 0, 0, 0.05) 15%, rgba(0, 0, 0, ${shadow.opacity}) 35%, rgba(0, 0, 0, 0) 100%); transform-origin: ${innerTranslate}px 100px; transform: translate3d(${at.x - innerTranslate}px, ${at.y - 100}px, 0) rotate(${angle}rad); clip-path: polygon(${innerClip});`;
|
|
1381
|
+
}
|
|
1382
|
+
drawHardShadows(shadow, rect) {
|
|
1383
|
+
const progress = shadow.progress > 100 ? 200 - shadow.progress : shadow.progress;
|
|
1384
|
+
const size = Math.min(rect.pageWidth, (100 - progress) * (2.5 * rect.pageWidth) / 100 + 20);
|
|
1385
|
+
const spine = rect.left + rect.width / 2;
|
|
1386
|
+
const flipped = shadow.direction === FlipDirection.forward && shadow.progress > 100 || shadow.direction === FlipDirection.back && shadow.progress <= 100;
|
|
1387
|
+
const common = `display: block; width: ${size}px; height: ${rect.height}px; left: ${spine}px; top: ${rect.top}px; transform-origin: 0 0;`;
|
|
1388
|
+
this.shadows.hardInner.style.cssText = `${common} z-index: ${Z.hardInnerShadow}; background: linear-gradient(to right, rgba(0, 0, 0, ${shadow.opacity * progress / 100}) 5%, rgba(0, 0, 0, 0) 100%); transform: translate3d(0, 0, 0)${flipped ? "" : " rotateY(180deg)"};`;
|
|
1389
|
+
this.shadows.hardOuter.style.cssText = `${common} z-index: ${Z.hardShadow}; background: linear-gradient(to left, rgba(0, 0, 0, ${shadow.opacity}) 5%, rgba(0, 0, 0, 0) 100%); transform: translate3d(0, 0, 0)${flipped ? " rotateY(180deg)" : ""};`;
|
|
1390
|
+
}
|
|
1391
|
+
hideSoftShadows() {
|
|
1392
|
+
this.shadows.outer.style.cssText = "display: none";
|
|
1393
|
+
this.shadows.inner.style.cssText = "display: none";
|
|
1394
|
+
}
|
|
1395
|
+
hideHardShadows() {
|
|
1396
|
+
this.shadows.hardOuter.style.cssText = "display: none";
|
|
1397
|
+
this.shadows.hardInner.style.cssText = "display: none";
|
|
1398
|
+
}
|
|
1399
|
+
hideShadows() {
|
|
1400
|
+
this.hideSoftShadows();
|
|
1401
|
+
this.hideHardShadows();
|
|
1402
|
+
}
|
|
1403
|
+
restore(element) {
|
|
1404
|
+
const saved = this.saved.get(element);
|
|
1405
|
+
if (saved === void 0) return;
|
|
1406
|
+
element.style.cssText = saved.cssText;
|
|
1407
|
+
element.className = saved.className;
|
|
1408
|
+
this.saved.delete(element);
|
|
1409
|
+
}
|
|
1410
|
+
/** Put the container and every page back the way they were found. */
|
|
1411
|
+
destroy() {
|
|
1412
|
+
this.dropClone();
|
|
1413
|
+
for (const page of this.pages) this.restore(page.element);
|
|
1414
|
+
this.pages = [];
|
|
1415
|
+
for (const el of Object.values(this.shadows)) el.remove();
|
|
1416
|
+
this.container.classList.remove(CLASS.book);
|
|
1417
|
+
const style = this.container.style;
|
|
1418
|
+
style.width = "";
|
|
1419
|
+
style.minWidth = "";
|
|
1420
|
+
style.maxWidth = "";
|
|
1421
|
+
style.aspectRatio = "";
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
//#endregion
|
|
1425
|
+
//#region src/book.ts
|
|
1426
|
+
function createBook(container, userOptions) {
|
|
1427
|
+
const { clock = browserClock, ...bookOptions } = userOptions;
|
|
1428
|
+
const options = resolveOptions(bookOptions);
|
|
1429
|
+
const elements = Array.from(bookOptions.pages ?? container.children).filter((el) => el instanceof HTMLElement);
|
|
1430
|
+
if (elements.length === 0) throw new TypeError("@openpageflip/core: createBook needs at least one page element");
|
|
1431
|
+
if (options.startPage >= elements.length) throw new TypeError(`@openpageflip/core: "startPage" ${options.startPage} is out of range for ${elements.length} pages`);
|
|
1432
|
+
const emitter = createEmitter();
|
|
1433
|
+
const renderer = new DomRenderer(container, options);
|
|
1434
|
+
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
|
|
1435
|
+
const measure = () => {
|
|
1436
|
+
const width = container.clientWidth;
|
|
1437
|
+
const { orientation } = computeLayout(width, container.clientHeight, options);
|
|
1438
|
+
renderer.applyContainerSizing(orientation);
|
|
1439
|
+
return computeLayout(width, container.clientHeight, options);
|
|
1440
|
+
};
|
|
1441
|
+
const buildPages = (els) => {
|
|
1442
|
+
const { hardByPosition } = buildSpreads(els.length, "landscape", options.cover);
|
|
1443
|
+
return createPages(els, hardByPosition);
|
|
1444
|
+
};
|
|
1445
|
+
let pages = buildPages(elements);
|
|
1446
|
+
renderer.setPages(pages);
|
|
1447
|
+
const controller = new FlipController({
|
|
1448
|
+
...options,
|
|
1449
|
+
get flipDuration() {
|
|
1450
|
+
return reducedMotion.matches ? 0 : options.flipDuration;
|
|
1451
|
+
}
|
|
1452
|
+
}, clock, {
|
|
1453
|
+
onFrame: (frame) => renderer.render(frame),
|
|
1454
|
+
onPage: (page) => emitter.emit("flip", { page }),
|
|
1455
|
+
onState: (state) => emitter.emit("changeState", { state })
|
|
1456
|
+
}, pages, measure());
|
|
1457
|
+
const relayout = () => {
|
|
1458
|
+
const layout = measure();
|
|
1459
|
+
if (controller.setLayout(layout)) emitter.emit("changeOrientation", { orientation: layout.orientation });
|
|
1460
|
+
};
|
|
1461
|
+
let lastSize = {
|
|
1462
|
+
width: container.clientWidth,
|
|
1463
|
+
height: container.clientHeight
|
|
1464
|
+
};
|
|
1465
|
+
let pendingRelayout = null;
|
|
1466
|
+
const observer = new ResizeObserver(() => {
|
|
1467
|
+
if (pendingRelayout !== null) return;
|
|
1468
|
+
pendingRelayout = requestAnimationFrame(() => {
|
|
1469
|
+
pendingRelayout = null;
|
|
1470
|
+
const size = {
|
|
1471
|
+
width: container.clientWidth,
|
|
1472
|
+
height: container.clientHeight
|
|
1473
|
+
};
|
|
1474
|
+
if (size.width === lastSize.width && size.height === lastSize.height) return;
|
|
1475
|
+
lastSize = size;
|
|
1476
|
+
relayout();
|
|
1477
|
+
});
|
|
1478
|
+
});
|
|
1479
|
+
observer.observe(container);
|
|
1480
|
+
const detachInput = attachInput(container, controller, options);
|
|
1481
|
+
controller.showPage(options.startPage);
|
|
1482
|
+
queueMicrotask(() => emitter.emit("init", {
|
|
1483
|
+
page: controller.page,
|
|
1484
|
+
orientation: controller.currentOrientation
|
|
1485
|
+
}));
|
|
1486
|
+
return {
|
|
1487
|
+
get page() {
|
|
1488
|
+
return controller.page;
|
|
1489
|
+
},
|
|
1490
|
+
get pageCount() {
|
|
1491
|
+
return controller.pageCount;
|
|
1492
|
+
},
|
|
1493
|
+
get orientation() {
|
|
1494
|
+
return controller.currentOrientation;
|
|
1495
|
+
},
|
|
1496
|
+
get state() {
|
|
1497
|
+
return controller.currentState;
|
|
1498
|
+
},
|
|
1499
|
+
get rect() {
|
|
1500
|
+
return controller.bookRect;
|
|
1501
|
+
},
|
|
1502
|
+
on: emitter.on,
|
|
1503
|
+
off: emitter.off,
|
|
1504
|
+
flipNext: (corner = FlipCorner.top) => controller.flipNext(corner),
|
|
1505
|
+
flipPrev: (corner = FlipCorner.top) => controller.flipPrev(corner),
|
|
1506
|
+
flipTo: (page, corner = FlipCorner.top) => controller.flipTo(page, corner),
|
|
1507
|
+
turnTo: (page) => controller.showPage(page),
|
|
1508
|
+
turnNext: () => controller.showNext(),
|
|
1509
|
+
turnPrev: () => controller.showPrev(),
|
|
1510
|
+
setPages(next) {
|
|
1511
|
+
const els = Array.from(next);
|
|
1512
|
+
if (els.length === 0) throw new TypeError("@openpageflip/core: setPages needs at least one page element");
|
|
1513
|
+
pages = buildPages(els);
|
|
1514
|
+
renderer.setPages(pages);
|
|
1515
|
+
controller.setPages(pages);
|
|
1516
|
+
emitter.emit("update", {
|
|
1517
|
+
page: controller.page,
|
|
1518
|
+
orientation: controller.currentOrientation
|
|
1519
|
+
});
|
|
1520
|
+
},
|
|
1521
|
+
update: relayout,
|
|
1522
|
+
destroy() {
|
|
1523
|
+
observer.disconnect();
|
|
1524
|
+
if (pendingRelayout !== null) cancelAnimationFrame(pendingRelayout);
|
|
1525
|
+
detachInput();
|
|
1526
|
+
controller.destroy();
|
|
1527
|
+
renderer.destroy();
|
|
1528
|
+
emitter.clear();
|
|
1529
|
+
}
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
27
1532
|
//#endregion
|
|
28
|
-
export { Direction, FlipCorner, Layout, PageDensity };
|
|
1533
|
+
export { ClickMode, Direction, FlipCorner, FlipDirection, FlipState, Layout, Orientation, PageDensity, SizeMode, computeFold, computeLayout, createBook };
|
|
29
1534
|
|
|
30
1535
|
//# sourceMappingURL=index.js.map
|