animot-presenter 0.5.22 → 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,28 +55,35 @@
55
55
  if (motionPathLoopAbort) { motionPathLoopAbort.abort(); motionPathLoopAbort = null; }
56
56
  }
57
57
 
58
- // Keyframe schedule loop independent from motion-path / morph engines.
59
- // Plain (non-reactive) state: no $state for the abort controller because
60
- // the keyframe loop only retargets existing tweens and doesn't need to
61
- // drive any reactive UI of its own. Adding reactive state here proved to
62
- // crowd out the existing tween reactivity in 0.5.20.
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.
63
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.
66
+ let keyframeOverrides = $state<Map<string, Record<string, any>>>(new Map());
64
67
  function cancelKeyframeLoops() {
65
68
  if (keyframeLoopAbort) { keyframeLoopAbort.abort(); keyframeLoopAbort = null; }
66
69
  }
67
- function easingForTween(name: string | undefined): string {
68
- switch (name) {
69
- case 'linear': case 'ease-in': case 'ease-out': case 'ease-in-out': return name;
70
- case 'spring': return 'ease-out';
71
- case 'ease': default: return 'ease-out';
72
- }
70
+ function setKeyframeOverride(elementId: string, prop: string, value: any) {
71
+ const cur = keyframeOverrides.get(elementId) ?? {};
72
+ keyframeOverrides.set(elementId, { ...cur, [prop]: value });
73
+ keyframeOverrides = new Map(keyframeOverrides);
74
+ }
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');
73
82
  }
