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,470 @@
1
+ /**
2
+ * HTML-in-Canvas 2D Pageflip Engine (HICPageflipEngine2D)
3
+ * Implements 2D geometric fold clipping, affine reflection matrices,
4
+ * fold drop shadows, spine shadows, and direct DOM element drawing via ctx.drawElementImage.
5
+ */
6
+
7
+ import { BaseEngine } from './engine-base.js';
8
+ import { calculateFold, clamp } from '../utils/math.js';
9
+
10
+ export class HICPageflipEngine2D extends BaseEngine {
11
+ constructor(canvas, slides = []) {
12
+ super(canvas, slides);
13
+ this.ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
14
+ this.devicePixelRatio = 1;
15
+ this.resize();
16
+ }
17
+
18
+ resize() {
19
+ const rect = this.canvas.getBoundingClientRect();
20
+ this.devicePixelRatio = 1;
21
+
22
+ // Use CSS pixel dimensions directly for 1:1 context coordinate mapping
23
+ this.canvas.width = Math.round(rect.width) || this.pw;
24
+ this.canvas.height = Math.round(rect.height) || this.ph;
25
+ this.viewportWidth = this.canvas.width;
26
+ this.viewportHeight = this.canvas.height;
27
+ this.offsetX = this.canvas.width / 2;
28
+ this.offsetY = this.canvas.height / 2;
29
+
30
+ const pad = 40;
31
+ const availW = Math.max(100, this.canvas.width - pad);
32
+ const availH = Math.max(100, this.canvas.height - pad);
33
+ const spreadW = this.pw * 2;
34
+ const spreadH = this.ph;
35
+
36
+ this.scale = Math.min(availW / spreadW, availH / spreadH, 1);
37
+ this.zoom = 1.0;
38
+ this.panX = 0;
39
+ this.panY = 0;
40
+ }
41
+
42
+ requestRender(callback) {
43
+ if (this.canvas && typeof this.canvas.requestPaint === 'function') {
44
+ this.canvas.requestPaint();
45
+ }
46
+ super.requestRender(callback);
47
+ }
48
+
49
+ /**
50
+ * Main render loop
51
+ */
52
+ render(state) {
53
+ const [leftPage, rightPage] = state.currentSpread;
54
+ const totalPages = state.totalPages || this.slides.length;
55
+ const activePages = new Set();
56
+
57
+ // Once the pointer goes down (dragging) or during active fold animation, inert ALL pages
58
+ const isInteracting = state.isDragging || (state.activeFlip && !state.activeFlip.isPeek);
59
+ if (!isInteracting) {
60
+ if (leftPage > 0) activePages.add(leftPage);
61
+ if (rightPage <= totalPages) activePages.add(rightPage);
62
+ }
63
+
64
+ // Dynamically inert all non-active pages
65
+ this.slides.forEach((s) => {
66
+ if (s.element) {
67
+ s.element.inert = !activePages.has(s.pageNum);
68
+ }
69
+ });
70
+
71
+ const ctx = this.ctx;
72
+ const dpr = this.devicePixelRatio;
73
+ const width = this.canvas.width / dpr;
74
+ const height = this.canvas.height / dpr;
75
+
76
+ ctx.save();
77
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
78
+ ctx.clearRect(0, 0, width, height);
79
+
80
+ // Apply main viewport transformations (center + zoom + pan)
81
+ ctx.translate(this.offsetX + this.panX, this.offsetY + this.panY);
82
+ ctx.scale(this.scale * this.zoom, this.scale * this.zoom);
83
+
84
+ // 1. Ambient shadow of the physical book block resting on the desk
85
+ this.renderBookShadow(ctx, state);
86
+
87
+ // 2. Render book pages, stationary gutter shadows, and turning flap on top
88
+ this.renderPages(ctx, state);
89
+
90
+ ctx.restore();
91
+ }
92
+
93
+ /**
94
+ * Ambient shadow of the physical book resting on the desk surface.
95
+ */
96
+ renderBookShadow(ctx, state) {
97
+ const pw = this.pw;
98
+ const ph = this.ph;
99
+ const [leftPageNum, rightPageNum] = state.currentSpread;
100
+ const flip = state.activeFlip;
101
+
102
+ let leftX = leftPageNum === 0 ? 0 : -pw;
103
+ let rightX = rightPageNum > state.totalPages ? 0 : pw;
104
+
105
+ if (flip) {
106
+ if (flip.dir < 0 && leftPageNum - 2 <= 0) {
107
+ leftX = 0;
108
+ }
109
+ if (flip.dir > 0 && rightPageNum + 2 > state.totalPages) {
110
+ rightX = 0;
111
+ }
112
+ }
113
+
114
+ const bookW = rightX - leftX;
115
+ if (bookW <= 0) return;
116
+
117
+ ctx.save();
118
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.55)';
119
+ ctx.shadowBlur = 38 * this.scale;
120
+ ctx.shadowOffsetX = 0;
121
+ ctx.shadowOffsetY = 16 * this.scale;
122
+
123
+ ctx.fillStyle = '#12141a';
124
+ ctx.fillRect(leftX, -ph / 2, bookW, ph);
125
+ ctx.restore();
126
+ }
127
+
128
+ /**
129
+ * Renders pages and dynamic page curl
130
+ */
131
+ renderPages(ctx, state) {
132
+ const pw = this.pw;
133
+ const ph = this.ph;
134
+ const [leftPageNum, rightPageNum] = state.currentSpread;
135
+ const flip = state.activeFlip;
136
+
137
+ if (!flip) {
138
+ // Static double-page spread
139
+ if (leftPageNum > 0) {
140
+ this.drawPage(ctx, leftPageNum, -pw, -ph / 2, pw, ph);
141
+ }
142
+ if (rightPageNum <= state.totalPages) {
143
+ this.drawPage(ctx, rightPageNum, 0, -ph / 2, pw, ph);
144
+ }
145
+ // Gutter shadow between the two stationary pages
146
+ if (leftPageNum > 0 && rightPageNum <= state.totalPages) {
147
+ this.renderSpineShadow(ctx, state);
148
+ }
149
+ return;
150
+ }
151
+
152
+ const fold = calculateFold(flip.px, flip.py, flip.sx, flip.sy, pw, ph);
153
+
154
+ if (flip.dir > 0) {
155
+ // ==========================================
156
+ // FORWARD FLIP: Right page turning to Left
157
+ // ==========================================
158
+
159
+ // 1. Current left page stays visible on the left desk
160
+ if (leftPageNum > 0) {
161
+ this.drawPage(ctx, leftPageNum, -pw, -ph / 2, pw, ph);
162
+ }
163
+
164
+ // 2. Underneath right page (Page N+2) revealed as page is peeled back
165
+ const nextRightPage = rightPageNum + 2;
166
+ if (nextRightPage <= state.totalPages) {
167
+ this.drawPage(ctx, nextRightPage, 0, -ph / 2, pw, ph);
168
+ }
169
+
170
+ if (!fold) {
171
+ if (rightPageNum <= state.totalPages) {
172
+ this.drawPage(ctx, rightPageNum, 0, -ph / 2, pw, ph);
173
+ }
174
+ if (leftPageNum > 0) {
175
+ this.renderSpineShadow(ctx, state);
176
+ }
177
+ return;
178
+ }
179
+
180
+ // 3. Drop shadow cast by the fold crease onto the revealed page underneath
181
+ if (nextRightPage <= state.totalPages) {
182
+ this.drawFoldUnderShadow(ctx, fold, 0, -ph / 2, pw, ph);
183
+ }
184
+
185
+ // 4. Flat unpeeled portion of Page N (contains spine: onDragSide = true)
186
+ if (rightPageNum <= state.totalPages) {
187
+ ctx.save();
188
+ ctx.beginPath();
189
+ ctx.rect(0, -ph / 2, pw, ph);
190
+ ctx.clip();
191
+ ctx.beginPath();
192
+ this.clipHalfPlane(ctx, fold, true);
193
+ ctx.clip();
194
+ this.drawPage(ctx, rightPageNum, 0, -ph / 2, pw, ph);
195
+ ctx.restore();
196
+ }
197
+
198
+ // 5. Gutter spine shadow on the flat stationary spread UNDER the turning flap
199
+ if (leftPageNum > 0 && rightPageNum <= state.totalPages) {
200
+ this.renderSpineShadow(ctx, state);
201
+ }
202
+
203
+ // 6. The Turning Flap (Back of turning page = Page N+1) rendered ON TOP
204
+ const backPageNum = rightPageNum + 1;
205
+ const theta = Math.atan2(fold.ny, fold.nx);
206
+
207
+ ctx.save();
208
+ // Clip to the dragged side of the fold (side containing P: onDragSide = true)
209
+ ctx.beginPath();
210
+ this.clipHalfPlane(ctx, fold, true);
211
+ ctx.clip();
212
+
213
+ // Apply reflection transformation across the fold crease line
214
+ ctx.translate(fold.cx, fold.cy);
215
+ ctx.rotate(theta);
216
+ ctx.scale(-1, 1);
217
+ ctx.rotate(-theta);
218
+ ctx.translate(-fold.cx, -fold.cy);
219
+
220
+ // Clip to the original right sheet boundary [0, -ph/2, pw, ph]
221
+ ctx.beginPath();
222
+ ctx.rect(0, -ph / 2, pw, ph);
223
+ ctx.clip();
224
+
225
+ // Draw Page N+1 on top
226
+ this.drawPageBack(ctx, backPageNum, 0, -ph / 2, pw, ph);
227
+ ctx.restore();
228
+
229
+ } else {
230
+ // ==========================================
231
+ // BACKWARD FLIP: Left page turning to Right
232
+ // ==========================================
233
+
234
+ // 1. Current right page stays visible on the right desk
235
+ if (rightPageNum <= state.totalPages) {
236
+ this.drawPage(ctx, rightPageNum, 0, -ph / 2, pw, ph);
237
+ }
238
+
239
+ // 2. Underneath left page (Page N-3) revealed as left page is peeled back
240
+ const prevLeftPage = leftPageNum - 2;
241
+ if (prevLeftPage > 0) {
242
+ this.drawPage(ctx, prevLeftPage, -pw, -ph / 2, pw, ph);
243
+ }
244
+
245
+ if (!fold) {
246
+ if (leftPageNum > 0) {
247
+ this.drawPage(ctx, leftPageNum, -pw, -ph / 2, pw, ph);
248
+ }
249
+ if (leftPageNum > 0 && rightPageNum <= state.totalPages) {
250
+ this.renderSpineShadow(ctx, state);
251
+ }
252
+ return;
253
+ }
254
+
255
+ // 3. Drop shadow cast by fold crease onto revealed page underneath
256
+ if (prevLeftPage > 0) {
257
+ this.drawFoldUnderShadow(ctx, fold, -pw, -ph / 2, pw, ph);
258
+ }
259
+
260
+ // 4. Flat unpeeled portion of Page N-1 (contains spine: onDragSide = true)
261
+ if (leftPageNum > 0) {
262
+ ctx.save();
263
+ ctx.beginPath();
264
+ ctx.rect(-pw, -ph / 2, pw, ph);
265
+ ctx.clip();
266
+ ctx.beginPath();
267
+ this.clipHalfPlane(ctx, fold, true);
268
+ ctx.clip();
269
+ this.drawPage(ctx, leftPageNum, -pw, -ph / 2, pw, ph);
270
+ ctx.restore();
271
+ }
272
+
273
+ // 5. Gutter spine shadow on the flat stationary spread UNDER the turning flap
274
+ if (leftPageNum > 0 && rightPageNum <= state.totalPages) {
275
+ this.renderSpineShadow(ctx, state);
276
+ }
277
+
278
+ // 6. The Turning Flap (Front of left sheet = Page N-1) rendered ON TOP
279
+ const frontPageNum = leftPageNum - 1;
280
+ const theta = Math.atan2(fold.ny, fold.nx);
281
+
282
+ ctx.save();
283
+ // Clip to the dragged side of the fold (side containing P: onDragSide = true)
284
+ ctx.beginPath();
285
+ this.clipHalfPlane(ctx, fold, true);
286
+ ctx.clip();
287
+
288
+ // Apply reflection transformation across the fold crease line
289
+ ctx.translate(fold.cx, fold.cy);
290
+ ctx.rotate(theta);
291
+ ctx.scale(-1, 1);
292
+ ctx.rotate(-theta);
293
+ ctx.translate(-fold.cx, -fold.cy);
294
+
295
+ // Clip to the left sheet boundary [-pw, -ph/2, pw, ph]
296
+ ctx.beginPath();
297
+ ctx.rect(-pw, -ph / 2, pw, ph);
298
+ ctx.clip();
299
+
300
+ // Draw Page N-1
301
+ this.drawPageFrontReflected(ctx, frontPageNum, -pw, -ph / 2, pw, ph);
302
+ ctx.restore();
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Clips to one half of the plane divided by the fold crease line.
308
+ */
309
+ clipHalfPlane(ctx, fold, onDragSide) {
310
+ const L = Math.max(this.pw, this.ph) * 6;
311
+ const tx = -fold.ny;
312
+ const ty = fold.nx;
313
+ const sign = onDragSide ? 1 : -1;
314
+ const nx = fold.nx * sign;
315
+ const ny = fold.ny * sign;
316
+
317
+ const p1 = { x: fold.cx - tx * L, y: fold.cy - ty * L };
318
+ const p2 = { x: fold.cx + tx * L, y: fold.cy + ty * L };
319
+ const p3 = { x: p2.x + nx * L, y: p2.y + ny * L };
320
+ const p4 = { x: p1.x + nx * L, y: p1.y + ny * L };
321
+
322
+ ctx.moveTo(p1.x, p1.y);
323
+ ctx.lineTo(p2.x, p2.y);
324
+ ctx.lineTo(p3.x, p3.y);
325
+ ctx.lineTo(p4.x, p4.y);
326
+ ctx.closePath();
327
+ }
328
+
329
+ /**
330
+ * Soft drop shadow under the fold crease onto the revealed page underneath.
331
+ */
332
+ drawFoldUnderShadow(ctx, fold, rx, ry, rw, rh) {
333
+ ctx.save();
334
+ ctx.beginPath();
335
+ ctx.rect(rx, ry, rw, rh);
336
+ ctx.clip();
337
+
338
+ ctx.beginPath();
339
+ this.clipHalfPlane(ctx, fold, false);
340
+ ctx.clip();
341
+
342
+ const shadowWidth = clamp(fold.len * 0.16, 12, 45);
343
+ const grad = ctx.createLinearGradient(
344
+ fold.cx,
345
+ fold.cy,
346
+ fold.cx - fold.nx * shadowWidth,
347
+ fold.cy - fold.ny * shadowWidth
348
+ );
349
+ grad.addColorStop(0, 'rgba(0, 0, 0, 0.35)');
350
+ grad.addColorStop(0.35, 'rgba(0, 0, 0, 0.14)');
351
+ grad.addColorStop(1, 'rgba(0, 0, 0, 0)');
352
+
353
+ ctx.fillStyle = grad;
354
+ ctx.fillRect(rx - 20, ry - 20, rw + 40, rh + 40);
355
+ ctx.restore();
356
+ }
357
+
358
+ /**
359
+ * Draws a flat DOM slide using ctx.drawElementImage.
360
+ */
361
+ drawPage(ctx, pageNum, x, y, w, h) {
362
+ const slide = this.slides[pageNum - 1];
363
+ if (!slide || !slide.element) return;
364
+
365
+ const el = slide.element;
366
+ if (typeof ctx.drawElementImage === 'function') {
367
+ try {
368
+ ctx.save();
369
+ ctx.translate(x, y);
370
+ const transform = ctx.drawElementImage(el, 0, 0);
371
+ if (transform && el.style) {
372
+ el.style.transform = transform.toString();
373
+ }
374
+ ctx.restore();
375
+ } catch (err) {
376
+ // Ignore if paint record not ready
377
+ }
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Draws the backside of the turning page (Page N+1) onto the sheet [0, -ph/2, pw, ph].
383
+ */
384
+ drawPageBack(ctx, pageNum, x, y, w, h) {
385
+ const slide = this.slides[pageNum - 1];
386
+ if (!slide || !slide.element) return;
387
+
388
+ const el = slide.element;
389
+ if (typeof ctx.drawElementImage === 'function') {
390
+ try {
391
+ ctx.save();
392
+ ctx.translate(x + w, y);
393
+ ctx.scale(-1, 1);
394
+ const transform = ctx.drawElementImage(el, 0, 0);
395
+ if (transform && el.style) {
396
+ el.style.transform = transform.toString();
397
+ }
398
+ ctx.restore();
399
+ } catch (err) {
400
+ // Ignore
401
+ }
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Draws the front of the turning left page reflected onto the right side.
407
+ */
408
+ drawPageFrontReflected(ctx, pageNum, x, y, w, h) {
409
+ const slide = this.slides[pageNum - 1];
410
+ if (!slide || !slide.element) return;
411
+
412
+ const el = slide.element;
413
+ if (typeof ctx.drawElementImage === 'function') {
414
+ try {
415
+ ctx.save();
416
+ ctx.translate(x, y);
417
+ ctx.scale(-1, 1);
418
+ ctx.translate(-w, 0);
419
+ const transform = ctx.drawElementImage(el, 0, 0);
420
+ if (transform && el.style) {
421
+ el.style.transform = transform.toString();
422
+ }
423
+ ctx.restore();
424
+ } catch (err) {
425
+ // Ignore
426
+ }
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Center spine crease shadow on the flat spread between stationary pages.
432
+ */
433
+ renderSpineShadow(ctx, state) {
434
+ const ph = this.ph;
435
+ const spineWidth = 32;
436
+ const [leftPageNum, rightPageNum] = state.currentSpread;
437
+
438
+ ctx.save();
439
+ // Left side of spine
440
+ if (leftPageNum > 0) {
441
+ const leftGrad = ctx.createLinearGradient(-spineWidth, 0, 0, 0);
442
+ leftGrad.addColorStop(0, 'rgba(0, 0, 0, 0)');
443
+ leftGrad.addColorStop(0.7, 'rgba(0, 0, 0, 0.14)');
444
+ leftGrad.addColorStop(1, 'rgba(0, 0, 0, 0.38)');
445
+ ctx.fillStyle = leftGrad;
446
+ ctx.fillRect(-spineWidth, -ph / 2, spineWidth, ph);
447
+ }
448
+
449
+ // Right side of spine
450
+ if (rightPageNum <= state.totalPages) {
451
+ const rightGrad = ctx.createLinearGradient(0, 0, spineWidth, 0);
452
+ rightGrad.addColorStop(0, 'rgba(0, 0, 0, 0.38)');
453
+ rightGrad.addColorStop(0.3, 'rgba(0, 0, 0, 0.14)');
454
+ rightGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
455
+ ctx.fillStyle = rightGrad;
456
+ ctx.fillRect(0, -ph / 2, spineWidth, ph);
457
+ }
458
+
459
+ // Fine center line
460
+ if (leftPageNum > 0 && rightPageNum <= state.totalPages) {
461
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.45)';
462
+ ctx.lineWidth = 1;
463
+ ctx.beginPath();
464
+ ctx.moveTo(0, -ph / 2);
465
+ ctx.lineTo(0, ph / 2);
466
+ ctx.stroke();
467
+ }
468
+ ctx.restore();
469
+ }
470
+ }