animot-presenter 0.5.23 → 0.5.24

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.
@@ -29,6 +29,42 @@
29
29
 
30
30
  type TweenValue = ReturnType<typeof tween<number>>;
31
31
 
32
+ // Svelte's underlying Tween.set() leaks: when retargeted before completion,
33
+ // the previous rAF task is aborted but its promise is NEVER fulfilled
34
+ // (see svelte/src/internal/client/loop.js — abort() only deletes the task,
35
+ // doesn't call fulfill). The presenter calls .to() on 15+ tween properties
36
+ // per element per slide transition, so a deck running in a loop accumulates
37
+ // hundreds of thousands of orphaned promises plus the async-function
38
+ // Contexts of any awaiter that's stuck on them. wrapTween() patches each
39
+ // tween instance so:
40
+ // 1) .to() on a value the tween is already at is a no-op (Promise.resolve)
41
+ // 2) Each .to() resolves the previous wrapper promise immediately on
42
+ // retarget, so any await chain unwinds and releases its Context even
43
+ // if the underlying Svelte promise is left dangling.
44
+ function wrapTween<TV extends { current: unknown; to: (v: never, o?: unknown) => Promise<void> }>(tv: TV): TV {
45
+ const origTo = tv.to.bind(tv);
46
+ let prevResolve: (() => void) | null = null;
47
+ (tv as unknown as { to: (v: unknown, o?: unknown) => Promise<void> }).to = (value, options) => {
48
+ if (tv.current === value) {
49
+ if (prevResolve) { const r = prevResolve; prevResolve = null; r(); }
50
+ return Promise.resolve();
51
+ }
52
+ if (prevResolve) { const r = prevResolve; prevResolve = null; r(); }
53
+ const inner = origTo(value as never, options);
54
+ return new Promise<void>((resolve) => {
55
+ prevResolve = resolve;
56
+ const done = () => {
57
+ if (prevResolve === resolve) { prevResolve = null; resolve(); }
58
+ };
59
+ inner.then(done, done);
60
+ });
61
+ };
62
+ return tv;
63
+ }
64
+ function mkTween<T>(value: T, options?: Parameters<typeof tween<T>>[1]): ReturnType<typeof tween<T>> {
65
+ return wrapTween(tween(value, options)) as ReturnType<typeof tween<T>>;
66
+ }
67
+
32
68
  interface AnimatedElement {
33
69
  x: TweenValue; y: TweenValue; width: TweenValue; height: TweenValue;
34
70
  rotation: TweenValue; skewX: TweenValue; skewY: TweenValue;
@@ -49,6 +85,33 @@
49
85
 
50
86
  interface ShapeMorphState { fromType: string; toType: string; }
51
87
 
88
+ // Race a promise against an AbortSignal so awaits unwind the instant a
89
+ // loop is cancelled — otherwise tween.to() / setTimeout promises keep
90
+ // pending and pin their async-function Context to the heap. Long-running
91
+ // loops without this leak millions of closure contexts (see commit notes).
92
+ function abortable<T>(p: Promise<T>, signal: AbortSignal): Promise<T> {
93
+ if (signal.aborted) return Promise.reject(new DOMException('aborted', 'AbortError'));
94
+ return new Promise<T>((resolve, reject) => {
95
+ const onAbort = () => reject(new DOMException('aborted', 'AbortError'));
96
+ signal.addEventListener('abort', onAbort, { once: true });
97
+ p.then(
98
+ (v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
99
+ (e) => { signal.removeEventListener('abort', onAbort); reject(e); }
100
+ );
101
+ });
102
+ }
103
+ function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
104
+ if (signal.aborted) return Promise.reject(new DOMException('aborted', 'AbortError'));
105
+ return new Promise<void>((resolve, reject) => {
106
+ const id = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms);
107
+ const onAbort = () => { clearTimeout(id); reject(new DOMException('aborted', 'AbortError')); };
108
+ signal.addEventListener('abort', onAbort, { once: true });
109
+ });
110
+ }
111
+ function isAbortError(e: unknown): boolean {
112
+ return !!(e && typeof e === 'object' && (e as { name?: string }).name === 'AbortError');
113
+ }
114
+
52
115
  // Active motion path loop cancellation tokens
53
116
  let motionPathLoopAbort: AbortController | null = null;
