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