74
83
  function animateKeyframes(slide: Slide) {
75
84
  cancelKeyframeLoops();
76
- // Bail if nothing on the slide actually has keyframes — avoids
77
- // allocating an AbortController + iterating elements for the common
78
- // case (no keyframes anywhere).
79
85
  const hasAnyKeyframes = slide.canvas.elements.some((el) => el.keyframes && el.keyframes.length > 0);
86
+ if (keyframeOverrides.size > 0) keyframeOverrides = new Map();
80
87
  if (!hasAnyKeyframes) return;
81
88
  keyframeLoopAbort = new AbortController();
82
89
  const signal = keyframeLoopAbort.signal;
@@ -86,6 +93,7 @@
86
93
  if (!animated) continue;
87
94
  const sorted = [...element.keyframes].sort((a, b) => a.time - b.time);
88
95
  const first = sorted[0];
96
+ // Snap tweens to KF1 instantly so the slide-displayed pose IS the start.
89
97
  if (first.position) { animated.x?.to(first.position.x, { duration: 0 }); animated.y?.to(first.position.y, { duration: 0 }); }
90
98
  if (first.size) { animated.width?.to(first.size.width, { duration: 0 }); animated.height?.to(first.size.height, { duration: 0 }); }
91
99
  if (first.rotation !== undefined) animated.rotation?.to(first.rotation, { duration: 0 });
@@ -104,6 +112,8 @@
104
112
  if (first.contrast !== undefined) animated.contrast?.to(first.contrast, { duration: 0 });
105
113
  if (first.saturate !== undefined) animated.saturate?.to(first.saturate, { duration: 0 });
106
114
  if (first.grayscale !== undefined) animated.grayscale?.to(first.grayscale, { duration: 0 });
115
+ if (first.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', first.backgroundColor);
116
+ if (first.color !== undefined) setKeyframeOverride(element.id, 'color', first.color);
107
117
  for (const kf of sorted.slice(1)) {
108
118
  setTimeout(() => {
109
119
  if (signal.aborted) return;
@@ -129,6 +139,8 @@
129
139
  if (kf.contrast !== undefined) animated.contrast?.to(kf.contrast, { duration: span, easing });
130
140
  if (kf.saturate !== undefined) animated.saturate?.to(kf.saturate, { duration: span, easing });
131
141
  if (kf.grayscale !== undefined) animated.grayscale?.to(kf.grayscale, { duration: span, easing });
142
+ if (kf.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', kf.backgroundColor);
143
+ if (kf.color !== undefined) setKeyframeOverride(element.id, 'color', kf.color);
132
144
  }, Math.max(0, kf.time));
133
145
  }
134
146
  }
@@ -347,6 +359,35 @@
347
359
  return slide?.canvas.elements.find(el => el.id === elementId);
348
360
  }
349
361
 
362
+ /**
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.
367
+ */
368
+ function liveProps<T extends CanvasElement>(element: T): T {
369
+ const hasKeyframes = !!element.keyframes && element.keyframes.length > 0;
370
+ const overrides = keyframeOverrides.get(element.id);
371
+ if (!hasKeyframes && !overrides) return element;
372
+ const a = animatedElements.get(element.id) as any;
373
+ const e = element as any;
374
+ const out: any = { ...element };
375
+ if (a && hasKeyframes) {
376
+ if (a.borderRadius && e.borderRadius !== undefined) out.borderRadius = a.borderRadius.current;
377
+ if (a.fontSize && e.fontSize !== undefined) out.fontSize = a.fontSize.current;
378
+ if (a.fillColor && e.fillColor !== undefined) out.fillColor = a.fillColor.current;
379
+ if (a.strokeColor && e.strokeColor !== undefined) out.strokeColor = a.strokeColor.current;
380
+ if (a.strokeWidth && e.strokeWidth !== undefined) out.strokeWidth = a.strokeWidth.current;
381
+ if (a.blur && e.blur !== undefined) out.blur = a.blur.current;
382
+ if (a.brightness && e.brightness !== undefined) out.brightness = a.brightness.current;
383
+ if (a.contrast && e.contrast !== undefined) out.contrast = a.contrast.current;
384
+ if (a.saturate && e.saturate !== undefined) out.saturate = a.saturate.current;
385
+ if (a.grayscale && e.grayscale !== undefined) out.grayscale = a.grayscale.current;
386
+ }
387
+ if (overrides) Object.assign(out, overrides);
388
+ return out as T;
389
+ }
390
+
350
391
  // Typewriter
351
392
  function startTypewriterAnimation(elementId: string, fullText: string, speed: number) {
352
393
  const existing = typewriterIntervals.get(elementId);
@@ -487,36 +528,71 @@
487
528
  for (const element of slide.canvas.elements) {
488
529
  if (!animatedElements.has(element.id)) {
489
530
  const inCurrent = getElementInSlide(currentSlide, element.id);
490
- const startOpacity = inCurrent ? ((inCurrent as any).opacity ?? 1) : 0;
531
+ let startOpacity = inCurrent ? ((inCurrent as any).opacity ?? 1) : 0;
491
532
  const br = (element as any).borderRadius ?? 0;
492
533
  const isShape = element.type === 'shape';
493
534
  const shapeEl = isShape ? element as ShapeElement : null;
494
535
  const isText = element.type === 'text';
495
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
+
496
572
  animatedElements.set(element.id, {
497
- x: tween(element.position.x, { duration: 500 }),
498
- y: tween(element.position.y, { duration: 500 }),
499
- width: tween(element.size.width, { duration: 500 }),
500
- height: tween(element.size.height, { duration: 500 }),
501
- rotation: tween(element.rotation, { duration: 500 }),
502
- skewX: tween(element.skewX ?? 0, { duration: 500 }),
503
- skewY: tween(element.skewY ?? 0, { duration: 500 }),
504
- tiltX: tween(element.tiltX ?? 0, { duration: 500 }),
505
- 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 }),
506
582
  perspective: tween(element.perspective ?? 1000, { duration: 500 }),
507
583
  opacity: tween(startOpacity, { duration: 300 }),
508
- borderRadius: tween(br, { duration: 500 }),
509
- fontSize: textEl ? tween(textEl.fontSize, { duration: 500 }) : null,
510
- fillColor: shapeEl ? tween(shapeEl.fillColor, { duration: 500 }) : null,
511
- strokeColor: shapeEl ? tween(shapeEl.strokeColor, { duration: 500 }) : null,
512
- 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,
513
589
  shapeMorph: shapeEl ? tween(1, { duration: 500 }) : null,
514
590
  motionPathProgress: element.motionPathConfig ? tween(0, { duration: 500 }) : null,
515
- blur: tween(element.blur ?? 0, { duration: 500 }),
516
- brightness: tween(element.brightness ?? 100, { duration: 500 }),
517
- contrast: tween(element.contrast ?? 100, { duration: 500 }),
518
- saturate: tween(element.saturate ?? 100, { duration: 500 }),
519
- 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 })
520
596
  });
521
597
  const currentSlideEl = getElementInSlide(currentSlide, element.id);
522
598
  elementContent.set(element.id, JSON.parse(JSON.stringify(currentSlideEl || element)));
@@ -1384,7 +1460,7 @@
1384
1460
  use:decorations={{ config: element.decorations, slideDuration: currentSlide?.duration, shape: element.type === 'shape' ? { type: (element as any).shapeType, borderRadius: (element as any).borderRadius } : undefined, key: `${currentSlideIndex}-${JSON.stringify(element.decorations ?? null)}-${element.type === 'shape' ? (element as any).shapeType + ':' + ((element as any).borderRadius ?? 0) : ''}` }}
1385
1461
  >
