@docmentis/udoc-viewer 0.6.14 → 0.6.16

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.
Files changed (87) hide show
  1. package/README.md +36 -0
  2. package/dist/package.json +1 -1
  3. package/dist/src/UDocClient.d.ts +12 -0
  4. package/dist/src/UDocClient.d.ts.map +1 -1
  5. package/dist/src/UDocClient.js +25 -4
  6. package/dist/src/UDocClient.js.map +1 -1
  7. package/dist/src/UDocViewer.d.ts +14 -1
  8. package/dist/src/UDocViewer.d.ts.map +1 -1
  9. package/dist/src/UDocViewer.js +33 -1
  10. package/dist/src/UDocViewer.js.map +1 -1
  11. package/dist/src/index.d.ts +1 -0
  12. package/dist/src/index.d.ts.map +1 -1
  13. package/dist/src/ui/viewer/components/FontsPanel.d.ts +12 -0
  14. package/dist/src/ui/viewer/components/FontsPanel.d.ts.map +1 -0
  15. package/dist/src/ui/viewer/components/FontsPanel.js +141 -0
  16. package/dist/src/ui/viewer/components/FontsPanel.js.map +1 -0
  17. package/dist/src/ui/viewer/components/LeftPanel.d.ts.map +1 -1
  18. package/dist/src/ui/viewer/components/LeftPanel.js +16 -1
  19. package/dist/src/ui/viewer/components/LeftPanel.js.map +1 -1
  20. package/dist/src/ui/viewer/components/Viewport.d.ts.map +1 -1
  21. package/dist/src/ui/viewer/components/Viewport.js +90 -40
  22. package/dist/src/ui/viewer/components/Viewport.js.map +1 -1
  23. package/dist/src/ui/viewer/i18n/ar.d.ts.map +1 -1
  24. package/dist/src/ui/viewer/i18n/ar.js +4 -0
  25. package/dist/src/ui/viewer/i18n/ar.js.map +1 -1
  26. package/dist/src/ui/viewer/i18n/de.d.ts.map +1 -1
  27. package/dist/src/ui/viewer/i18n/de.js +4 -0
  28. package/dist/src/ui/viewer/i18n/de.js.map +1 -1
  29. package/dist/src/ui/viewer/i18n/en.d.ts.map +1 -1
  30. package/dist/src/ui/viewer/i18n/en.js +4 -0
  31. package/dist/src/ui/viewer/i18n/en.js.map +1 -1
  32. package/dist/src/ui/viewer/i18n/es.d.ts.map +1 -1
  33. package/dist/src/ui/viewer/i18n/es.js +4 -0
  34. package/dist/src/ui/viewer/i18n/es.js.map +1 -1
  35. package/dist/src/ui/viewer/i18n/fr.d.ts.map +1 -1
  36. package/dist/src/ui/viewer/i18n/fr.js +4 -0
  37. package/dist/src/ui/viewer/i18n/fr.js.map +1 -1
  38. package/dist/src/ui/viewer/i18n/ja.d.ts.map +1 -1
  39. package/dist/src/ui/viewer/i18n/ja.js +4 -0
  40. package/dist/src/ui/viewer/i18n/ja.js.map +1 -1
  41. package/dist/src/ui/viewer/i18n/ko.d.ts.map +1 -1
  42. package/dist/src/ui/viewer/i18n/ko.js +4 -0
  43. package/dist/src/ui/viewer/i18n/ko.js.map +1 -1
  44. package/dist/src/ui/viewer/i18n/pt-BR.d.ts.map +1 -1
  45. package/dist/src/ui/viewer/i18n/pt-BR.js +4 -0
  46. package/dist/src/ui/viewer/i18n/pt-BR.js.map +1 -1
  47. package/dist/src/ui/viewer/i18n/ru.d.ts.map +1 -1
  48. package/dist/src/ui/viewer/i18n/ru.js +4 -0
  49. package/dist/src/ui/viewer/i18n/ru.js.map +1 -1
  50. package/dist/src/ui/viewer/i18n/types.d.ts +3 -0
  51. package/dist/src/ui/viewer/i18n/types.d.ts.map +1 -1
  52. package/dist/src/ui/viewer/i18n/zh-CN.d.ts.map +1 -1
  53. package/dist/src/ui/viewer/i18n/zh-CN.js +4 -0
  54. package/dist/src/ui/viewer/i18n/zh-CN.js.map +1 -1
  55. package/dist/src/ui/viewer/i18n/zh-TW.d.ts.map +1 -1
  56. package/dist/src/ui/viewer/i18n/zh-TW.js +4 -0
  57. package/dist/src/ui/viewer/i18n/zh-TW.js.map +1 -1
  58. package/dist/src/ui/viewer/icons.d.ts +1 -0
  59. package/dist/src/ui/viewer/icons.d.ts.map +1 -1
  60. package/dist/src/ui/viewer/icons.js +1 -0
  61. package/dist/src/ui/viewer/icons.js.map +1 -1
  62. package/dist/src/ui/viewer/state.d.ts +2 -2
  63. package/dist/src/ui/viewer/state.d.ts.map +1 -1
  64. package/dist/src/ui/viewer/state.js +2 -1
  65. package/dist/src/ui/viewer/state.js.map +1 -1
  66. package/dist/src/ui/viewer/styles-inline.d.ts +1 -1
  67. package/dist/src/ui/viewer/styles-inline.d.ts.map +1 -1
  68. package/dist/src/ui/viewer/styles-inline.js +89 -0
  69. package/dist/src/ui/viewer/styles-inline.js.map +1 -1
  70. package/dist/src/ui/viewer/transition.js +443 -180
  71. package/dist/src/ui/viewer/transition.js.map +1 -1
  72. package/dist/src/wasm/udoc.d.ts +64 -18
  73. package/dist/src/wasm/udoc.js +82 -35
  74. package/dist/src/wasm/udoc_bg.wasm +0 -0
  75. package/dist/src/wasm/udoc_bg.wasm.d.ts +6 -4
  76. package/dist/src/worker/WorkerClient.d.ts +28 -4
  77. package/dist/src/worker/WorkerClient.d.ts.map +1 -1
  78. package/dist/src/worker/WorkerClient.js +91 -7
  79. package/dist/src/worker/WorkerClient.js.map +1 -1
  80. package/dist/src/worker/index.d.ts +1 -1
  81. package/dist/src/worker/index.d.ts.map +1 -1
  82. package/dist/src/worker/worker-inline.js +1 -1
  83. package/dist/src/worker/worker.d.ts +36 -3
  84. package/dist/src/worker/worker.d.ts.map +1 -1
  85. package/dist/src/worker/worker.js +102 -37
  86. package/dist/src/worker/worker.js.map +1 -1
  87. package/package.json +1 -1
