animot-presenter 0.5.21 → 0.5.23

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.
@@ -55,8 +55,14 @@
55
55
  if (motionPathLoopAbort) { motionPathLoopAbort.abort(); motionPathLoopAbort = null; }
56
56
  }
57
57
 
58
- // Keyframe schedule loop independent from motion-path / morph engines.
58
+ // Keyframe schedule loop. Plain (non-reactive) module-scope state adding
59
+ // $state for keyframe overrides in 0.5.20 broke reactivity for the existing
60
+ // tween-driven render. This implementation only retargets existing tweens
61
+ // via setTimeout; nothing here is reactive, nothing is read from render.
59
62
  let keyframeLoopAbort: AbortController | null = null;
63
+ // Per-element overrides for keyframe-driven props that aren't tweened
64
+ // (backgroundColor, text color). The schedule writes these at each
65
+ // keyframe boundary; liveProps reads them at render time.
60
66
  let keyframeOverrides = $state<Map<string, Record<string, any>>>(new Map());
61
67
  function cancelKeyframeLoops() {
62
68
  if (keyframeLoopAbort) { keyframeLoopAbort.abort(); keyframeLoopAbort = null; }
@@ -66,27 +72,28 @@
66
72
  keyframeOverrides.set(elementId, { ...cur, [prop]: value });
67
73
  keyframeOverrides = new Map(keyframeOverrides);
68
74
  }
69
- function easingForTween(name: string | undefined): string {
70
- // Animotion tween easing accepts strings; pass-through with default.
71
- switch (name) {
72
- case 'linear': case 'ease-in': case 'ease-out': case 'ease-in-out': return name;
73
- case 'spring': return 'ease-out';
74
- case 'ease': default: return 'ease-out';
75
- }
75
+ // Tweens take an easing FUNCTION (t→number), not a CSS keyword. Returning
76
+ // a string here was the cause of "r is not a function" thrown deep in the
77
+ // tween animation loop — `r(t)` crashed because `r` was the literal string
78
+ // passed in. Reuse the engine's `getEasingFn` so keyframe easing matches
79
+ // the slide-morph engine's vocabulary.
80
+ function easingForTween(name: string | undefined): (t: number) => number {
81
+ return getEasingFn(name ?? 'ease-out');
76
82
  }
77
83
  function animateKeyframes(slide: Slide) {
78
84
  cancelKeyframeLoops();
85
+ const hasAnyKeyframes = slide.canvas.elements.some((el) => el.keyframes && el.keyframes.length > 0);
86
+ if (keyframeOverrides.size > 0) keyframeOverrides = new Map();
87
+ if (!hasAnyKeyframes) return;
79
88
  keyframeLoopAbort = new AbortController();
80
89
  const signal = keyframeLoopAbort.signal;
81
- if (keyframeOverrides.size > 0) keyframeOverrides = new Map();
82
90
  for (const element of slide.canvas.elements) {
83
91
  if (!element.keyframes || element.keyframes.length === 0) continue;
84
92
  const animated = animatedElements.get(element.id) as any;
85
93
  if (!animated) continue;
86
94
  const sorted = [...element.keyframes].sort((a, b) => a.time - b.time);
87
95
  const first = sorted[0];
88
-
89
- // Snap to KF1 instantly so the slide-displayed pose IS the start.
96
+ // Snap tweens to KF1 instantly so the slide-displayed pose IS the start.
90
97
  if (first.position) { animated.x?.to(first.position.x, { duration: 0 }); animated.y?.to(first.position.y, { duration: 0 }); }
91
98
  if (first.size) { animated.width?.to(first.size.width, { duration: 0 }); animated.height?.to(first.size.height, { duration: 0 }); }
92
99
  if (first.rotation !== undefined) animated.rotation?.to(first.rotation, { duration: 0 });
@@ -107,9 +114,7 @@
107
114
  if (first.grayscale !== undefined) animated.grayscale?.to(first.grayscale, { duration: 0 });
108
115
  if (first.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', first.backgroundColor);
109
116
  if (first.color !== undefined) setKeyframeOverride(element.id, 'color', first.color);
110
-
111
117
  for (const kf of sorted.slice(1)) {
112
- const delay = Math.max(0, kf.time);
113
118
  setTimeout(() => {
114
119
  if (signal.aborted) return;
115
120
  const easing = easingForTween(kf.easing);
@@ -136,7 +141,7 @@
136
141
  if (kf.grayscale !== undefined) animated.grayscale?.to(kf.grayscale, { duration: span, easing });
137
142
  if (kf.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', kf.backgroundColor);
138
143
  if (kf.color !== undefined) setKeyframeOverride(element.id, 'color', kf.color);
139
- }, delay);
144
+ }, Math.max(0, kf.time));
140
145
  }
141
146
  }
142
147
  }
@@ -350,54 +355,15 @@
350
355
  return elements.map(e => e.id);
351
356
  });