1386
1462
  {#if element.type === 'code'}
1387
- {@const codeEl = element as CodeElement}
1463
+ {@const codeEl = liveProps(element) as CodeElement}
1388
1464
  {@const morphState = codeMorphState.get(codeEl.id)}
1389
1465
  <div class="animot-code-block" class:transparent-bg={codeEl.transparentBackground} style:font-size="{codeEl.fontSize}px" style:font-weight={codeEl.fontWeight || 400} style:padding="{codeEl.padding}px" style:border-radius="{animated.borderRadius.current}px" style:background={codeEl.bgColor ?? '#0d1117'}>
1390
1466
  {#if codeEl.showHeader}
@@ -1441,7 +1517,7 @@
1441
1517
  </div>
1442
1518
  </div>
1443
1519
  {:else if element.type === 'text'}
1444
- {@const textEl = element as TextElement}
1520
+ {@const textEl = liveProps(element) as TextElement}
1445
1521
  {@const animFontSize = animated.fontSize?.current ?? textEl.fontSize}
1446
1522
  {@const typewriterState = textTypewriterState.get(element.id)}
1447
1523
  {@const displayText = typewriterState?.isAnimating ? typewriterState.fullText.slice(0, typewriterState.displayedChars) : textEl.content}
@@ -1472,7 +1548,7 @@
1472
1548
  {#if !isActionTextMode}{displayText}{#if typewriterState?.isAnimating}<span class="animot-typewriter-cursor">|</span>{/if}{/if}
1473
1549
  </div>
1474
1550
  {:else if element.type === 'arrow'}
1475
- {@const arrowEl = element as ArrowElement}
1551
+ {@const arrowEl = liveProps(element) as ArrowElement}
1476
1552
  {@const cp = arrowEl.controlPoints || []}
1477
1553
  {@const pathD = cp.length === 0 ? `M ${arrowEl.startPoint.x} ${arrowEl.startPoint.y} L ${arrowEl.endPoint.x} ${arrowEl.endPoint.y}` : cp.length === 1 ? `M ${arrowEl.startPoint.x} ${arrowEl.startPoint.y} Q ${cp[0].x} ${cp[0].y} ${arrowEl.endPoint.x} ${arrowEl.endPoint.y}` : cp.length === 2 ? `M ${arrowEl.startPoint.x} ${arrowEl.startPoint.y} C ${cp[0].x} ${cp[0].y} ${cp[1].x} ${cp[1].y} ${arrowEl.endPoint.x} ${arrowEl.endPoint.y}` : buildCatmullRomPath(arrowEl.startPoint, cp, arrowEl.endPoint)}
1478
1554
  {@const lastCp = cp.length > 0 ? cp[cp.length - 1] : arrowEl.startPoint}
@@ -1495,11 +1571,11 @@
1495
1571
  {/if}
1496
1572
  </svg>
1497
1573
  {:else if element.type === 'image'}
1498
- {@const imgEl = element as ImageElement}
1574
+ {@const imgEl = liveProps(element) as ImageElement}
1499
1575
  {@const clipPath = imgEl.clipMask?.enabled ? (imgEl.clipMask.shapeType === 'circle' ? 'circle(50% at 50% 50%)' : imgEl.clipMask.shapeType === 'ellipse' ? 'ellipse(50% 50% at 50% 50%)' : imgEl.clipMask.shapeType === 'triangle' ? 'polygon(50% 0%, 0% 100%, 100% 100%)' : imgEl.clipMask.shapeType === 'star' ? 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)' : imgEl.clipMask.shapeType === 'hexagon' ? 'polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%)' : (imgEl.clipMask.borderRadius ?? 0) > 0 ? `inset(0 round ${imgEl.clipMask.borderRadius}px)` : 'none') : 'none'}
1500
1576
  <img class="animot-image-element" src={imgEl.src} alt="" style:object-fit={imgEl.objectFit} style:border-radius="{imgEl.clipMask?.enabled ? 0 : imgEl.borderRadius}px" style:clip-path={clipPath} style:background-color={imgEl.backgroundColor ?? 'transparent'} />
1501
1577
  {:else if element.type === 'video'}
1502
- {@const videoEl = element as VideoElement}
1578
+ {@const videoEl = liveProps(element) as VideoElement}
1503
1579
  {@const videoEmbed = parseEmbedUrl(videoEl.src)}
1504
1580
  {#if videoEmbed}
1505
1581
  <div class="animot-video-element animot-embed-wrap" style:border-radius="{videoEl.borderRadius}px" style:opacity={videoEl.opacity}>
@@ -1509,7 +1585,7 @@
1509
1585
  <video class="animot-video-element" src={videoEl.src} poster={videoEl.posterImage} autoplay={videoEl.autoplay} loop={videoEl.loop} muted={videoEl.muted} controls={!!videoEl.showControls} playsinline preload="auto" style:object-fit={videoEl.objectFit} style:border-radius="{videoEl.borderRadius}px" style:opacity={videoEl.opacity}></video>
1510
1586
  {/if}
1511
1587
  {:else if element.type === 'shape'}
1512
- {@const shapeEl = element as ShapeElement}
1588
+ {@const shapeEl = liveProps(element) as ShapeElement}
1513
1589
  {@const animFill = animated.fillColor?.current ?? shapeEl.fillColor}
1514
1590
  {@const animStroke = animated.strokeColor?.current ?? shapeEl.strokeColor}
1515
1591
  {@const animStrokeWidth = animated.strokeWidth?.current ?? shapeEl.strokeWidth}