@@ -150,23 +150,6 @@ function oppositeEightDir(dir) {
150
150
  };
151
151
  return map[dir];
152
152
  }
153
- /**
154
- * Build a clip-path: inset() that reveals the incoming element
155
- * progressively from the given direction.
156
- */
157
- function revealInset(dir, t) {
158
- const p = (1 - t) * 100;
159
- switch (dir) {
160
- case "right":
161
- return `inset(0 0 0 ${p}%)`;
162
- case "left":
163
- return `inset(0 ${p}% 0 0)`;
164
- case "down":
165
- return `inset(${p}% 0 0 0)`;
166
- case "up":
167
- return `inset(0 0 ${p}% 0)`;
168
- }
169
- }
170
153
  function eightDirToRevealInset(dir, t) {
171
154
  const p = (1 - t) * 100;
172
155
  let top = 0, right = 0, bottom = 0, left = 0;
@@ -190,14 +173,10 @@ function resolveEffect(effect, forward) {
190
173
  return effect.throughBlack ? cutThroughBlack : null;
191
174
  case "dissolve":
192
175
  return dissolveEffect();
193
- case "push": {
194
- const dir = forward ? effect.direction : oppositeSide(effect.direction);
195
- return pushEffect(dir);
196
- }
197
- case "wipe": {
198
- const dir = forward ? effect.direction : oppositeSide(effect.direction);
199
- return wipeEffect(dir);
200
- }
176
+ case "push":
177
+ return pushEffect(effect.direction);
178
+ case "wipe":
179
+ return wipeEffect(effect.direction);
201
180
  case "cover":
202
181
  return coverEffect(oppositeEightDir(effect.direction));
203
182
  case "uncover":
@@ -228,7 +207,7 @@ function resolveEffect(effect, forward) {
228
207
  case "wheel":
229
208
  return wheelSweepEffect(effect.spokes, true);
230
209
  case "newsflash":
231
- return circleEffect;
210
+ return newsflashEffect;
232
211
  case "blinds":
233
212
  return blindsEffect(effect.orientation);
234
213
  case "checker":
@@ -355,10 +334,15 @@ function cutThroughBlack(t, outgoing, _incoming) {
355
334
  * Push: snapshot slides away on top, incoming revealed via clip-path underneath.
356
335
  */
357
336
  function pushEffect(dir) {
337
+ const opp = oppositeSide(dir);
358
338
  return (t, outgoing, incoming) => {
339
+ if (t >= 1) {
340
+ incoming.style.transform = "";
341
+ return;
342
+ }
359
343
  outgoing.style.zIndex = "1";
360
344
  outgoing.style.transform = sideToTranslate(dir, t);
361
- incoming.style.clipPath = revealInset(oppositeSide(dir), t);
345
+ incoming.style.transform = sideToTranslate(opp, 1 - t);
362
346
  };
363
347
  }
364
348
  /**
@@ -366,7 +350,28 @@ function pushEffect(dir) {
366
350
  */
367
351
  function wipeEffect(dir) {
368
352
  return (t, _outgoing, incoming) => {
369
- incoming.style.clipPath = revealInset(dir, t);
353
+ if (t >= 1) {
354
+ clearMask(incoming);
355
+ return;
356
+ }
357
+ const p = (1 - t) * 100;
358
+ const blur = Math.max(0.5, t * 50 * 0.08);
359
+ let rect;
360
+ switch (dir) {
361
+ case "right":
362
+ rect = `<rect x="${p}" y="0" width="${100 - p}" height="100" fill="white" filter="url(#b)"/>`;
363
+ break;
364
+ case "left":
365
+ rect = `<rect x="0" y="0" width="${100 - p}" height="100" fill="white" filter="url(#b)"/>`;
366
+ break;
367
+ case "down":
368
+ rect = `<rect x="0" y="0" width="100" height="${100 - p}" fill="white" filter="url(#b)"/>`;
369
+ break;
370
+ case "up":
371
+ rect = `<rect x="0" y="${p}" width="100" height="${100 - p}" fill="white" filter="url(#b)"/>`;
372
+ break;
373
+ }
374
+ applyMask(incoming, svgMask(rect, blur));
370
375
  };
371
376
  }
372
377
  /**
@@ -390,10 +395,9 @@ function uncoverEffect(dir) {
390
395
  * Pull: snapshot slides away on top while incoming is revealed via clip-path.
391
396
  */
392
397
  function pullEffect(dir) {
393
- return (t, outgoing, incoming) => {
398
+ return (t, outgoing, _incoming) => {
394
399
  outgoing.style.zIndex = "1";
395
400
  outgoing.style.transform = eightDirToTranslate(dir, t);
396
- incoming.style.clipPath = eightDirToRevealInset(oppositeEightDir(dir), t);
397
401
  };
398
402
  }
399
403
  /**
@@ -401,19 +405,36 @@ function pullEffect(dir) {
401
405
  * Split "in": incoming revealed from edges inward — clip-path on snapshot (outgoing).
402
406
  */
403
407
  function splitEffect(orientation, inOut) {
408
+ const isH = orientation === "horizontal";
404
409
  if (inOut === "out") {
405
410
  // Center outward: incoming starts fully clipped, opens from center
406
411
  return (t, _outgoing, incoming) => {
412
+ if (t >= 1) {
413
+ clearMask(incoming);
414
+ return;
415
+ }
407
416
  const p = (1 - t) * 50;
408
- incoming.style.clipPath = orientation === "horizontal" ? `inset(${p}% 0)` : `inset(0 ${p}%)`;
417
+ const blur = Math.max(0.5, t * 50 * 0.08);
418
+ const rect = isH
419
+ ? `<rect x="0" y="${p}" width="100" height="${100 - 2 * p}" fill="white" filter="url(#b)"/>`
420
+ : `<rect x="${p}" y="0" width="${100 - 2 * p}" height="100" fill="white" filter="url(#b)"/>`;
421
+ applyMask(incoming, svgMask(rect, blur));
409
422
  };
410
423
  }
411
424
  else {
412
425
  // Edges inward: snapshot collapses toward center, revealing incoming at edges
413
426
  return (t, outgoing, _incoming) => {
427
+ if (t >= 1) {
428
+ clearMask(outgoing);
429
+ return;
430
+ }
414
431
  outgoing.style.zIndex = "1";
415
432
  const p = t * 50;
416
- outgoing.style.clipPath = orientation === "horizontal" ? `inset(${p}% 0)` : `inset(0 ${p}%)`;
433
+ const blur = Math.max(0.5, p * 0.08);
434
+ const rect = isH
435
+ ? `<rect x="0" y="${p}" width="100" height="${100 - 2 * p}" fill="white" filter="url(#b)"/>`
436
+ : `<rect x="${p}" y="0" width="${100 - 2 * p}" height="100" fill="white" filter="url(#b)"/>`;
437
+ applyMask(outgoing, svgMask(rect, blur));
417
438
  };
418
439
  }
419
440
  }
@@ -455,31 +476,86 @@ function circleEffect(t, _outgoing, incoming) {
455
476
  return;
456
477
  }
457
478
  if (t <= 0) {
458
- const mask = "radial-gradient(circle 0% at 50% 50%, #000 0%, transparent 0%)";
479
+ const mask = "radial-gradient(circle at 50% 50%, #000 0%, transparent 0%)";
459
480
  incoming.style.maskImage = mask;
460
481
  incoming.style.setProperty("-webkit-mask-image", mask);
461
482
  return;
462
483
  }
463
- const r = t * 71;
464
- const edge = Math.max(1, r * 0.12); // ~12% of radius for soft edge
484
+ // Color stops are relative to the gradient ray (farthest-corner by default),
485
+ // so r must reach 100% to fully cover the slide at t=1.
486
+ const r = t * 100;
487
+ const edge = Math.max(1, r * 0.15);
465
488
  const inner = Math.max(0, r - edge);
466
- const mask = `radial-gradient(circle ${r}% at 50% 50%, #000 ${inner}%, transparent ${r + 0.5}%)`;
489
+ const mask = `radial-gradient(circle at 50% 50%, #000 ${inner}%, transparent ${r + 0.5}%)`;
467
490
  incoming.style.maskImage = mask;
468
491
  incoming.style.setProperty("-webkit-mask-image", mask);
469
492
  }
470
- /** Diamond reveal from center. */
493
+ /** Diamond reveal from center with feathered edge via SVG mask + blur. */
471
494
  function diamondEffect(t, _outgoing, incoming) {
472
- const p = t * 50;
473
- incoming.style.clipPath = `polygon(50% ${50 - p}%, ${50 + p}% 50%, 50% ${50 + p}%, ${50 - p}% 50%)`;
495
+ if (t >= 1) {
496
+ clearMask(incoming);
497
+ return;
498
+ }
499
+ const p = t * 100;
500
+ const blur = Math.max(0.5, p * 0.08);
501
+ applyMask(incoming, diamondMaskSvg(p, blur));
502
+ }
503
+ /** Shared helper: wrap SVG shape content into a blurred mask data-URI. */
504
+ function svgMask(shapeContent, blur) {
505
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none">` +
506
+ `<defs><filter id="b" x="-50%" y="-50%" width="200%" height="200%">` +
507
+ `<feGaussianBlur stdDeviation="${blur}"/></filter></defs>` +
508
+ shapeContent +
509
+ `</svg>`;
510
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
474
511
  }
475
- /** Plus/cross reveal from center. */
512
+ function applyMask(el, mask) {
513
+ el.style.maskImage = mask;
514
+ el.style.maskSize = "100% 100%";
515
+ el.style.setProperty("-webkit-mask-image", mask);
516
+ el.style.setProperty("-webkit-mask-size", "100% 100%");
517
+ }
518
+ function clearMask(el) {
519
+ el.style.maskImage = "";
520
+ el.style.setProperty("-webkit-mask-image", "");
521
+ }
522
+ function diamondMaskSvg(p, blur) {
523
+ return svgMask(`<polygon points="50,${50 - p} ${50 + p},50 50,${50 + p} ${50 - p},50" fill="white" filter="url(#b)"/>`, blur);
524
+ }
525
+ /** Plus/cross reveal from center with feathered edge via SVG mask + blur. */
476
526
  function plusEffect(t, _outgoing, incoming) {
477
- const h = t * 50;
478
- const v = t * 50;
479
- incoming.style.clipPath =
480
- `polygon(${50 - v}% 0%, ${50 + v}% 0%, ${50 + v}% ${50 - h}%, 100% ${50 - h}%, ` +
481
- `100% ${50 + h}%, ${50 + v}% ${50 + h}%, ${50 + v}% 100%, ${50 - v}% 100%, ` +
482
- `${50 - v}% ${50 + h}%, 0% ${50 + h}%, 0% ${50 - h}%, ${50 - v}% ${50 - h}%)`;
527
+ if (t >= 1) {
528
+ clearMask(incoming);
529
+ return;
530
+ }
531
+ const p = t * 50;
532
+ const blur = Math.max(0.5, p * 0.08);
533
+ applyMask(incoming, plusMaskSvg(p, blur));
534
+ }
535
+ function plusMaskSvg(p, blur) {
536
+ // Plus shape: a cross centered at (50,50) with arm half-widths of p
537
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none">` +
538
+ `<defs><filter id="b" x="-50%" y="-50%" width="200%" height="200%">` +
539
+ `<feGaussianBlur stdDeviation="${blur}"/></filter></defs>` +
540
+ `<polygon points="${50 - p},0 ${50 + p},0 ${50 + p},${50 - p} 100,${50 - p} ` +
541
+ `100,${50 + p} ${50 + p},${50 + p} ${50 + p},100 ${50 - p},100 ` +
542
+ `${50 - p},${50 + p} 0,${50 + p} 0,${50 - p} ${50 - p},${50 - p}" ` +
543
+ `fill="white" filter="url(#b)"/></svg>`;
544
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
545
+ }
546
+ /** Newsflash: incoming slide expands from a single pixel at center while rotating 360°. */
547
+ function newsflashEffect(t, _outgoing, incoming) {
548
+ if (t >= 1) {
549
+ incoming.style.transform = "";
550
+ incoming.style.opacity = "";
551
+ return;
552
+ }
553
+ // Scale from near-zero to 1, rotate a full 360°.
554
+ const scale = Math.max(0.01, t);
555
+ const deg = -t * 360;
556
+ incoming.style.transform = `scale(${scale}) rotate(${deg}deg)`;
557
+ incoming.style.transformOrigin = "50% 50%";
558
+ incoming.style.opacity = String(t);
483
559
  }
484
560
  /**
485
561
  * Strips: diagonal wipe that reveals the incoming slide from a corner.
@@ -488,125 +564,235 @@ function plusEffect(t, _outgoing, incoming) {
488
564
  function stripsEffect(dir) {
489
565
  return (t, _outgoing, incoming) => {
490
566
  if (t >= 1) {
491
- incoming.style.clipPath = "none";
567
+ clearMask(incoming);
492
568
  return;
493
569
  }
494
570
  if (t <= 0) {
495
- incoming.style.clipPath = "polygon(0% 0%, 0% 0%, 0% 0%)";
571
+ applyMask(incoming, svgMask(`<polygon points="0,0 0,0 0,0" fill="white" filter="url(#b)"/>`, 0));
496
572
  return;
497
573
  }
498
- // d goes from 0 to 200 — the diagonal sweep distance across the rectangle
499
574
  const d = t * 200;
575
+ const blur = Math.max(0.5, t * 50 * 0.08);
500
576
  let points;
501
577
  if (d <= 100) {
502
- // Triangle phase: expanding triangle from the corner
503
578
  switch (dir) {
504
579
  case "rightDown":
505
- points = `0% 0%, ${d}% 0%, 0% ${d}%`;
580
+ points = `0,0 ${d},0 0,${d}`;
506
581
  break;
507
582
  case "leftDown":
508
- points = `100% 0%, ${100 - d}% 0%, 100% ${d}%`;
583
+ points = `100,0 ${100 - d},0 100,${d}`;
509
584
  break;
510
585
  case "rightUp":
511
- points = `0% 100%, ${d}% 100%, 0% ${100 - d}%`;
586
+ points = `0,100 ${d},100 0,${100 - d}`;
512
587
  break;
513
588
  case "leftUp":
514
- points = `100% 100%, ${100 - d}% 100%, 100% ${100 - d}%`;
589
+ points = `100,100 ${100 - d},100 100,${100 - d}`;
515
590
  break;
516
591
  }
517
592
  }
518
593
  else {
519
- // Quadrilateral phase: the diagonal has passed the opposite edges
520
594
  const e = d - 100;
521
595
  switch (dir) {
522
596
  case "rightDown":
523
- points = `0% 0%, 100% 0%, 100% ${e}%, ${e}% 100%, 0% 100%`;
597
+ points = `0,0 100,0 100,${e} ${e},100 0,100`;
524
598
  break;
525
599
  case "leftDown":
526
- points = `100% 0%, 0% 0%, 0% ${e}%, ${100 - e}% 100%, 100% 100%`;
600
+ points = `100,0 0,0 0,${e} ${100 - e},100 100,100`;
527
601
  break;
528
602
  case "rightUp":
529
- points = `0% 100%, 100% 100%, 100% ${100 - e}%, ${e}% 0%, 0% 0%`;
603
+ points = `0,100 100,100 100,${100 - e} ${e},0 0,0`;
530
604
  break;
531
605
  case "leftUp":
532
- points = `100% 100%, 0% 100%, 0% ${100 - e}%, ${100 - e}% 0%, 100% 0%`;
606
+ points = `100,100 0,100 0,${100 - e} ${100 - e},0 100,0`;
533
607
  break;
534
608
  }
535
609
  }
536
- incoming.style.clipPath = `polygon(${points})`;
610
+ applyMask(incoming, svgMask(`<polygon points="${points}" fill="white" filter="url(#b)"/>`, blur));
537
611
  };
538
612
  }
539
613
  // ---------------------------------------------------------------------------
540
614
  // Blinds / Comb
541
615
  // ---------------------------------------------------------------------------
542
- const BLIND_STRIPS = 6;
616
+ const BLIND_STRIPS = 14;
543
617
  /**
544
- * Blinds: 3D flip effect per strip.
545
- * First half: outgoing strips narrow toward their top/left edge (rotating away).
546
- * Second half: incoming strips widen from their top/left edge (rotating toward viewer).
547
- * Simulates each strip flipping over to reveal the new slide on its back.
618
+ * Blinds: CSS 3D box-rotation effect matching PowerPoint.
619
+ *
620
+ * Each strip is modelled as a 3D box whose cross-section is a square
621
+ * (depth === strip height). The front face shows the outgoing slide and the
622
+ * bottom face shows the incoming slide. All boxes rotate 90° around the X
623
+ * axis (horizontal) or Y axis (vertical) at the box centre, so the bottom
624
+ * face rolls into view like a real window blind.
625
+ *
626
+ * All faces share a single `preserve-3d` context rooted on the outgoing
627
+ * snapshot element, so the browser z-sorts faces across strips correctly.
548
628
  */
549
629
  function blindsEffect(orientation) {
550
630
  const N = BLIND_STRIPS;
631
+ const isH = orientation === "horizontal";
632
+ let setup = null;
551
633
  return (t, outgoing, incoming) => {
552
634
  if (t >= 1) {
635
+ incoming.style.opacity = "";
553
636
  incoming.style.clipPath = "none";
554
- outgoing.style.clipPath = "polygon(0% 0%, 0% 0%, 0% 0%)";
637
+ // outgoing (now containing strips) is removed by onComplete
555
638
  return;
556
639
  }
557
- if (t <= 0) {
640
+ // Lazy init: build 3D strip DOM on first frame
641
+ if (!setup) {
642
+ setup = setupBlinds3D(outgoing, incoming, N, isH);
643
+ }
644
+ // Fallback to crossfade if 3D setup failed (no canvases)
645
+ if (!setup || setup.flippers.length === 0) {
558
646
  outgoing.style.zIndex = "1";
559
- incoming.style.clipPath = "polygon(0% 0%, 0% 0%, 0% 0%)";
647
+ outgoing.style.opacity = `${1 - t}`;
560
648
  return;
561
649
  }
562
- if (t < 0.5) {
563
- // First half: outgoing strips narrow (flip away from viewer)
564
- outgoing.style.zIndex = "1";
565
- const frac = 1 - t * 2; // 1 0
566
- const outPoints = [];
567
- if (orientation === "horizontal") {
568
- const stripH = 100 / N;
569
- for (let i = 0; i < N; i++) {
570
- const top = i * stripH;
571
- const bot = top + frac * stripH;
572
- outPoints.push("0% 0%", `0% ${top}%`, `100% ${top}%`, `100% ${bot}%`, `0% ${bot}%`, `0% ${top}%`, "0% 0%");
573
- }
574
- }
575
- else {
576
- const stripW = 100 / N;
577
- for (let i = 0; i < N; i++) {
578
- const left = i * stripW;
579
- const right = left + frac * stripW;
580
- outPoints.push("0% 0%", `${left}% 0%`, `${right}% 0%`, `${right}% 100%`, `${left}% 100%`, `${left}% 0%`, "0% 0%");
581
- }
582
- }
583
- outgoing.style.clipPath = `polygon(${outPoints.join(", ")})`;
584
- incoming.style.clipPath = "polygon(0% 0%, 0% 0%, 0% 0%)";
650
+ // Staggered rotation: center strips start first, edges start last.
651
+ // Each strip's local progress is derived from its distance to center.
652
+ const hd = setup.halfDepth;
653
+ const stagger = 0.7; // fraction of total duration used for stagger spread
654
+ const center = (N - 1) / 2;
655
+ for (let i = 0; i < setup.flippers.length; i++) {
656
+ const dist = Math.abs(i - center) / center; // 0 at center, 1 at edges
657
+ const delay = dist * stagger;
658
+ const stripT = Math.max(0, Math.min(1, (t - delay) / (1 - stagger)));
659
+ const angle = stripT * 90;
660
+ setup.flippers[i].style.transform = isH
661
+ ? `translateZ(-${hd}px) rotateX(${angle}deg)`
662
+ : `translateZ(-${hd}px) rotateY(-${angle}deg)`;
663
+ }
664
+ };
665
+ }
666
+ /**
667
+ * Build N 3D box-strip elements inside the outgoing snapshot.
668
+ *
669
+ * DOM structure (horizontal example):
670
+ * outgoing [perspective, preserve-3d]
671
+ * ├── flipper-0 [preserve-3d, position: absolute, rotateX(angle)]
672
+ * │ ├── front [canvas, translateZ(d/2)]
673
+ * │ └── bottom [canvas, rotateX(-90deg) translateZ(d/2)]
674
+ * ├── flipper-1 ...
675
+ * └── ...
676
+ */
677
+ function setupBlinds3D(outgoing, incoming, N, isH) {
678
+ // The outgoing snapshot is a div (positioned at the slot area) containing a canvas.
679
+ // The incoming is the real spread — find its main canvas.
680
+ const outCanvas = outgoing.querySelector("canvas");
681
+ const inCanvas = incoming.querySelector(".udoc-spread__canvas") ??
682
+ incoming.querySelector("canvas");
683
+ if (!outCanvas || !inCanvas || outCanvas.width === 0 || inCanvas.width === 0) {
684
+ return { flippers: [], halfDepth: 0 };
685
+ }
686
+ // Derive CSS dimensions from the canvas physical size / DPR so they are
687
+ // on exact device-pixel boundaries (the snapshot is built this way).
688
+ const dpr = window.devicePixelRatio || 1;
689
+ const slideW = outCanvas.width / dpr;
690
+ const slideH = outCanvas.height / dpr;
691
+ const d = isH ? slideH / N : slideW / N; // box depth = strip size (square cross-section)
692
+ // Repurpose the disposable snapshot as the container.
693
+ // Since the snapshot is now a canvas positioned at the slot area,
694
+ // all children are placed at (0,0) relative to it — no offset needed.
695
+ outgoing.innerHTML = "";
696
+ outgoing.style.overflow = "visible";
697
+ outgoing.style.zIndex = "1";
698
+ // Hide the real spread during animation (resetIncoming restores on cancel)
699
+ incoming.style.opacity = "0";
700
+ // Black backdrop — flat 2D element, NOT inside the preserve-3d context.
701
+ // Sits behind the 3D scene in normal stacking order (DOM before scene).
702
+ const backdrop = document.createElement("div");
703
+ backdrop.style.position = "absolute";
704
+ backdrop.style.left = "0";
705
+ backdrop.style.top = "0";
706
+ backdrop.style.width = `${slideW}px`;
707
+ backdrop.style.height = `${slideH}px`;
708
+ backdrop.style.background = "#000";
709
+ outgoing.appendChild(backdrop);
710
+ // 3D scene container — preserve-3d + perspective live here,
711
+ // separate from the flat backdrop so it doesn't interfere.
712
+ const scene = document.createElement("div");
713
+ scene.style.position = "absolute";
714
+ scene.style.left = "0";
715
+ scene.style.top = "0";
716
+ scene.style.width = `${slideW}px`;
717
+ scene.style.height = `${slideH}px`;
718
+ scene.style.perspective = `${(isH ? slideH : slideW) * 2}px`;
719
+ scene.style.transformStyle = "preserve-3d";
720
+ outgoing.appendChild(scene);
721
+ const flippers = [];
722
+ for (let i = 0; i < N; i++) {
723
+ // Flipper — the rotating 3D box for this strip
724
+ const flipper = document.createElement("div");
725
+ flipper.style.position = "absolute";
726
+ flipper.style.transformStyle = "preserve-3d";
727
+ const pad = 0.5; // half-pixel overlap to hide sub-pixel seams
728
+ if (isH) {
729
+ flipper.style.left = "0";
730
+ flipper.style.top = `${i * d - pad}px`;
731
+ flipper.style.width = `${slideW}px`;
732
+ flipper.style.height = `${d + pad * 2}px`;
585
733
  }
586
734
  else {
587
- // Second half: incoming strips widen (flip toward viewer)
588
- outgoing.style.opacity = "0";
589
- const frac = (t - 0.5) * 2; // 0 → 1
590
- const inPoints = [];
591
- if (orientation === "horizontal") {
592
- const stripH = 100 / N;
593
- for (let i = 0; i < N; i++) {
594
- const top = i * stripH;
595
- const bot = top + frac * stripH;
596
- inPoints.push("0% 0%", `0% ${top}%`, `100% ${top}%`, `100% ${bot}%`, `0% ${bot}%`, `0% ${top}%`, "0% 0%");
597
- }
598
- }
599
- else {
600
- const stripW = 100 / N;
601
- for (let i = 0; i < N; i++) {
602
- const left = i * stripW;
603
- const right = left + frac * stripW;
604
- inPoints.push("0% 0%", `${left}% 0%`, `${right}% 0%`, `${right}% 100%`, `${left}% 100%`, `${left}% 0%`, "0% 0%");
605
- }
606
- }
607
- incoming.style.clipPath = `polygon(${inPoints.join(", ")})`;
735
+ flipper.style.top = "0";
736
+ flipper.style.left = `${i * d - pad}px`;
737
+ flipper.style.width = `${d + pad * 2}px`;
738
+ flipper.style.height = `${slideH}px`;
608
739
  }
609
- };
740
+ // transform-origin defaults to center — correct for box-centre rotation
741
+ // Front face: outgoing content, pushed forward by d/2
742
+ const front = createBlindFace(outCanvas, i, N, isH, pad);
743
+ front.style.backfaceVisibility = "hidden";
744
+ front.style.transform = `translateZ(${d / 2}px)`;
745
+ // Bottom/right face: incoming content, rotated into position then pushed out
746
+ const bottom = createBlindFace(inCanvas, i, N, isH, pad);
747
+ bottom.style.backfaceVisibility = "hidden";
748
+ bottom.style.transform = isH
749
+ ? `rotateX(-90deg) translateZ(${d / 2}px)`
750
+ : `rotateY(90deg) translateZ(${d / 2}px)`;
751
+ flipper.appendChild(front);
752
+ flipper.appendChild(bottom);
753
+ scene.appendChild(flipper);
754
+ flippers.push(flipper);
755
+ }
756
+ return { flippers, halfDepth: d / 2 };
757
+ }
758
+ /** Create a canvas element showing one strip slice of a source canvas.
759
+ * The slice includes `pad` CSS-pixel overlap on each side so that
760
+ * `width:100%; height:100%` fills the padded flipper without scaling. */
761
+ function createBlindFace(source, index, total, isHorizontal, pad) {
762
+ const dpr = window.devicePixelRatio || 1;
763
+ const padPx = Math.ceil(pad * dpr); // padding in device pixels
764
+ const canvas = document.createElement("canvas");
765
+ canvas.style.position = "absolute";
766
+ canvas.style.top = "0";
767
+ canvas.style.left = "0";
768
+ canvas.style.width = "100%";
769
+ canvas.style.height = "100%";
770
+ canvas.style.display = "block";
771
+ if (isHorizontal) {
772
+ const stripTop = Math.round((index * source.height) / total);
773
+ const stripBot = Math.round(((index + 1) * source.height) / total);
774
+ const sy = Math.max(0, stripTop - padPx);
775
+ const syEnd = Math.min(source.height, stripBot + padPx);
776
+ const sh = syEnd - sy;
777
+ canvas.width = source.width;
778
+ canvas.height = sh;
779
+ const ctx = canvas.getContext("2d");
780
+ if (ctx)
781
+ ctx.drawImage(source, 0, sy, source.width, sh, 0, 0, source.width, sh);
782
+ }
783
+ else {
784
+ const stripLeft = Math.round((index * source.width) / total);
785
+ const stripRight = Math.round(((index + 1) * source.width) / total);
786
+ const sx = Math.max(0, stripLeft - padPx);
787
+ const sxEnd = Math.min(source.width, stripRight + padPx);
788
+ const sw = sxEnd - sx;
789
+ canvas.width = sw;
790
+ canvas.height = source.height;
791
+ const ctx = canvas.getContext("2d");
792
+ if (ctx)
793
+ ctx.drawImage(source, sx, 0, sw, source.height, 0, 0, sw, source.height);
794
+ }
795
+ return canvas;
610
796
  }
611
797
  /**
612
798
  * Comb: N strips revealed from alternating directions.
@@ -677,27 +863,28 @@ function wheelSweepEffect(spokes, clockwise) {
677
863
  const dir = clockwise ? 1 : -1;
678
864
  return (t, _outgoing, incoming) => {
679
865
  if (t >= 1) {
680
- incoming.style.clipPath = "none";
866
+ clearMask(incoming);
681
867
  return;
682
868
  }
683
869
  if (t <= 0) {
684
- incoming.style.clipPath = "polygon(50% 50%, 50% 50%, 50% 50%)";
870
+ applyMask(incoming, svgMask(`<polygon points="50,50 50,50 50,50" fill="white" filter="url(#b)"/>`, 0));
685
871
  return;
686
872
  }
873
+ const blur = Math.max(0.3, t * 50 * 0.06);
687
874
  const sectorAngle = 360 / n;
688
875
  const sweep = t * sectorAngle;
689
- const points = [];
876
+ let path = "";
690
877
  for (let s = 0; s < n; s++) {
691
878
  const startDeg = -90 + s * sectorAngle;
692
- points.push("50% 50%");
879
+ path += "M50,50 ";
693
880
  for (let j = 0; j <= ARC_STEPS; j++) {
694
881
  const deg = startDeg + dir * (j / ARC_STEPS) * sweep;
695
882
  const rad = (deg * Math.PI) / 180;
696
- points.push(`${50 + SWEEP_RADIUS * Math.cos(rad)}% ${50 + SWEEP_RADIUS * Math.sin(rad)}%`);
883
+ path += `L${50 + SWEEP_RADIUS * Math.cos(rad)},${50 + SWEEP_RADIUS * Math.sin(rad)} `;
697
884
  }
698
- points.push("50% 50%");
885
+ path += "Z ";
699
886
  }
700
- incoming.style.clipPath = `polygon(${points.join(", ")})`;
887
+ applyMask(incoming, svgMask(`<path d="${path}" fill="white" filter="url(#b)"/>`, blur));
701
888
  };
702
889
  }
703
890
  /**
@@ -705,28 +892,30 @@ function wheelSweepEffect(spokes, clockwise) {
705
892
  */
706
893
  function wedgeEffect(t, _outgoing, incoming) {
707
894
  if (t >= 1) {
708
- incoming.style.clipPath = "none";
895
+ clearMask(incoming);
709
896
  return;
710
897
  }
711
898
  if (t <= 0) {
712
- incoming.style.clipPath = "polygon(50% 50%, 50% 50%, 50% 50%)";
899
+ applyMask(incoming, svgMask(`<polygon points="50,50 50,50 50,50" fill="white" filter="url(#b)"/>`, 0));
713
900
  return;
714
901
  }
902
+ const blur = Math.max(0.3, t * 50 * 0.06);
715
903
  const sweep = t * 180;
716
- const points = ["50% 50%"];
904
+ let path = "M50,50 ";
717
905
  // Counter-clockwise half
718
906
  for (let j = ARC_STEPS; j >= 0; j--) {
719
907
  const deg = -90 - (j / ARC_STEPS) * sweep;
720
908
  const rad = (deg * Math.PI) / 180;
721
- points.push(`${50 + SWEEP_RADIUS * Math.cos(rad)}% ${50 + SWEEP_RADIUS * Math.sin(rad)}%`);
909
+ path += `L${50 + SWEEP_RADIUS * Math.cos(rad)},${50 + SWEEP_RADIUS * Math.sin(rad)} `;
722
910
  }
723
911
  // Clockwise half
724
912
  for (let j = 0; j <= ARC_STEPS; j++) {
725
913
  const deg = -90 + (j / ARC_STEPS) * sweep;
726
914
  const rad = (deg * Math.PI) / 180;
727
- points.push(`${50 + SWEEP_RADIUS * Math.cos(rad)}% ${50 + SWEEP_RADIUS * Math.sin(rad)}%`);
915
+ path += `L${50 + SWEEP_RADIUS * Math.cos(rad)},${50 + SWEEP_RADIUS * Math.sin(rad)} `;
728
916
  }
729
- incoming.style.clipPath = `polygon(${points.join(", ")})`;
917
+ path += "Z";
918
+ applyMask(incoming, svgMask(`<path d="${path}" fill="white" filter="url(#b)"/>`, blur));
730
919
  }
731
920
  // ---------------------------------------------------------------------------
732
921
  // Checker / Random Bar
@@ -734,79 +923,153 @@ function wedgeEffect(t, _outgoing, incoming) {
734
923
  const CHECKER_COLS = 8;
735
924
  const CHECKER_ROWS = 6;
736
925
  /**
737
- * Checker: 3D flip effect per cell in a checkerboard pattern.
738
- * Each cell flips individually: outgoing cell narrows (first half),
739
- * then incoming cell widens (second half). The flip is staggered
740
- * by position, sweeping "across" or "down" with checkerboard offset.
926
+ * Checker: 3D tile-flip effect in a checkerboard pattern.
927
+ *
928
+ * The slide is divided into a grid of tiles. Each tile is a 3D object
929
+ * with the outgoing slide on the front face and the incoming slide on
930
+ * the back face. Tiles flip 180° around the X axis (across/horizontal)
931
+ * or Y axis (down/vertical), staggered in a checkerboard sweep pattern.
741
932
  */
742
933
  function checkerEffect(orientation) {
743
- const cols = orientation === "horizontal" ? CHECKER_COLS : CHECKER_ROWS;
744
- const rows = orientation === "horizontal" ? CHECKER_ROWS : CHECKER_COLS;
745
- const sweepLen = (orientation === "horizontal" ? cols : rows) + 1;
746
- // Each cell's flip takes 1 sweep-unit. Total time = sweepLen + 1 (for flip duration)
747
- const totalLen = sweepLen + 1;
934
+ const isH = orientation === "horizontal";
935
+ const cols = CHECKER_COLS;
936
+ const rows = CHECKER_ROWS;
937
+ let setup = null;
938
+ // Sweep across columns (horizontal) or down rows (vertical),
939
+ // with checkerboard phase offset.
940
+ const sweepLen = (isH ? cols : rows) + 1;
941
+ const flipDur = 3; // each tile takes 3 sweep-units to flip
942
+ const totalLen = sweepLen + flipDur;
748
943
  return (t, outgoing, incoming) => {
749
944
  if (t >= 1) {
945
+ incoming.style.opacity = "";
750
946
  incoming.style.clipPath = "none";
751
- outgoing.style.clipPath = "polygon(0% 0%, 0% 0%, 0% 0%)";
752
947
  return;
753
948
  }
754
- const cellW = 100 / cols;
755
- const cellH = 100 / rows;
949
+ if (!setup) {
950
+ setup = setupChecker3D(outgoing, incoming, cols, rows, isH);
951
+ }
756
952
  const sweep = t * totalLen;
757
- const outPoints = [];
758
- const inPoints = [];
759
953
  for (let r = 0; r < rows; r++) {
760
954
  for (let c = 0; c < cols; c++) {
761
- const pos = orientation === "horizontal" ? c : r;
955
+ const idx = r * cols + c;
956
+ const pos = isH ? c : r;
762
957
  const phase = (r + c) % 2;
763
958
  const cellStart = pos + phase;
764
- // cellT: 0 = not started, 0→0.5 = outgoing shrinks, 0.5→1 = incoming grows
765
- const cellT = Math.max(0, Math.min(1, sweep - cellStart));
766
- const left = c * cellW;
767
- const top = r * cellH;
768
- if (cellT <= 0) {
769
- // Cell hasn't started flipping — outgoing fully visible
770
- outPoints.push("0% 0%", `${left}% ${top}%`, `${left + cellW}% ${top}%`, `${left + cellW}% ${top + cellH}%`, `${left}% ${top + cellH}%`, `${left}% ${top}%`, "0% 0%");
771
- }
772
- else if (cellT < 0.5) {
773
- // First half: outgoing cell narrows (flip away)
774
- const frac = 1 - cellT * 2;
775
- if (orientation === "horizontal") {
776
- const bot = top + frac * cellH;
777
- outPoints.push("0% 0%", `${left}% ${top}%`, `${left + cellW}% ${top}%`, `${left + cellW}% ${bot}%`, `${left}% ${bot}%`, `${left}% ${top}%`, "0% 0%");
778
- }
779
- else {
780
- const right = left + frac * cellW;
781
- outPoints.push("0% 0%", `${left}% ${top}%`, `${right}% ${top}%`, `${right}% ${top + cellH}%`, `${left}% ${top + cellH}%`, `${left}% ${top}%`, "0% 0%");
782
- }
783
- }
784
- else {
785
- // Second half: incoming cell widens (flip toward viewer)
786
- const frac = (cellT - 0.5) * 2;
787
- if (orientation === "horizontal") {
788
- const bot = top + frac * cellH;
789
- inPoints.push("0% 0%", `${left}% ${top}%`, `${left + cellW}% ${top}%`, `${left + cellW}% ${bot}%`, `${left}% ${bot}%`, `${left}% ${top}%`, "0% 0%");
790
- }
791
- else {
792
- const right = left + frac * cellW;
793
- inPoints.push("0% 0%", `${left}% ${top}%`, `${right}% ${top}%`, `${right}% ${top + cellH}%`, `${left}% ${top + cellH}%`, `${left}% ${top}%`, "0% 0%");
794
- }
795
- }
959
+ const cellT = Math.max(0, Math.min(1, (sweep - cellStart) / flipDur));
960
+ const angle = cellT * 180;
961
+ setup.flippers[idx].style.transform = isH ? `rotateY(-${angle}deg)` : `rotateX(${angle}deg)`;
796
962
  }
797
963
  }
798
- outgoing.style.zIndex = "1";
799
- outgoing.style.clipPath = outPoints.length
800
- ? `polygon(${outPoints.join(", ")})`
801
- : "polygon(0% 0%, 0% 0%, 0% 0%)";
802
- incoming.style.clipPath = inPoints.length ? `polygon(${inPoints.join(", ")})` : "polygon(0% 0%, 0% 0%, 0% 0%)";
803
964
  };
804
965
  }
966
+ /**
967
+ * Build a grid of 3D flipper tiles for the checker transition.
968
+ *
969
+ * DOM structure:
970
+ * outgoing [container]
971
+ * ├── backdrop [black background]
972
+ * └── scene [perspective, preserve-3d]
973
+ * ├── flipper-0,0 [preserve-3d, rotateX/Y]
974
+ * │ ├── front [canvas tile of outgoing]
975
+ * │ └── back [canvas tile of incoming, rotated 180°]
976
+ * ├── flipper-0,1 ...
977
+ * └── ...
978
+ */
979
+ function setupChecker3D(outgoing, incoming, cols, rows, isH) {
980
+ const outCanvas = outgoing.querySelector("canvas");
981
+ const inCanvas = incoming.querySelector(".udoc-spread__canvas") ??
982
+ incoming.querySelector("canvas");
983
+ if (!outCanvas || !inCanvas || outCanvas.width === 0 || inCanvas.width === 0) {
984
+ return { flippers: [] };
985
+ }
986
+ // Derive CSS dimensions from the canvas physical size / DPR so they are
987
+ // on exact device-pixel boundaries (the snapshot is built this way).
988
+ const dpr = window.devicePixelRatio || 1;
989
+ const slideW = outCanvas.width / dpr;
990
+ const slideH = outCanvas.height / dpr;
991
+ const tileW = slideW / cols;
992
+ const tileH = slideH / rows;
993
+ outgoing.innerHTML = "";
994
+ outgoing.style.overflow = "visible";
995
+ outgoing.style.zIndex = "1";
996
+ incoming.style.opacity = "0";
997
+ const backdrop = document.createElement("div");
998
+ backdrop.style.position = "absolute";
999
+ backdrop.style.left = "0";
1000
+ backdrop.style.top = "0";
1001
+ backdrop.style.width = `${slideW}px`;
1002
+ backdrop.style.height = `${slideH}px`;
1003
+ backdrop.style.background = "#000";
1004
+ outgoing.appendChild(backdrop);
1005
+ const scene = document.createElement("div");
1006
+ scene.style.position = "absolute";
1007
+ scene.style.left = "0";
1008
+ scene.style.top = "0";
1009
+ scene.style.width = `${slideW}px`;
1010
+ scene.style.height = `${slideH}px`;
1011
+ scene.style.perspective = `${Math.max(slideW, slideH) * 2}px`;
1012
+ scene.style.transformStyle = "preserve-3d";
1013
+ outgoing.appendChild(scene);
1014
+ const flippers = [];
1015
+ for (let r = 0; r < rows; r++) {
1016
+ for (let c = 0; c < cols; c++) {
1017
+ const flipper = document.createElement("div");
1018
+ flipper.style.position = "absolute";
1019
+ flipper.style.transformStyle = "preserve-3d";
1020
+ const pad = 0.5; // half-pixel overlap to hide sub-pixel seams
1021
+ flipper.style.left = `${c * tileW - pad}px`;
1022
+ flipper.style.top = `${r * tileH - pad}px`;
1023
+ flipper.style.width = `${tileW + pad * 2}px`;
1024
+ flipper.style.height = `${tileH + pad * 2}px`;
1025
+ const front = createCheckerFace(outCanvas, c, r, cols, rows, pad);
1026
+ front.style.backfaceVisibility = "hidden";
1027
+ const back = createCheckerFace(inCanvas, c, r, cols, rows, pad);
1028
+ back.style.backfaceVisibility = "hidden";
1029
+ back.style.transform = isH ? "rotateY(-180deg)" : "rotateX(180deg)";
1030
+ flipper.appendChild(front);
1031
+ flipper.appendChild(back);
1032
+ scene.appendChild(flipper);
1033
+ flippers.push(flipper);
1034
+ }
1035
+ }
1036
+ return { flippers };
1037
+ }
1038
+ /** Create a canvas element showing one tile of a source canvas.
1039
+ * The slice includes `pad` CSS-pixel overlap on each side so that
1040
+ * `width:100%; height:100%` fills the padded flipper without scaling. */
1041
+ function createCheckerFace(source, col, row, cols, rows, pad) {
1042
+ const dpr = window.devicePixelRatio || 1;
1043
+ const padPx = Math.ceil(pad * dpr);
1044
+ const canvas = document.createElement("canvas");
1045
+ canvas.style.position = "absolute";
1046
+ canvas.style.top = "0";
1047
+ canvas.style.left = "0";
1048
+ canvas.style.width = "100%";
1049
+ canvas.style.height = "100%";
1050
+ canvas.style.display = "block";
1051
+ const tileLeft = Math.round((col * source.width) / cols);
1052
+ const tileRight = Math.round(((col + 1) * source.width) / cols);
1053
+ const tileTop = Math.round((row * source.height) / rows);
1054
+ const tileBot = Math.round(((row + 1) * source.height) / rows);
1055
+ const sx = Math.max(0, tileLeft - padPx);
1056
+ const sxEnd = Math.min(source.width, tileRight + padPx);
1057
+ const sy = Math.max(0, tileTop - padPx);
1058
+ const syEnd = Math.min(source.height, tileBot + padPx);
1059
+ const sw = sxEnd - sx;
1060
+ const sh = syEnd - sy;
1061
+ canvas.width = sw;
1062
+ canvas.height = sh;
1063
+ const ctx = canvas.getContext("2d");
1064
+ if (ctx)
1065
+ ctx.drawImage(source, sx, sy, sw, sh, 0, 0, sw, sh);
1066
+ return canvas;
1067
+ }
805
1068
  // ---------------------------------------------------------------------------
806
1069
  // Dissolve
807
1070
  // ---------------------------------------------------------------------------
808
- const DISSOLVE_COLS = 12;
809
- const DISSOLVE_ROWS = 8;
1071
+ const DISSOLVE_COLS = 60;
1072
+ const DISSOLVE_ROWS = 40;
810
1073
  /**
811
1074
  * Dissolve: random grid cells appear progressively, approximating PowerPoint's
812
1075
  * pixelated dissolve pattern. Cells snap fully visible in shuffled order.