352
357
 
353
- /**
354
- * Resolve an element's pose on a slide, taking per-element keyframes
355
- * into account. Keyframes turn the element's "effective state" into
356
- * something time-dependent — `pose='enter'` returns the FIRST keyframe's
357
- * state (used as morph TARGET / initial-tween-seed), `pose='exit'` the
358
- * LAST keyframe's (used as morph SOURCE on slide leave).
359
- */
360
- function getElementInSlide(slide: Slide | null, elementId: string, pose: 'enter' | 'exit' = 'enter'): CanvasElement | undefined {
361
- const el = slide?.canvas.elements.find(el => el.id === elementId);
362
- if (!el) return el;
363
- if (!el.keyframes || el.keyframes.length === 0) return el;
364
- const sorted = [...el.keyframes].sort((a, b) => a.time - b.time);
365
- const k = pose === 'exit' ? sorted[sorted.length - 1] : sorted[0];
366
- const out: any = { ...el };
367
- if (k.position) out.position = k.position;
368
- if (k.size) out.size = k.size;
369
- if (k.rotation !== undefined) out.rotation = k.rotation;
370
- if (k.opacity !== undefined) out.opacity = k.opacity;
371
- if (k.skewX !== undefined) out.skewX = k.skewX;
372
- if (k.skewY !== undefined) out.skewY = k.skewY;
373
- if (k.tiltX !== undefined) out.tiltX = k.tiltX;
374
- if (k.tiltY !== undefined) out.tiltY = k.tiltY;
375
- if (k.borderRadius !== undefined) out.borderRadius = k.borderRadius;
376
- if (k.fontSize !== undefined) out.fontSize = k.fontSize;
377
- if (k.fillColor !== undefined) out.fillColor = k.fillColor;
378
- if (k.strokeColor !== undefined) out.strokeColor = k.strokeColor;
379
- if (k.strokeWidth !== undefined) out.strokeWidth = k.strokeWidth;
380
- if (k.blur !== undefined) out.blur = k.blur;
381
- if (k.brightness !== undefined) out.brightness = k.brightness;
382
- if (k.contrast !== undefined) out.contrast = k.contrast;
383
- if (k.saturate !== undefined) out.saturate = k.saturate;
384
- if (k.grayscale !== undefined) out.grayscale = k.grayscale;
385
- return out as CanvasElement;
358
+ function getElementInSlide(slide: Slide | null, elementId: string): CanvasElement | undefined {
359
+ return slide?.canvas.elements.find(el => el.id === elementId);
386
360
  }
387
361
 
388
362
  /**
389
- * Overlay the current LIVE tween values for properties that the renderer
390
- * reads directly from element data. Lets per-element keyframes drive
391
- * borderRadius / fontSize / shape colors / stroke width / CSS filters
392
- * at render time, plus non-tweened overrides (backgroundColor, color).
393
- */
394
- /**
395
- * Overlay tween-driven values for properties that the renderer reads
396
- * directly from element data (instead of via tween.current). Only
397
- * applies when this element has keyframes OR has an active override —
398
- * for everything else this is a passthrough so the existing slide-morph
399
- * render pipeline is left exactly as it was. Without this gate, every
400
- * render frame allocated a new copy and crowded out the tween reads.
363
+ * Overlay tween-driven values + non-tweened keyframe overrides onto the
364
+ * element used in render. Gated: returns the element unchanged when it
365
+ * has no keyframes AND no active override that's the 99% case and
366
+ * keeps the existing slide-morph render pipeline allocation-free.
401
367
  */
402
368
  function liveProps<T extends CanvasElement>(element: T): T {
403
369
  const hasKeyframes = !!element.keyframes && element.keyframes.length > 0;
@@ -562,36 +528,71 @@
562
528
  for (const element of slide.canvas.elements) {
563
529
  if (!animatedElements.has(element.id)) {
564
530
  const inCurrent = getElementInSlide(currentSlide, element.id);
565
- const startOpacity = inCurrent ? ((inCurrent as any).opacity ?? 1) : 0;
531
+ let startOpacity = inCurrent ? ((inCurrent as any).opacity ?? 1) : 0;
566
532
  const br = (element as any).borderRadius ?? 0;
567
533
  const isShape = element.type === 'shape';
568
534
  const shapeEl = isShape ? element as ShapeElement : null;
569
535
  const isText = element.type === 'text';
570
536
  const textEl = isText ? element as TextElement : null;
537
+
538
+ // If the element has keyframes, seed each tween with the FIRST
539
+ // keyframe's value instead of the persisted (last-captured)
540
+ // state. The persisted state is whatever was on the canvas
541
+ // at the moment the user captured their final keyframe; using
542
+ // it as the tween's starting value would cause the slide to
543
+ // flash at the "wrong" pose before the keyframe schedule
544
+ // snaps it to KF1. Doing it here also avoids depending on a
545
+ // duration:0 snap that the tween library may not apply
546
+ // synchronously.
547
+ const sortedKfs = element.keyframes && element.keyframes.length > 0
548
+ ? [...element.keyframes].sort((a, b) => a.time - b.time)
549
+ : null;
550
+ const k0 = sortedKfs ? sortedKfs[0] : null;
551
+ const seedX = k0?.position ? k0.position.x : element.position.x;
552
+ const seedY = k0?.position ? k0.position.y : element.position.y;
553
+ const seedW = k0?.size ? k0.size.width : element.size.width;
554
+ const seedH = k0?.size ? k0.size.height : element.size.height;
555
+ const seedRot = k0?.rotation !== undefined ? k0.rotation : element.rotation;
556
+ const seedSkewX = k0?.skewX !== undefined ? k0.skewX : (element.skewX ?? 0);
557
+ const seedSkewY = k0?.skewY !== undefined ? k0.skewY : (element.skewY ?? 0);
558
+ const seedTiltX = k0?.tiltX !== undefined ? k0.tiltX : (element.tiltX ?? 0);
559
+ const seedTiltY = k0?.tiltY !== undefined ? k0.tiltY : (element.tiltY ?? 0);
560
+ if (k0?.opacity !== undefined) startOpacity = k0.opacity;
561
+ const seedBR = k0?.borderRadius !== undefined ? k0.borderRadius : br;
562
+ const seedFontSize = k0?.fontSize !== undefined ? k0.fontSize : (textEl ? textEl.fontSize : 0);
563
+ const seedFill = k0?.fillColor !== undefined ? k0.fillColor : (shapeEl ? shapeEl.fillColor : '');
564
+ const seedStroke = k0?.strokeColor !== undefined ? k0.strokeColor : (shapeEl ? shapeEl.strokeColor : '');
565
+ const seedStrokeW = k0?.strokeWidth !== undefined ? k0.strokeWidth : (shapeEl ? shapeEl.strokeWidth : 0);
566
+ const seedBlur = k0?.blur !== undefined ? k0.blur : (element.blur ?? 0);
567
+ const seedBright = k0?.brightness !== undefined ? k0.brightness : (element.brightness ?? 100);
568
+ const seedContrast = k0?.contrast !== undefined ? k0.contrast : (element.contrast ?? 100);
569
+ const seedSat = k0?.saturate !== undefined ? k0.saturate : (element.saturate ?? 100);
570
+ const seedGray = k0?.grayscale !== undefined ? k0.grayscale : (element.grayscale ?? 0);
571
+
571
572
  animatedElements.set(element.id, {
572
- x: tween(element.position.x, { duration: 500 }),
573
- y: tween(element.position.y, { duration: 500 }),
574
- width: tween(element.size.width, { duration: 500 }),
575
- height: tween(element.size.height, { duration: 500 }),
576
- rotation: tween(element.rotation, { duration: 500 }),
577
- skewX: tween(element.skewX ?? 0, { duration: 500 }),
578
- skewY: tween(element.skewY ?? 0, { duration: 500 }),
579
- tiltX: tween(element.tiltX ?? 0, { duration: 500 }),
580
- tiltY: tween(element.tiltY ?? 0, { duration: 500 }),
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 }),
581
582
  perspective: tween(element.perspective ?? 1000, { duration: 500 }),
582
583
  opacity: tween(startOpacity, { duration: 300 }),
583
- borderRadius: tween(br, { duration: 500 }),
584
- fontSize: textEl ? tween(textEl.fontSize, { duration: 500 }) : null,
585
- fillColor: shapeEl ? tween(shapeEl.fillColor, { duration: 500 }) : null,
586
- strokeColor: shapeEl ? tween(shapeEl.strokeColor, { duration: 500 }) : null,
587
- strokeWidth: shapeEl ? tween(shapeEl.strokeWidth, { duration: 500 }) : null,
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,
588
589
  shapeMorph: shapeEl ? tween(1, { duration: 500 }) : null,
589
590
  motionPathProgress: element.motionPathConfig ? tween(0, { duration: 500 }) : null,
590
- blur: tween(element.blur ?? 0, { duration: 500 }),
591
- brightness: tween(element.brightness ?? 100, { duration: 500 }),
592
- contrast: tween(element.contrast ?? 100, { duration: 500 }),
593
- saturate: tween(element.saturate ?? 100, { duration: 500 }),
594
- grayscale: tween(element.grayscale ?? 0, { duration: 500 })
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 })
595
596
  });
