hic-pageflip 1.0.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.
@@ -0,0 +1,657 @@
1
+ /**
2
+ * Pageflip Controller & Interaction Engine.
3
+ * Standalone class that manages page flip state, gesture interactions (mouse/touch),
4
+ * corner hover peeks, animations, and active engine (2D/3D) rendering on a canvas.
5
+ */
6
+
7
+ import { constrainPaper, Easing, clamp } from './utils/math.js';
8
+ import { HICPageflipEngine2D } from './engines/engine-2d.js';
9
+ import { HICPageflipEngine3D } from './engines/engine-3d.js';
10
+
11
+ export class Pageflip {
12
+ constructor(canvas, slides = [], options = {}) {
13
+ this.canvas = canvas;
14
+ this.slides = slides;
15
+ this.engineMode = options.engine || '2d';
16
+ this.totalPages = options.totalPages || slides.length || 6;
17
+
18
+ // Current page: 0 = closed (cover on right), 2 = pages 2 & 3, etc.
19
+ const rawPage = options.page !== undefined ? options.page : (options.currentPage !== undefined ? options.currentPage : 0);
20
+ const initialPage = typeof rawPage === 'string' ? (parseInt(rawPage, 10) || 0) : (Number(rawPage) || 0);
21
+ const cleanTarget = clamp(Math.floor(initialPage), 0, this.totalPages);
22
+ this.currentPage = cleanTarget <= 1 ? 0 : Math.floor(cleanTarget / 2) * 2;
23
+ this.activeFlip = null; // { sx, sy, px, py, dir, isPeek, animating, ... }
24
+ this.hoverCorner = null; // 'tr' | 'br' | 'tl' | 'bl' | null
25
+ this.isDragging = false;
26
+ this.dragStartScreen = { x: 0, y: 0 };
27
+ this.lastPointer = { x: 0, y: 0, time: 0 };
28
+ this.velocity = { x: 0, y: 0 };
29
+
30
+ // Auto-flip animation state
31
+ this.animation = null;
32
+ this._rafLoopId = null;
33
+
34
+ // Callbacks
35
+ this.onPageChange = options.onPageChange || null;
36
+ this.onFlipProgress = options.onFlipProgress || null;
37
+
38
+ this.createEngine(this.engineMode);
39
+ this.bindEvents();
40
+ this.startLoop();
41
+ }
42
+
43
+ createEngine(engineMode) {
44
+ if (engineMode === '3d') {
45
+ this.engine = new HICPageflipEngine3D(this.canvas, this.slides);
46
+ } else {
47
+ this.engine = new HICPageflipEngine2D(this.canvas, this.slides);
48
+ }
49
+ }
50
+
51
+ setSlides(slides) {
52
+ this.slides = slides || [];
53
+ this.totalPages = this.slides.length;
54
+ if (this.engine) {
55
+ this.engine.slides = this.slides;
56
+ }
57
+ const cleanTarget = clamp(this.currentPage, 0, this.totalPages);
58
+ this.currentPage = cleanTarget <= 1 ? 0 : Math.floor(cleanTarget / 2) * 2;
59
+ }
60
+
61
+ switchEngine(engineMode) {
62
+ if (this.engineMode === engineMode && this.engine) return;
63
+ this.engineMode = engineMode;
64
+
65
+ this.createEngine(engineMode);
66
+ this.setDimensions(this.pw, this.ph);
67
+ this.render();
68
+ }
69
+
70
+ get pw() {
71
+ return this.engine ? this.engine.pw : 1024;
72
+ }
73
+
74
+ get ph() {
75
+ return this.engine ? this.engine.ph : 768;
76
+ }
77
+
78
+ setDimensions(pw, ph) {
79
+ this.slides.forEach((s) => {
80
+ s.pw = pw;
81
+ s.ph = ph;
82
+ });
83
+ if (this.engine) {
84
+ this.engine.setDimensions(pw, ph);
85
+ }
86
+ }
87
+
88
+ resize() {
89
+ if (this.engine) {
90
+ this.engine.resize();
91
+ }
92
+ }
93
+
94
+ render() {
95
+ if (this.engine) {
96
+ this.engine.render(this.getState());
97
+ }
98
+ }
99
+
100
+ requestRender() {
101
+ if (this.canvas && typeof this.canvas.requestPaint === 'function') {
102
+ this.canvas.requestPaint();
103
+ } else if (this.engine) {
104
+ this.engine.requestRender(() => this.render());
105
+ }
106
+ }
107
+
108
+ handlePaint() {
109
+ if (this.engineMode === '3d' && this.engine) {
110
+ const state = this.getState();
111
+ const [leftPage, rightPage] = state.currentSpread;
112
+ if (!state.isDragging && (!state.activeFlip || state.activeFlip.isPeek)) {
113
+ if (leftPage > 0 && this.slides[leftPage - 1]) {
114
+ this.engine.rasterizeSlideToTexture(this.slides[leftPage - 1]);
115
+ }
116
+ if (rightPage <= this.slides.length && this.slides[rightPage - 1]) {
117
+ this.engine.rasterizeSlideToTexture(this.slides[rightPage - 1]);
118
+ }
119
+ }
120
+ this.engine.render(state);
121
+ } else if (this.engine) {
122
+ this.engine.render(this.getState());
123
+ }
124
+ }
125
+
126
+ reloadTextures() {
127
+ if (this.engine && typeof this.engine.preloadSlideTextures === 'function') {
128
+ this.engine.isReloadingTextures = true;
129
+ this.slides.forEach((s) => {
130
+ if (s.element) {
131
+ s.element.style.transform = 'none';
132
+ }
133
+ });
134
+
135
+ requestAnimationFrame(() => {
136
+ requestAnimationFrame(() => {
137
+ this.engine.preloadSlideTextures();
138
+ this.engine.isReloadingTextures = false;
139
+ this.render();
140
+ });
141
+ });
142
+ } else {
143
+ this.render();
144
+ }
145
+ }
146
+
147
+ get currentSpread() {
148
+ if (this.currentPage === 0) {
149
+ return [0, 1]; // Left blank, Right is Cover (Page 1)
150
+ }
151
+ const left = this.currentPage;
152
+ const right = this.currentPage + 1;
153
+ return [left, right];
154
+ }
155
+
156
+ getState() {
157
+ return {
158
+ currentPage: this.currentPage,
159
+ totalPages: this.totalPages,
160
+ currentSpread: this.currentSpread,
161
+ activeFlip: this.activeFlip,
162
+ hoverCorner: this.hoverCorner,
163
+ isDragging: this.isDragging
164
+ };
165
+ }
166
+
167
+ bindEvents() {
168
+ const el = this.canvas;
169
+ if (!el) return;
170
+
171
+ this._onPointerMove = (e) => this.handlePointerMove(e);
172
+ this._onPointerDown = (e) => this.handlePointerDown(e);
173
+ this._onPointerUp = (e) => this.handlePointerUp(e);
174
+ this._onPointerLeave = () => this.handlePointerLeave();
175
+ this._onTouchStart = (e) => this.handleTouchStart(e);
176
+ this._onTouchMove = (e) => this.handleTouchMove(e);
177
+ this._onTouchEnd = (e) => this.handleTouchEnd(e);
178
+ this._onPaint = () => this.handlePaint();
179
+
180
+ // Mouse events
181
+ el.addEventListener('mousemove', this._onPointerMove);
182
+ el.addEventListener('mousedown', this._onPointerDown);
183
+ window.addEventListener('mouseup', this._onPointerUp);
184
+ el.addEventListener('mouseleave', this._onPointerLeave);
185
+
186
+ // Touch events
187
+ el.addEventListener('touchstart', this._onTouchStart, { passive: false });
188
+ el.addEventListener('touchmove', this._onTouchMove, { passive: false });
189
+ window.addEventListener('touchend', this._onTouchEnd);
190
+ window.addEventListener('touchcancel', this._onTouchEnd);
191
+
192
+ // Paint listener
193
+ el.onpaint = this._onPaint;
194
+ el.addEventListener('paint', this._onPaint);
195
+ }
196
+
197
+ unbindEvents() {
198
+ const el = this.canvas;
199
+ if (!el) return;
200
+
201
+ el.removeEventListener('mousemove', this._onPointerMove);
202
+ el.removeEventListener('mousedown', this._onPointerDown);
203
+ window.removeEventListener('mouseup', this._onPointerUp);
204
+ el.removeEventListener('mouseleave', this._onPointerLeave);
205
+
206
+ el.removeEventListener('touchstart', this._onTouchStart);
207
+ el.removeEventListener('touchmove', this._onTouchMove);
208
+ window.removeEventListener('touchend', this._onTouchEnd);
209
+ window.removeEventListener('touchcancel', this._onTouchEnd);
210
+
211
+ el.removeEventListener('paint', this._onPaint);
212
+ }
213
+
214
+ destroy() {
215
+ this.unbindEvents();
216
+ if (this._rafLoopId) {
217
+ cancelAnimationFrame(this._rafLoopId);
218
+ this._rafLoopId = null;
219
+ }
220
+ }
221
+
222
+ detectCorner(bookX, bookY) {
223
+ const pw = this.pw;
224
+ const ph = this.ph;
225
+ const cornerSize = Math.min(pw * 0.35, 180);
226
+ const [leftPage, rightPage] = this.currentSpread;
227
+
228
+ if (rightPage <= this.totalPages) {
229
+ if (bookX > pw - cornerSize && bookX <= pw) {
230
+ if (bookY > ph / 2 - cornerSize) return 'br';
231
+ if (bookY < -ph / 2 + cornerSize) return 'tr';
232
+ }
233
+ }
234
+
235
+ if (leftPage > 0) {
236
+ if (bookX < -pw + cornerSize && bookX >= -pw) {
237
+ if (bookY > ph / 2 - cornerSize) return 'bl';
238
+ if (bookY < -ph / 2 + cornerSize) return 'tl';
239
+ }
240
+ }
241
+
242
+ return null;
243
+ }
244
+
245
+ handlePointerMove(e) {
246
+ if (this.animation || !this.engine) return;
247
+
248
+ const rect = this.canvas.getBoundingClientRect();
249
+ const screenX = e.clientX - rect.left;
250
+ const screenY = e.clientY - rect.top;
251
+
252
+ const now = performance.now();
253
+ const dt = Math.max(1, now - (this.lastPointer.time || now));
254
+ this.velocity = {
255
+ x: (screenX - this.lastPointer.x) / dt,
256
+ y: (screenY - this.lastPointer.y) / dt
257
+ };
258
+ this.lastPointer = { x: screenX, y: screenY, time: now };
259
+
260
+ const bookPt = this.engine.screenToBook(screenX, screenY);
261
+
262
+ if (this.isDragging && this.activeFlip) {
263
+ this.updateDrag(bookPt.x, bookPt.y);
264
+ return;
265
+ }
266
+
267
+ const corner = this.detectCorner(bookPt.x, bookPt.y);
268
+ if (corner !== this.hoverCorner) {
269
+ this.hoverCorner = corner;
270
+ this.canvas.style.cursor = corner ? 'pointer' : 'default';
271
+
272
+ if (corner && !this.activeFlip) {
273
+ this.startCornerPeek(corner);
274
+ } else if (!corner && this.activeFlip && this.activeFlip.isPeek) {
275
+ this.endCornerPeek();
276
+ }
277
+ }
278
+ }
279
+
280
+ handlePointerDown(e) {
281
+ if (e.button !== 0 || this.animation || !this.engine) return;
282
+
283
+ const rect = this.canvas.getBoundingClientRect();
284
+ const screenX = e.clientX - rect.left;
285
+ const screenY = e.clientY - rect.top;
286
+ const bookPt = this.engine.screenToBook(screenX, screenY);
287
+
288
+ const corner = this.detectCorner(bookPt.x, bookPt.y);
289
+ if (corner) {
290
+ this.startDrag(corner, bookPt.x, bookPt.y, screenX, screenY);
291
+ }
292
+ }
293
+
294
+ handlePointerUp(e) {
295
+ if (!this.isDragging) return;
296
+ this.endDrag();
297
+ }
298
+
299
+ handlePointerLeave() {
300
+ if (!this.isDragging && this.activeFlip && this.activeFlip.isPeek) {
301
+ this.endCornerPeek();
302
+ }
303
+ this.hoverCorner = null;
304
+ if (this.canvas) this.canvas.style.cursor = 'default';
305
+ }
306
+
307
+ handleTouchStart(e) {
308
+ if (e.touches.length !== 1 || this.animation || !this.engine) return;
309
+ const touch = e.touches[0];
310
+ const rect = this.canvas.getBoundingClientRect();
311
+ const screenX = touch.clientX - rect.left;
312
+ const screenY = touch.clientY - rect.top;
313
+ const bookPt = this.engine.screenToBook(screenX, screenY);
314
+
315
+ const corner = this.detectCorner(bookPt.x, bookPt.y);
316
+ if (corner) {
317
+ e.preventDefault();
318
+ this.startDrag(corner, bookPt.x, bookPt.y, screenX, screenY);
319
+ }
320
+ }
321
+
322
+ handleTouchMove(e) {
323
+ if (!this.isDragging || e.touches.length !== 1 || !this.engine) return;
324
+ e.preventDefault();
325
+ const touch = e.touches[0];
326
+ const rect = this.canvas.getBoundingClientRect();
327
+ const screenX = touch.clientX - rect.left;
328
+ const screenY = touch.clientY - rect.top;
329
+
330
+ const now = performance.now();
331
+ const dt = Math.max(1, now - (this.lastPointer.time || now));
332
+ this.velocity = {
333
+ x: (screenX - this.lastPointer.x) / dt,
334
+ y: (screenY - this.lastPointer.y) / dt
335
+ };
336
+ this.lastPointer = { x: screenX, y: screenY, time: now };
337
+
338
+ const bookPt = this.engine.screenToBook(screenX, screenY);
339
+ this.updateDrag(bookPt.x, bookPt.y);
340
+ }
341
+
342
+ handleTouchEnd(e) {
343
+ if (this.isDragging) {
344
+ this.endDrag();
345
+ }
346
+ }
347
+
348
+ getCornerCoords(corner) {
349
+ const pw = this.pw;
350
+ const ph = this.ph;
351
+ switch (corner) {
352
+ case 'br': return { x: pw, y: ph / 2, dir: 1 };
353
+ case 'tr': return { x: pw, y: -ph / 2, dir: 1 };
354
+ case 'bl': return { x: -pw, y: ph / 2, dir: -1 };
355
+ case 'tl': return { x: -pw, y: -ph / 2, dir: -1 };
356
+ default: return null;
357
+ }
358
+ }
359
+
360
+ startCornerPeek(corner) {
361
+ const origin = this.getCornerCoords(corner);
362
+ if (!origin) return;
363
+
364
+ const peekDistance = 35;
365
+ const targetPx = origin.dir > 0 ? origin.x - peekDistance : origin.x + peekDistance;
366
+ const targetPy = origin.y > 0 ? origin.y - peekDistance : origin.y + peekDistance;
367
+
368
+ this.activeFlip = {
369
+ corner,
370
+ sx: origin.x,
371
+ sy: origin.y,
372
+ px: targetPx,
373
+ py: targetPy,
374
+ dir: origin.dir,
375
+ isPeek: true
376
+ };
377
+ }
378
+
379
+ endCornerPeek() {
380
+ if (!this.activeFlip || !this.activeFlip.isPeek) return;
381
+ const flip = this.activeFlip;
382
+ const startPx = flip.px;
383
+ const startPy = flip.py;
384
+ const targetPx = flip.sx;
385
+ const targetPy = flip.sy;
386
+
387
+ const startTime = performance.now();
388
+ const duration = 200;
389
+
390
+ this.animation = {
391
+ startTime,
392
+ duration,
393
+ tick: (now) => {
394
+ const elapsed = now - startTime;
395
+ const progress = Math.min(1, elapsed / duration);
396
+ const t = Easing.easeOutQuad(progress);
397
+
398
+ flip.px = startPx + (targetPx - startPx) * t;
399
+ flip.py = startPy + (targetPy - startPy) * t;
400
+
401
+ if (progress >= 1) {
402
+ this.animation = null;
403
+ this.activeFlip = null;
404
+ }
405
+ }
406
+ };
407
+ }
408
+
409
+ startDrag(corner, bookX, bookY, screenX, screenY) {
410
+ const origin = this.getCornerCoords(corner);
411
+ if (!origin) return;
412
+
413
+ this.isDragging = true;
414
+ this.hoverCorner = null;
415
+ this.animation = null;
416
+ this.dragStartScreen = { x: screenX, y: screenY };
417
+
418
+ this.activeFlip = {
419
+ corner,
420
+ sx: origin.x,
421
+ sy: origin.y,
422
+ px: bookX,
423
+ py: bookY,
424
+ dir: origin.dir,
425
+ isPeek: false
426
+ };
427
+
428
+ if (this.canvas) this.canvas.style.cursor = 'grabbing';
429
+ }
430
+
431
+ updateDrag(bookX, bookY) {
432
+ if (!this.activeFlip) return;
433
+ const pw = this.pw;
434
+ const ph = this.ph;
435
+ const flip = this.activeFlip;
436
+
437
+ const constrained = constrainPaper(bookX, bookY, flip.sx, flip.sy, pw, ph);
438
+ flip.px = constrained.x;
439
+ flip.py = constrained.y;
440
+
441
+ this.notifyFlipProgress();
442
+ }
443
+
444
+ endDrag() {
445
+ this.isDragging = false;
446
+ if (this.canvas) this.canvas.style.cursor = 'default';
447
+ if (!this.activeFlip) return;
448
+
449
+ const pw = this.pw;
450
+ const flip = this.activeFlip;
451
+ const dragX = flip.px;
452
+ const vx = this.velocity.x;
453
+
454
+ let complete = false;
455
+ if (flip.dir > 0) {
456
+ complete = dragX < 0 || vx < -0.4;
457
+ } else {
458
+ complete = dragX > 0 || vx > 0.4;
459
+ }
460
+
461
+ this.animateFlipCompletion(complete);
462
+ }
463
+
464
+ animateFlipCompletion(complete) {
465
+ const flip = this.activeFlip;
466
+ if (!flip) return;
467
+
468
+ const pw = this.pw;
469
+ const startPx = flip.px;
470
+ const startPy = flip.py;
471
+
472
+ let targetPx, targetPy;
473
+ if (complete) {
474
+ targetPx = flip.dir > 0 ? -pw : pw;
475
+ targetPy = flip.sy;
476
+ } else {
477
+ targetPx = flip.sx;
478
+ targetPy = flip.sy;
479
+ }
480
+
481
+ const dist = Math.hypot(targetPx - startPx, targetPy - startPy);
482
+ const duration = clamp(dist * 0.75, 200, 480);
483
+ const startTime = performance.now();
484
+
485
+ this.animation = {
486
+ startTime,
487
+ duration,
488
+ tick: (now) => {
489
+ const elapsed = now - startTime;
490
+ const rawProgress = Math.min(1, elapsed / duration);
491
+ const t = Easing.easeOutCubic(rawProgress);
492
+
493
+ flip.px = startPx + (targetPx - startPx) * t;
494
+ flip.py = startPy + (targetPy - startPy) * t;
495
+
496
+ this.notifyFlipProgress();
497
+
498
+ if (rawProgress >= 1) {
499
+ this.animation = null;
500
+ this.activeFlip = null;
501
+
502
+ if (complete) {
503
+ if (flip.dir > 0) {
504
+ this.currentPage = Math.min(this.totalPages, this.currentPage + 2);
505
+ } else {
506
+ this.currentPage = Math.max(0, this.currentPage - 2);
507
+ }
508
+ this.notifyPageChange();
509
+ }
510
+ }
511
+ }
512
+ };
513
+ }
514
+
515
+ flipForward() {
516
+ if (this.animation) return;
517
+ const [_, rightPage] = this.currentSpread;
518
+ if (rightPage > this.totalPages) return;
519
+
520
+ const pw = this.pw;
521
+ const ph = this.ph;
522
+ const sx = pw;
523
+ const sy = ph / 2;
524
+
525
+ this.activeFlip = {
526
+ sx,
527
+ sy,
528
+ px: sx,
529
+ py: sy,
530
+ dir: 1,
531
+ isPeek: false
532
+ };
533
+
534
+ const startTime = performance.now();
535
+ const duration = 480;
536
+
537
+ this.animation = {
538
+ startTime,
539
+ duration,
540
+ tick: (now) => {
541
+ const elapsed = now - startTime;
542
+ const progress = Math.min(1, elapsed / duration);
543
+ const t = Easing.easeInOutCubic(progress);
544
+
545
+ const x = pw - (pw * 2) * t;
546
+ const arcY = Math.sin(t * Math.PI) * (ph * 0.16);
547
+ const y = sy - arcY;
548
+
549
+ const constrained = constrainPaper(x, y, sx, sy, pw, ph);
550
+ this.activeFlip.px = constrained.x;
551
+ this.activeFlip.py = constrained.y;
552
+
553
+ this.notifyFlipProgress();
554
+
555
+ if (progress >= 1) {
556
+ this.animation = null;
557
+ this.activeFlip = null;
558
+ this.currentPage = Math.min(this.totalPages, this.currentPage + 2);
559
+ this.notifyPageChange();
560
+ }
561
+ }
562
+ };
563
+ }
564
+
565
+ flipBackward() {
566
+ if (this.animation) return;
567
+ const [leftPage, _] = this.currentSpread;
568
+ if (leftPage <= 0) return;
569
+
570
+ const pw = this.pw;
571
+ const ph = this.ph;
572
+ const sx = -pw;
573
+ const sy = ph / 2;
574
+
575
+ this.activeFlip = {
576
+ sx,
577
+ sy,
578
+ px: sx,
579
+ py: sy,
580
+ dir: -1,
581
+ isPeek: false
582
+ };
583
+
584
+ const startTime = performance.now();
585
+ const duration = 480;
586
+
587
+ this.animation = {
588
+ startTime,
589
+ duration,
590
+ tick: (now) => {
591
+ const elapsed = now - startTime;
592
+ const progress = Math.min(1, elapsed / duration);
593
+ const t = Easing.easeInOutCubic(progress);
594
+
595
+ const x = -pw + (pw * 2) * t;
596
+ const arcY = Math.sin(t * Math.PI) * (ph * 0.16);
597
+ const y = sy - arcY;
598
+
599
+ const constrained = constrainPaper(x, y, sx, sy, pw, ph);
600
+ this.activeFlip.px = constrained.x;
601
+ this.activeFlip.py = constrained.y;
602
+
603
+ this.notifyFlipProgress();
604
+
605
+ if (progress >= 1) {
606
+ this.animation = null;
607
+ this.activeFlip = null;
608
+ this.currentPage = Math.max(0, this.currentPage - 2);
609
+ this.notifyPageChange();
610
+ }
611
+ }
612
+ };
613
+ }
614
+
615
+ gotoPage(target) {
616
+ const cleanTarget = clamp(Math.floor(target), 0, this.totalPages);
617
+ const targetSpread = cleanTarget <= 1 ? 0 : Math.floor(cleanTarget / 2) * 2;
618
+
619
+ if (targetSpread === this.currentPage) return;
620
+
621
+ if (Math.abs(targetSpread - this.currentPage) === 2) {
622
+ if (targetSpread > this.currentPage) {
623
+ this.flipForward();
624
+ } else {
625
+ this.flipBackward();
626
+ }
627
+ } else {
628
+ this.currentPage = targetSpread;
629
+ this.activeFlip = null;
630
+ this.animation = null;
631
+ this.notifyPageChange();
632
+ }
633
+ }
634
+
635
+ notifyPageChange() {
636
+ if (this.onPageChange) {
637
+ this.onPageChange(this.getState());
638
+ }
639
+ }
640
+
641
+ notifyFlipProgress() {
642
+ if (this.onFlipProgress) {
643
+ this.onFlipProgress(this.getState());
644
+ }
645
+ }
646
+
647
+ startLoop() {
648
+ const loop = (now) => {
649
+ if (this.animation) {
650
+ this.animation.tick(now);
651
+ }
652
+ this.render();
653
+ this._rafLoopId = requestAnimationFrame(loop);
654
+ };
655
+ this._rafLoopId = requestAnimationFrame(loop);
656
+ }
657
+ }