54
117
  function cancelMotionPathLoops() {
@@ -60,12 +123,20 @@
60
123
  // tween-driven render. This implementation only retargets existing tweens
61
124
  // via setTimeout; nothing here is reactive, nothing is read from render.
62
125
  let keyframeLoopAbort: AbortController | null = null;
126
+ // Scheduled-but-not-yet-fired keyframe timeouts. We track these so cancel
127
+ // also clears them — otherwise their closures (which capture `signal`,
128
+ // `animated`, etc.) survive until natural firing time and pin a Context.
129
+ let keyframeTimeouts: ReturnType<typeof setTimeout>[] = [];
63
130
  // Per-element overrides for keyframe-driven props that aren't tweened
64
131
  // (backgroundColor, text color). The schedule writes these at each
65
132
  // keyframe boundary; liveProps reads them at render time.
66
133
  let keyframeOverrides = $state<Map<string, Record<string, any>>>(new Map());
67
134
  function cancelKeyframeLoops() {
68
135
  if (keyframeLoopAbort) { keyframeLoopAbort.abort(); keyframeLoopAbort = null; }
136
+ if (keyframeTimeouts.length) {
137
+ for (const id of keyframeTimeouts) clearTimeout(id);
138
+ keyframeTimeouts = [];
139
+ }
69
140
  }
70
141
  function setKeyframeOverride(elementId: string, prop: string, value: any) {
71
142
  const cur = keyframeOverrides.get(elementId) ?? {};
@@ -115,7 +186,7 @@
115
186
  if (first.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', first.backgroundColor);
116
187
  if (first.color !== undefined) setKeyframeOverride(element.id, 'color', first.color);
117
188
  for (const kf of sorted.slice(1)) {
118
- setTimeout(() => {
189
+ const tid = setTimeout(() => {
119
190
  if (signal.aborted) return;
120
191
  const easing = easingForTween(kf.easing);
121
192
  const idx = sorted.indexOf(kf);
@@ -142,6 +213,7 @@
142
213
  if (kf.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', kf.backgroundColor);
143
214
  if (kf.color !== undefined) setKeyframeOverride(element.id, 'color', kf.color);
144
215
  }, Math.max(0, kf.time));
216
+ keyframeTimeouts.push(tid);
145
217
  }
146
218
  }
147
219
  }
@@ -570,29 +642,29 @@
570
642
  const seedGray = k0?.grayscale !== undefined ? k0.grayscale : (element.grayscale ?? 0);
571
643
 
572
644
  animatedElements.set(element.id, {
573
- x: tween(seedX, { duration: 500 }),
574
- y: tween(seedY, { duration: 500 }),
575
- width: tween(seedW, { duration: 500 }),
576
- height: tween(seedH, { duration: 500 }),
577
- rotation: tween(seedRot, { duration: 500 }),
578
- skewX: tween(seedSkewX, { duration: 500 }),
579
- skewY: tween(seedSkewY, { duration: 500 }),
580
- tiltX: tween(seedTiltX, { duration: 500 }),
581
- tiltY: tween(seedTiltY, { duration: 500 }),
582
- perspective: tween(element.perspective ?? 1000, { duration: 500 }),
583
- opacity: tween(startOpacity, { duration: 300 }),
584
- borderRadius: tween(seedBR, { duration: 500 }),
585
- fontSize: textEl ? tween(seedFontSize, { duration: 500 }) : null,
586
- fillColor: shapeEl ? tween(seedFill, { duration: 500 }) : null,
587
- strokeColor: shapeEl ? tween(seedStroke, { duration: 500 }) : null,
588
- strokeWidth: shapeEl ? tween(seedStrokeW, { duration: 500 }) : null,
589
- shapeMorph: shapeEl ? tween(1, { duration: 500 }) : null,
590
- motionPathProgress: element.motionPathConfig ? tween(0, { duration: 500 }) : null,
591
- blur: tween(seedBlur, { duration: 500 }),
592
- brightness: tween(seedBright, { duration: 500 }),
593
- contrast: tween(seedContrast, { duration: 500 }),
594
- saturate: tween(seedSat, { duration: 500 }),
595
- grayscale: tween(seedGray, { duration: 500 })
645
+ x: mkTween(seedX, { duration: 500 }),
646
+ y: mkTween(seedY, { duration: 500 }),
647
+ width: mkTween(seedW, { duration: 500 }),
648
+ height: mkTween(seedH, { duration: 500 }),
649
+ rotation: mkTween(seedRot, { duration: 500 }),
650
+ skewX: mkTween(seedSkewX, { duration: 500 }),
651
+ skewY: mkTween(seedSkewY, { duration: 500 }),
652
+ tiltX: mkTween(seedTiltX, { duration: 500 }),
653
+ tiltY: mkTween(seedTiltY, { duration: 500 }),
654
+ perspective: mkTween(element.perspective ?? 1000, { duration: 500 }),
655
+ opacity: mkTween(startOpacity, { duration: 300 }),
656
+ borderRadius: mkTween(seedBR, { duration: 500 }),
657
+ fontSize: textEl ? mkTween(seedFontSize, { duration: 500 }) : null,
658
+ fillColor: shapeEl ? mkTween(seedFill, { duration: 500 }) : null,
659
+ strokeColor: shapeEl ? mkTween(seedStroke, { duration: 500 }) : null,
660
+ strokeWidth: shapeEl ? mkTween(seedStrokeW, { duration: 500 }) : null,
661
+ shapeMorph: shapeEl ? mkTween(1, { duration: 500 }) : null,
662
+ motionPathProgress: element.motionPathConfig ? mkTween(0, { duration: 500 }) : null,
663
+ blur: mkTween(seedBlur, { duration: 500 }),
664
+ brightness: mkTween(seedBright, { duration: 500 }),
665
+ contrast: mkTween(seedContrast, { duration: 500 }),
666
+ saturate: mkTween(seedSat, { duration: 500 }),
667
+ grayscale: mkTween(seedGray, { duration: 500 })
596
668
  });
597
669
  const currentSlideEl = getElementInSlide(currentSlide, element.id);
598
670
  elementContent.set(element.id, JSON.parse(JSON.stringify(currentSlideEl || element)));
@@ -631,16 +703,20 @@
631
703
  if (shouldLoop) {
632
704
  const laps = element.motionPathConfig.laps ?? 0;
633
705
  (async () => {
634
- let lap = 0;
635
- while (!signal.aborted && (laps === 0 || lap < laps)) {
636
- await animated.motionPathProgress!.to(0, { duration: 0 });
637
- await animated.motionPathProgress!.to(1, { duration, easing });
638
- lap++;
639
- if (!signal.aborted && (laps === 0 || lap < laps)) await new Promise(r => setTimeout(r, 50));
706
+ try {
707
+ let lap = 0;
708
+ while (!signal.aborted && (laps === 0 || lap < laps)) {
709
+ await abortable(animated.motionPathProgress!.to(0, { duration: 0 }), signal);
710
+ await abortable(animated.motionPathProgress!.to(1, { duration, easing }), signal);
711
+ lap++;
712
+ if (!signal.aborted && (laps === 0 || lap < laps)) await abortableSleep(50, signal);
713
+ }
714
+ } catch (e) {
715
+ if (!isAbortError(e)) throw e;
640
716
  }
641
717
  })();
642
718
  } else {
643
- animated.motionPathProgress.to(1, { duration, easing });
719
+ animated.motionPathProgress.to(1, { duration, easing }).catch(() => {});
644
720
  }
645
721
  }
646
722
  }
@@ -1593,15 +1669,15 @@
1593
1669
  {@const morphProgress = animated.shapeMorph?.current ?? 1}
1594
1670
  {@const effectiveShapeType = mState ? (morphProgress >= 1 ? mState.toType : (morphProgress <= 0 ? mState.fromType : null)) : shapeEl.shapeType}
1595
1671
  {@const isMorphing = mState && morphProgress > 0 && morphProgress < 1}
1596
- <svg class="animot-shape-element" viewBox="0 0 {animated.width.current} {animated.height.current}" fill-opacity={shapeEl.fillOpacity ?? 1} stroke-opacity={shapeEl.strokeOpacity ?? 1} style:filter={shapeEl.boxShadow?.enabled ? `drop-shadow(${shapeEl.boxShadow.offsetX}px ${shapeEl.boxShadow.offsetY}px ${shapeEl.boxShadow.blur}px ${shapeEl.boxShadow.color})` : 'none'}>
1672
+ <svg class="animot-shape-element" viewBox="0 0 {Math.max(0, animated.width.current)} {Math.max(0, animated.height.current)}" fill-opacity={shapeEl.fillOpacity ?? 1} stroke-opacity={shapeEl.strokeOpacity ?? 1} style:filter={shapeEl.boxShadow?.enabled ? `drop-shadow(${shapeEl.boxShadow.offsetX}px ${shapeEl.boxShadow.offsetY}px ${shapeEl.boxShadow.blur}px ${shapeEl.boxShadow.color})` : 'none'}>
1597
1673
  {#if isMorphing}
1598
- {@const w = animated.width.current}
1599
- {@const h = animated.height.current}
1674
+ {@const w = Math.max(0, animated.width.current)}
1675
+ {@const h = Math.max(0, animated.height.current)}
1600
1676
  {@const sw = animStrokeWidth}
1601
1677
  <g style:opacity={1 - morphProgress}>{@html renderShape(mState!.fromType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1602
1678
  <g style:opacity={morphProgress}>{@html renderShape(mState!.toType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1603
1679
  {:else}
1604
- {@html renderShape(effectiveShapeType ?? shapeEl.shapeType, animated.width.current, animated.height.current, animated.borderRadius.current, animFill, animStroke, animStrokeWidth, shapeEl.strokeStyle, shapeEl.strokeDashGap)}
1680
+ {@html renderShape(effectiveShapeType ?? shapeEl.shapeType, Math.max(0, animated.width.current), Math.max(0, animated.height.current), animated.borderRadius.current, animFill, animStroke, animStrokeWidth, shapeEl.strokeStyle, shapeEl.strokeDashGap)}
1605
1681
  {/if}
1606
1682
  </svg>
1607
1683
  {:else if element.type === 'counter'}
@@ -1650,7 +1726,7 @@
1650
1726
  {:else if element.type === 'motionPath'}
1651
1727
  {@const mpEl = element as MotionPathElement}
1652
1728
  {#if mpEl.showInPresentation}
1653
- <svg width="100%" height="100%" viewBox="0 0 {animated.width.current} {animated.height.current}" style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible;">
1729
+ <svg width="100%" height="100%" viewBox="0 0 {Math.max(0, animated.width.current)} {Math.max(0, animated.height.current)}" style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible;">
1654
1730
  <path d={buildPresenterPathD(mpEl.points, mpEl.closed)} stroke={mpEl.pathColor} stroke-width={mpEl.pathWidth} fill="none" stroke-dasharray="8 4" />
1655
1731
  </svg>
1656
1732
  {/if}
@@ -1719,6 +1795,8 @@
1719
1795
  }
1720
1796
 
1721
1797
  function renderShape(type: string, w: number, h: number, br: number, fill: string, stroke: string, sw: number, strokeStyle?: string, strokeDashGap?: number): string {
1798
+ const nn = (v: number) => (v > 0 ? v : 0);
1799
+ w = nn(w); h = nn(h); br = nn(br); sw = nn(sw);
1722
1800
  let dashAttr = '';
1723
1801
  if (strokeStyle && strokeStyle !== 'solid') {
1724
1802
  const s = sw || 1;
@@ -1732,17 +1810,17 @@
1732
1810
  return `<polygon points="${pts}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr} stroke-linejoin="round"/>`;
1733
1811
  };
1734
1812
  switch (type) {
1735
- case 'rectangle': return `<rect x="${sw/2}" y="${sw/2}" width="${w-sw}" height="${h-sw}" rx="${br}" ry="${br}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1736
- case 'circle': return `<circle cx="${w/2}" cy="${h/2}" r="${Math.min(w,h)/2-sw/2}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1737
- case 'ellipse': return `<ellipse cx="${w/2}" cy="${h/2}" rx="${w/2-sw/2}" ry="${h/2-sw/2}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1813
+ case 'rectangle': return `<rect x="${sw/2}" y="${sw/2}" width="${nn(w-sw)}" height="${nn(h-sw)}" rx="${nn(Math.min(br, (w-sw)/2, (h-sw)/2))}" ry="${nn(Math.min(br, (w-sw)/2, (h-sw)/2))}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1814
+ case 'circle': return `<circle cx="${w/2}" cy="${h/2}" r="${nn(Math.min(w,h)/2-sw/2)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1815
+ case 'ellipse': return `<ellipse cx="${w/2}" cy="${h/2}" rx="${nn(w/2-sw/2)}" ry="${nn(h/2-sw/2)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"${dashAttr}/>`;
1738
1816
  case 'triangle': return polyOrPath(`${w/2},${sw/2} ${sw/2},${h-sw/2} ${w-sw/2},${h-sw/2}`);
1739
1817
  case 'star': {
1740
- const cx = w/2, cy = h/2, outerR = Math.min(w,h)/2-sw/2, innerR = outerR*0.4;
1818
+ const cx = w/2, cy = h/2, outerR = nn(Math.min(w,h)/2-sw/2), innerR = outerR*0.4;
1741
1819
  const pts = Array.from({length:10},(_,i)=>{const a=(i*Math.PI/5)-Math.PI/2;const r=i%2===0?outerR:innerR;return`${cx+r*Math.cos(a)},${cy+r*Math.sin(a)}`;}).join(' ');
1742
1820
  return polyOrPath(pts);
1743
1821
  }
1744
1822
  case 'hexagon': {
1745
- const cx = w/2, cy = h/2, r = Math.min(w,h)/2-sw/2;
1823
+ const cx = w/2, cy = h/2, r = nn(Math.min(w,h)/2-sw/2);
1746
1824
  const pts = Array.from({length:6},(_,i)=>{const a=(i*Math.PI/3)-Math.PI/2;return`${cx+r*Math.cos(a)},${cy+r*Math.sin(a)}`;}).join(' ');
1747
1825
  return polyOrPath(pts);
1748
1826
  }