596
597
  const currentSlideEl = getElementInSlide(currentSlide, element.id);
597
598
  elementContent.set(element.id, JSON.parse(JSON.stringify(currentSlideEl || element)));
@@ -853,9 +854,7 @@
853
854
  // Per-element morphing (transition type = 'none')
854
855
  const animations: Promise<void>[] = [];
855
856
  for (const elementId of allElementIds) {
856
- // FROM-sync: use the EXIT pose (last keyframe) so morph starts
857
- // from where the keyframe loop left the element on screen.
858
- const currentEl = getElementInSlide(currentSlide, elementId, 'exit');
857
+ const currentEl = getElementInSlide(currentSlide, elementId);
859
858
  const animated = animatedElements.get(elementId);
860
859
  if (!animated) continue;
861
860
  if (currentEl) {
@@ -916,8 +915,8 @@
916
915
  const animationTasks: AnimationTask[] = [];
917
916
 
918
917
  for (const elementId of allElementIds) {
919
- const currentEl = getElementInSlide(currentSlide, elementId, 'exit');
920
- const targetEl = getElementInSlide(targetSlide, elementId, 'enter');
918
+ const currentEl = getElementInSlide(currentSlide, elementId);
919
+ const targetEl = getElementInSlide(targetSlide, elementId);
921
920
  const animated = animatedElements.get(elementId);
922
921
  if (!animated) continue;
923
922
  const animConfig = targetEl?.animationConfig || currentEl?.animationConfig;