animot-presenter 0.6.3 → 0.6.5

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.
@@ -19,10 +19,13 @@
19
19
  import { parseEmbedUrl } from './utils/video-embed';
20
20
  import EmbedPlayer from './EmbedPlayer.svelte';
21
21
  import { easeInOutCubic, getEasingFn, getBackgroundStyle, gradientShapeToCss, hashFraction, getFloatAnimName, getIdleAnimName, computeFloatAmp, computeFloatSpeed, entranceRuntimeKeyframe, exitRuntimeKeyframe, emphasisKeyframeName } from './engine/utils';
22
+ import { drawElementToPath } from './utils/freehand';
23
+ import { makePathInterpolator, makeMorphParts, fitPathToBox, resolveElementPath, resolveElementPathParts, shapeToPath, extractCombinedPath, extractFill, type PathInterpolator, type MorphPart } from './utils/path-morph';
22
24
  import type {
23
25
  AnimotProject, AnimotPresenterProps, CanvasElement, CodeElement, TextElement,
24
26
  ArrowElement, ImageElement, VideoElement, ShapeElement, CounterElement, ChartElement, IconElement,
25
27
  SvgElement, MotionPathElement, ProgressElement, ContainerElement, PathPoint,
28
+ DrawElement, StickyElement,
26
29
  Slide, CodeAnimationMode, AnimatableProperty
27
30
  } from './types';
28
31
  import './styles/presenter.css';
@@ -75,6 +78,7 @@
75
78
  strokeColor: ReturnType<typeof tween<string>> | null;
76
79
  strokeWidth: TweenValue | null;
77
80
  shapeMorph: TweenValue | null;
81
+ pathMorph: TweenValue | null;
78
82
  motionPathProgress: TweenValue | null;
79
83
  blur: TweenValue;
80
84
  brightness: TweenValue;
@@ -84,6 +88,58 @@
84
88
  }
85
89
 
86
90
  interface ShapeMorphState { fromType: string; toType: string; }
91
+ interface PathMorphState { interp: PathInterpolator; fromColor: string; toColor: string; viewBox?: string; parts?: MorphPart[]; }
92
+
93
+ // Resolve any svg/shape element to a single morphable path `d`.
94
+ function resolveMorphPath(el: CanvasElement): string | null {
95
+ if (el.type === 'svg') return extractCombinedPath((el as SvgElement).svgContent);
96
+ if (el.type === 'shape') {
97
+ const s = el as ShapeElement;
98
+ return shapeToPath(s.shapeType, s.size.width, s.size.height, s.borderRadius);
99
+ }
100
+ return null;
101
+ }
102
+ function resolveMorphColor(el: CanvasElement): string {
103
+ if (el.type === 'shape') return (el as ShapeElement).fillColor || '#888888';
104
+ const svg = el as SvgElement;
105
+ return svg.color || extractFill(svg.svgContent) || '#888888';
106
+ }
107
+ // Resting geometry for keyframe-path elements before animateKeyframes runs,
108
+ // so the element never flashes its base/last shape on slide enter.
109
+ function restingKfMorph(el: CanvasElement): PathMorphState | null {
110
+ const kfs = (el.keyframes ?? []).filter((k) => k.path);
111
+ if (kfs.length < 1) return null;
112
+ const sorted = [...kfs].sort((a, b) => a.time - b.time);
113
+ const d = sorted[0].path as string;
114
+ const c = resolveMorphColor(el);
115
+ return { interp: () => d, fromColor: c, toColor: c, viewBox: resolveElementPath(el as any)?.viewBox };
116
+ }
117
+ // viewBox to render a morph path in — the element's own coordinate space.
118
+ function morphViewBox(el: CanvasElement, w: number, h: number): string {
119
+ if (el.type === 'svg') {
120
+ const s = el as SvgElement;
121
+ if (s.viewBox) return s.viewBox;
122
+ const m = s.svgContent.match(/viewBox=["']([^"']+)["']/i);
123
+ if (m) return m[1];
124
+ }
125
+ return `0 0 ${Math.max(1, w)} ${Math.max(1, h)}`;
126
+ }
127
+ // Linear interpolate two hex colors (#rgb/#rrggbb). Falls back to `to`.
128
+ function lerpHex(from: string, to: string, t: number): string {
129
+ const parse = (c: string): [number, number, number] | null => {
130
+ let h = c.trim().replace('#', '');
131
+ if (h.length === 3) h = h.split('').map((x) => x + x).join('');
132
+ if (h.length !== 6) return null;
133
+ const n = parseInt(h, 16);
134
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
135
+ };
136
+ const a = parse(from), b = parse(to);
137
+ if (!a || !b) return t < 0.5 ? from : to;
138
+ const r = Math.round(a[0] + (b[0] - a[0]) * t);
139
+ const g = Math.round(a[1] + (b[1] - a[1]) * t);
140
+ const bl = Math.round(a[2] + (b[2] - a[2]) * t);
141
+ return `rgb(${r}, ${g}, ${bl})`;
142
+ }
87
143
 
88
144
  // Race a promise against an AbortSignal so awaits unwind the instant a
89
145
  // loop is cancelled — otherwise tween.to() / setTimeout promises keep
@@ -137,6 +193,7 @@
137
193
  for (const id of keyframeTimeouts) clearTimeout(id);
138
194
  keyframeTimeouts = [];
139
195
  }
196
+ cancelKfPathRafs();
140
197
  }
141
198
  function setKeyframeOverride(elementId: string, prop: string, value: any) {
142
199
  const cur = keyframeOverrides.get(elementId) ?? {};
@@ -151,10 +208,103 @@
151
208
  function easingForTween(name: string | undefined): (t: number) => number {
152
209
  return getEasingFn(name ?? 'ease-out');
153
210
  }
154
- function animateKeyframes(slide: Slide) {
211
+
212
+ // ── Cross-slide shape/path morph (on slide enter) ──────────────────────
213
+ let morphLoopAbort: AbortController | null = null;
214
+ // Armed at the top of animateToSlide, played only after currentSlideIndex
215
+ // flips so the full 0→1 morph runs while the target slide is on screen
216
+ // (playing before the target paints finishes the tween invisibly → snap).
217
+ let pendingMorphPlays: Array<{ id: string; duration: number; easing: (t: number) => number }> = [];
218
+ function cancelMorphLoops() {
219
+ if (morphLoopAbort) { morphLoopAbort.abort(); morphLoopAbort = null; }
220
+ pendingMorphPlays = [];
221
+ cancelMorphRaf();
222
+ if (crossMorphProg.size > 0) crossMorphProg = new Map();
223
+ }
224
+ function playCrossSlideMorphs() {
225
+ const plays = pendingMorphPlays;
226
+ pendingMorphPlays = [];
227
+ if (!plays.length || typeof requestAnimationFrame === 'undefined') return;
228
+ const maxDur = Math.max(...plays.map((p) => p.duration), 1);
229
+ cancelMorphRaf();
230
+ let startTs = 0;
231
+ const step = (ts: number) => {
232
+ if (!startTs) startTs = ts;
233
+ const elapsed = ts - startTs;
234
+ const next = new Map(crossMorphProg);
235
+ let done = true;
236
+ for (const p of plays) {
237
+ const lin = Math.min(1, p.duration > 0 ? elapsed / p.duration : 1);
238
+ next.set(p.id, p.easing(lin));
239
+ if (lin < 1) done = false;
240
+ }
241
+ crossMorphProg = next;
242
+ if (!done) {
243
+ morphRafId = requestAnimationFrame(step);
244
+ } else {
245
+ // Morph complete — drop both progress and morph state so the render
246
+ // hands off to the faithful static SVG (preserves layering/opacity).
247
+ morphRafId = null;
248
+ const cleared = new Map(crossMorphProg);
249
+ const pm = new Map(pathMorphStates);
250
+ for (const p of plays) { cleared.delete(p.id); pm.delete(p.id); }
251
+ crossMorphProg = cleared;
252
+ pathMorphStates = pm;
253
+ }
254
+ };
255
+ morphRafId = requestAnimationFrame(step);
256
+ }
257
+ function animateCrossSlideMorph(fromSlide: Slide | undefined, toSlide: Slide) {
258
+ cancelMorphLoops();
259
+ if (pathMorphStates.size > 0) pathMorphStates = new Map();
260
+ if (!fromSlide || fromSlide === toSlide) return;
261
+ morphLoopAbort = new AbortController();
262
+ const signal = morphLoopAbort.signal;
263
+ for (const element of toSlide.canvas.elements) {
264
+ try {
265
+ if (element.type !== 'shape' && element.type !== 'svg' && element.type !== 'draw') continue;
266
+ // Keyframe path-morphs own pathMorph on their slide — skip to avoid
267
+ // two drivers fighting (snap).
268
+ if (element.keyframes?.some((k) => k.path)) continue;
269
+ const fromEl = fromSlide.canvas.elements.find((e) => e.id === element.id);
270
+ if (!fromEl || (fromEl.type !== 'shape' && fromEl.type !== 'svg' && fromEl.type !== 'draw')) continue;
271
+ const animated = animatedElements.get(element.id) as any;
272
+ if (!animated?.pathMorph) continue;
273
+ const from = resolveElementPathParts(fromEl as any);
274
+ const to = resolveElementPathParts(element as any);
275
+ if (!from || !to) continue;
276
+ // Skip identical geometry (compare RAW parts — fitPathToBox reserializes
277
+ // and would never string-match its own source). See app copy.
278
+ if (from.viewBox === to.viewBox && from.parts.length === to.parts.length && from.parts.every((p, i) => p.d === to.parts[i].d)) continue;
279
+ const tvb = to.viewBox.split(/[\s,]+/).map(Number);
280
+ const fromFitted = from.parts.map((p) => ({ d: fitPathToBox(p.d, from.viewBox, tvb[2] || 100, tvb[3] || 100), fill: p.fill }));
281
+ const duration = (element.animationConfig?.duration ?? 800);
282
+ const parts = makeMorphParts(fromFitted, to.parts);
283
+ pathMorphStates.set(element.id, {
284
+ interp: parts[0].interp,
285
+ fromColor: parts[0].fromColor,
286
+ toColor: parts[0].toColor,
287
+ viewBox: to.viewBox,
288
+ parts
289
+ });
290
+ pathMorphStates = new Map(pathMorphStates);
291
+ if (signal.aborted) return;
292
+ // Drive progress via the rAF loop + crossMorphProg $state (not the
293
+ // tween — its reactivity doesn't cross the bundle boundary). Seed 0.
294
+ crossMorphProg.set(element.id, 0);
295
+ pendingMorphPlays.push({ id: element.id, duration, easing: getEasingFn(element.animationConfig?.easing) });
296
+ } catch (e) {
297
+ console.warn('[morph] skipped element', element.id, e);
298
+ }
299
+ }
300
+ if (crossMorphProg.size > 0) crossMorphProg = new Map(crossMorphProg);
301
+ }
302
+
303
+ function animateKeyframes(slide: Slide, fromSlide?: Slide) {
155
304
  cancelKeyframeLoops();
156
305
  const hasAnyKeyframes = slide.canvas.elements.some((el) => el.keyframes && el.keyframes.length > 0);
157
306
  if (keyframeOverrides.size > 0) keyframeOverrides = new Map();
307
+ if (kfPathStates.size > 0) kfPathStates = new Map();
158
308
  if (!hasAnyKeyframes) return;
159
309
  keyframeLoopAbort = new AbortController();
160
310
  const signal = keyframeLoopAbort.signal;
@@ -164,13 +314,19 @@
164
314
  if (!animated) continue;
165
315
  const sorted = [...element.keyframes].sort((a, b) => a.time - b.time);
166
316
  const first = sorted[0];
167
- // Snap tweens to KF1 instantly so the slide-displayed pose IS the start.
168
- if (first.position) { animated.x?.to(first.position.x, { duration: 0 }); animated.y?.to(first.position.y, { duration: 0 }); }
169
- if (first.size) { animated.width?.to(first.size.width, { duration: 0 }); animated.height?.to(first.size.height, { duration: 0 }); }
170
- if (first.rotation !== undefined) animated.rotation?.to(first.rotation, { duration: 0 });
317
+ // If we came from another slide where this element existed, morph the
318
+ // first keyframe's transform props IN from the previous pose (so a
319
+ // slide change / loop wrap blends like every other element). openDur=0
320
+ // (no source) keeps the snap-to-KF1 behavior on initial load.
321
+ const cameFromEl = fromSlide?.canvas.elements.find((e) => e.id === element.id);
322
+ const openDur = cameFromEl ? Math.max(50, first.time || 600) : 0;
323
+ const openEase = easingForTween('ease-in-out');
324
+ if (first.position) { animated.x?.to(first.position.x, { duration: openDur, easing: openEase }); animated.y?.to(first.position.y, { duration: openDur, easing: openEase }); }
325
+ if (first.size) { animated.width?.to(first.size.width, { duration: openDur, easing: openEase }); animated.height?.to(first.size.height, { duration: openDur, easing: openEase }); }
326
+ if (first.rotation !== undefined) animated.rotation?.to(first.rotation, { duration: openDur, easing: openEase });
171
327
  if (first.opacity !== undefined) animated.opacity?.to(first.opacity, { duration: 0 });
172
- if (first.skewX !== undefined) animated.skewX?.to(first.skewX, { duration: 0 });
173
- if (first.skewY !== undefined) animated.skewY?.to(first.skewY, { duration: 0 });
328
+ if (first.skewX !== undefined) animated.skewX?.to(first.skewX, { duration: openDur, easing: openEase });
329
+ if (first.skewY !== undefined) animated.skewY?.to(first.skewY, { duration: openDur, easing: openEase });
174
330
  if (first.tiltX !== undefined) animated.tiltX?.to(first.tiltX, { duration: 0 });
175
331
  if (first.tiltY !== undefined) animated.tiltY?.to(first.tiltY, { duration: 0 });
176
332
  if (first.borderRadius !== undefined) animated.borderRadius?.to(first.borderRadius, { duration: 0 });
@@ -185,7 +341,44 @@
185
341
  if (first.grayscale !== undefined) animated.grayscale?.to(first.grayscale, { duration: 0 });
186
342
  if (first.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', first.backgroundColor);
187
343
  if (first.color !== undefined) setKeyframeOverride(element.id, 'color', first.color);
344
+ // Path morph: snap to the first keyframe's path (static interp) so the
345
+ // element shows that geometry from the start.
346
+ const kfColor = resolveMorphColor(element);
347
+ const kfVB = resolveElementPath(element as any)?.viewBox;
348
+ if (first.path && animated.pathMorph) {
349
+ const fromEl = fromSlide?.canvas.elements.find((e) => e.id === element.id);
350
+ let openFrom: string | null = null;
351
+ if (fromEl && (fromEl.type === 'shape' || fromEl.type === 'svg' || fromEl.type === 'draw')) {
352
+ try {
353
+ const fp = resolveElementPath(fromEl as any);
354
+ if (fp && fp.d !== first.path) {
355
+ const tvb = (kfVB ?? '0 0 100 100').split(/[\s,]+/).map(Number);
356
+ openFrom = fitPathToBox(fp.d, fp.viewBox, tvb[2] || 100, tvb[3] || 100);
357
+ }
358
+ } catch { /* skip */ }
359
+ }
360
+ if (openFrom) {
361
+ const openSpan = Math.max(50, first.time || 600);
362
+ kfPathStates.set(element.id, { interp: makePathInterpolator(openFrom, first.path), fromColor: kfColor, toColor: kfColor, viewBox: kfVB });
363
+ kfPathStates = new Map(kfPathStates);
364
+ driveKfPathMorph(element.id, openSpan, getEasingFn('ease-in-out'));
365
+ } else {
366
+ const staticD = first.path;
367
+ kfPathStates.set(element.id, { interp: () => staticD, fromColor: kfColor, toColor: kfColor, viewBox: kfVB });
368
+ kfPathStates = new Map(kfPathStates);
369
+ cancelKfPathRaf(element.id);
370
+ kfPathProg.set(element.id, 1);
371
+ kfPathProg = new Map(kfPathProg);
372
+ }
373
+ }
374
+ let kfPrevPath = first.path;
188
375
  for (const kf of sorted.slice(1)) {
376
+ const kfSegFrom = kfPrevPath;
377
+ if (kf.path) kfPrevPath = kf.path;
378
+ // Fire at the PREVIOUS keyframe and animate over the gap so the
379
+ // element arrives AT this keyframe's time (not span ms later).
380
+ const kfIdx = sorted.indexOf(kf);
381
+ const segPrevTime = kfIdx <= 1 ? sorted[0].time : sorted[kfIdx - 1].time;
189
382
  const tid = setTimeout(() => {
190
383
  if (signal.aborted) return;
191
384
  const easing = easingForTween(kf.easing);
@@ -212,7 +405,16 @@
212
405
  if (kf.grayscale !== undefined) animated.grayscale?.to(kf.grayscale, { duration: span, easing });
213
406
  if (kf.backgroundColor !== undefined) setKeyframeOverride(element.id, 'backgroundColor', kf.backgroundColor);
214
407
  if (kf.color !== undefined) setKeyframeOverride(element.id, 'color', kf.color);
215
- }, Math.max(0, kf.time));
408
+ // Path morph between the previous path and this keyframe's path.
409
+ if (kf.path && kfSegFrom && kf.path !== kfSegFrom && animated.pathMorph) {
410
+ kfPathStates.set(element.id, {
411
+ interp: makePathInterpolator(kfSegFrom, kf.path),
412
+ fromColor: kfColor, toColor: kfColor, viewBox: kfVB
413
+ });
414
+ kfPathStates = new Map(kfPathStates);
415
+ driveKfPathMorph(element.id, span, easing);
416
+ }
417
+ }, Math.max(0, segPrevTime));
216
418
  keyframeTimeouts.push(tid);
217
419
  }
218
420
  }
@@ -403,6 +605,62 @@
403
605
  let textTypewriterState = $state<Map<string, {fullText: string, displayedChars: number, isAnimating: boolean}>>(new Map());
404
606
  let typewriterIntervals = new Map<string, ReturnType<typeof setInterval>>();
405
607
  let shapeMorphStates = $state<Map<string, ShapeMorphState>>(new Map());
608
+ let pathMorphStates = $state<Map<string, PathMorphState>>(new Map());
609
+ // Within-slide keyframe path morph (separate from cross-slide pathMorphStates
610
+ // so the render keeps interpolating even at progress=1, since the element's
611
+ // base svgContent is NOT the keyframe target).
612
+ let kfPathStates = $state<Map<string, PathMorphState>>(new Map());
613
+ // Cross-slide morph progress, driven by a manual rAF loop into this
614
+ // component-owned $state map. The @animotion Tween's reactive `.current`
615
+ // does NOT propagate re-renders across the packaged-bundle boundary (it
616
+ // does in the editor app), so reading the tween in the template never
617
+ // repaints → snap. A $state map we own DOES repaint (same as
618
+ // pathMorphStates), and the manual loop is the pattern motion paths use.
619
+ let crossMorphProg = $state<Map<string, number>>(new Map());
620
+ let kfPathProg = $state<Map<string, number>>(new Map());
621
+ let morphRafId: number | null = null;
622
+ function cancelMorphRaf() {
623
+ if (morphRafId !== null && typeof cancelAnimationFrame !== 'undefined') cancelAnimationFrame(morphRafId);
624
+ morphRafId = null;
625
+ }
626
+ let kfPathRafIds = new Map<string, number>();
627
+ function cancelKfPathRafs() {
628
+ if (typeof cancelAnimationFrame !== 'undefined') {
629
+ for (const id of kfPathRafIds.values()) cancelAnimationFrame(id);
630
+ }
631
+ kfPathRafIds.clear();
632
+ if (kfPathProg.size > 0) kfPathProg = new Map();
633
+ }
634
+ function cancelKfPathRaf(elementId: string) {
635
+ const rafId = kfPathRafIds.get(elementId);
636
+ if (rafId !== undefined && typeof cancelAnimationFrame !== 'undefined') cancelAnimationFrame(rafId);
637
+ kfPathRafIds.delete(elementId);
638
+ }
639
+ function driveKfPathMorph(elementId: string, duration: number, easing: (t: number) => number) {
640
+ if (typeof requestAnimationFrame === 'undefined') {
641
+ kfPathProg.set(elementId, 1);
642
+ kfPathProg = new Map(kfPathProg);
643
+ return;
644
+ }
645
+ cancelKfPathRaf(elementId);
646
+ kfPathProg.set(elementId, 0);
647
+ kfPathProg = new Map(kfPathProg);
648
+ let startTs = 0;
649
+ const span = Math.max(1, duration);
650
+ const step = (ts: number) => {
651
+ if (!startTs) startTs = ts;
652
+ const lin = Math.min(1, (ts - startTs) / span);
653
+ const next = new Map(kfPathProg);
654
+ next.set(elementId, easing(lin));
655
+ kfPathProg = next;
656
+ if (lin < 1) {
657
+ kfPathRafIds.set(elementId, requestAnimationFrame(step));
658
+ } else {
659
+ kfPathRafIds.delete(elementId);
660
+ }
661
+ };
662
+ kfPathRafIds.set(elementId, requestAnimationFrame(step));
663
+ }
406
664
  let autoplayTimer: ReturnType<typeof setTimeout> | null = null;
407
665
  let menuVisible = $state(true);
408
666
  let mouseIdleTimer: ReturnType<typeof setTimeout> | null = null;
@@ -713,6 +971,10 @@
713
971
  strokeColor: shapeEl ? mkTween(seedStroke, { duration: 500 }) : null,
714
972
  strokeWidth: shapeEl ? mkTween(seedStrokeW, { duration: 500 }) : null,
715
973
  shapeMorph: shapeEl ? mkTween(1, { duration: 500 }) : null,
974
+ // Raw tween (NOT mkTween): wrapTween's `current === value` guard
975
+ // would skip the deferred play-to-1 (pathMorph inits to 1 and the
976
+ // arm's async to(0) hasn't applied yet) → cross-slide morph snaps.
977
+ pathMorph: (element.type === 'svg' || element.type === 'draw' || shapeEl) ? tween(1, { duration: 500 }) : null,
716
978
  motionPathProgress: element.motionPathConfig ? mkTween(0, { duration: 500 }) : null,
717
979
  blur: mkTween(seedBlur, { duration: 500 }),
718
980
  brightness: mkTween(seedBright, { duration: 500 }),
@@ -784,6 +1046,7 @@
784
1046
  clearAllTypewriterAnimations();
785
1047
  cancelMotionPathLoops();
786
1048
  cancelKeyframeLoops();
1049
+ cancelMorphLoops();
787
1050
  const firstSlide = slides[0];
788
1051
  if (!firstSlide) { isTransitioning = false; return; }
789
1052
 
@@ -845,6 +1108,7 @@
845
1108
  previousChartContent = new Map();
846
1109
  previousProgressContent = new Map();
847
1110
  shapeMorphStates = new Map();
1111
+ pathMorphStates = new Map();
848
1112
  elementContent = new Map(elementContent);
849
1113
  currentSlideIndex = 0;
850
1114
  isTransitioning = false;
@@ -877,9 +1141,14 @@
877
1141
  isTransitioning = true;
878
1142
  transitionDirection = targetIndex > currentSlideIndex ? 'forward' : 'backward';
879
1143
  const targetSlide = slides[targetIndex];
1144
+ // Slide we're leaving — `currentSlideIndex` flips to target mid-function
1145
+ // and `currentSlide` derives from it, so morph source geometry must read
1146
+ // from this snapshot.
1147
+ const sourceSlide = slides[currentSlideIndex];
880
1148
  clearAllTypewriterAnimations();
881
1149
  cancelMotionPathLoops();
882
1150
  cancelKeyframeLoops();
1151
+ cancelMorphLoops();
883
1152
  // Manual arrow nav and the autoplay timer are both forms of "user
884
1153
  // is moving forward in the deck" — both should swap narration to
885
1154
  // the new slide. The pause button is what stops audio. Without
@@ -891,6 +1160,11 @@
891
1160
  transitionDurationMs = duration;
892
1161
  const hasSlideTransition = transition.type !== 'none';
893
1162
 
1163
+ // Cross-slide shape/path morph: set up BEFORE the flip + transition so
1164
+ // the first render shows the source geometry and morphs to target
1165
+ // concurrently (no target→source flash), and fires on the loop wrap.
1166
+ animateCrossSlideMorph(sourceSlide, targetSlide);
1167
+
894
1168
  if (hasSlideTransition) {
895
1169
  // Phase 2 sprint-1 polish — fire per-element exit presets BEFORE
896
1170
  // the slide-level CSS transition starts, then WAIT for them to
@@ -987,6 +1261,9 @@
987
1261
  }
988
1262
  }
989
1263
  codeHighlights = new Map(codeHighlights);
1264
+ // Do NOT clear pathMorphStates — animateCrossSlideMorph() armed it at
1265
+ // the top of this function and its tween is mid-flight; wiping it makes
1266
+ // the cross-slide morph vanish behind the slide transition.
990
1267
  shapeMorphStates = new Map();
991
1268
  codeMorphState = newCodeMorphState;
992
1269
  previousCodeContent = newPreviousCodeContent;
@@ -995,6 +1272,7 @@
995
1272
  elementContent = newElementContent;
996
1273
  animatedElements = new Map(animatedElements);
997
1274
  currentSlideIndex = targetIndex;
1275
+ playCrossSlideMorphs();
998
1276
  for (const elementId of allElementIds) {
999
1277
  const targetEl = getElementInSlide(targetSlide, elementId);
1000
1278
  if (targetEl?.type === 'text') {
@@ -1009,7 +1287,7 @@
1009
1287
  firePresenterEntrancePresets(targetIndex);
1010
1288
  await new Promise(r => setTimeout(r, duration * 0.6));
1011
1289
  transitionClass = '';
1012
- animateKeyframes(targetSlide);
1290
+ animateKeyframes(targetSlide, sourceSlide);
1013
1291
  animateMotionPaths(targetSlide);
1014
1292
  isTransitioning = false;
1015
1293
  onslidechange?.(targetIndex, slides.length);
@@ -1256,18 +1534,16 @@
1256
1534
  if (animated.strokeColor) anims.push(animated.strokeColor.to(ts.strokeColor, { duration: elementDuration, easing }));
1257
1535
  if (animated.strokeWidth) anims.push(animated.strokeWidth.to(ts.strokeWidth, { duration: elementDuration, easing }));
1258
1536
  }
1259
- if (cs.shapeType !== ts.shapeType && animated.shapeMorph) {
1260
- shapeMorphStates.set(elementId, { fromType: cs.shapeType, toType: ts.shapeType });
1261
- shapeMorphStates = new Map(shapeMorphStates);
1262
- anims.push(animated.shapeMorph.to(0, { duration: 0 }));
1263
- anims.push(animated.shapeMorph.to(1, { duration: elementDuration, easing }));
1264
- }
1537
+ // Shape→shape geometry change is handled by the flubber path
1538
+ // morph below (true geometric morph, not a crossfade).
1265
1539
  } else if (targetEl.type === 'shape' && !propertySequences?.length) {
1266
1540
  const s = targetEl as ShapeElement;
1267
1541
  if (animated.fillColor) anims.push(animated.fillColor.to(s.fillColor, { duration: elementDuration, easing }));
1268
1542
  if (animated.strokeColor) anims.push(animated.strokeColor.to(s.strokeColor, { duration: elementDuration, easing }));
1269
1543
  if (animated.strokeWidth) anims.push(animated.strokeWidth.to(s.strokeWidth, { duration: elementDuration, easing }));
1270
1544
  }
1545
+ // Cross-slide shape/path morph is handled on slide ENTER by
1546
+ // animateCrossSlideMorph (transition-independent), not here.
1271
1547
  if (!currentEl) {
1272
1548
  // Snap ALL properties to target instantly — the tween may hold
1273
1549
  // stale values from a previous slide where the element last appeared
@@ -1385,11 +1661,14 @@
1385
1661
  }
1386
1662
  }
1387
1663
  codeHighlights = new Map(codeHighlights);
1664
+ // Do NOT clear pathMorphStates (same fix as the slide-transition branch) —
1665
+ // animateCrossSlideMorph() armed it at the top and its tween is mid-flight.
1388
1666
  shapeMorphStates = new Map();
1389
1667
  codeMorphState = newCodeMorphState;
1390
1668
  previousCodeContent = newPreviousCodeContent;
1391
1669
  elementContent = newElementContent;
1392
1670
  currentSlideIndex = targetIndex;
1671
+ playCrossSlideMorphs();
1393
1672
  isTransitioning = false;
1394
1673
  // Ensure elements not on the new slide are fully hidden. Phase 2:
1395
1674
  // skip elements with an active runtime exit registration — their
@@ -1403,7 +1682,7 @@
1403
1682
  const hasActiveExit = !!reg?.get(elementId)?.exit;
1404
1683
  if (!onSlide && animated && !hasActiveExit) { animated.opacity.to(0, { duration: 0 }); }
1405
1684
  }
1406
- animateKeyframes(slides[targetIndex]);
1685
+ animateKeyframes(slides[targetIndex], sourceSlide);
1407
1686
  animateMotionPaths(slides[targetIndex]);
1408
1687
  onslidechange?.(targetIndex, slides.length);
1409
1688
  if (targetIndex === slides.length - 1 && !loop) oncomplete?.();
@@ -1418,7 +1697,7 @@
1418
1697
  autoplayTimer = setTimeout(() => {
1419
1698
  if (currentSlideIndex < slides.length - 1) animateToSlide(currentSlideIndex + 1);
1420
1699
  else if (loop) {
1421
- const loopMode = project?.settings?.loopMode ?? 'reset';
1700
+ const loopMode = project?.settings?.loopMode ?? ((project as any)?.mode === 'flow' ? 'reset' : 'transition');
1422
1701
  if (loopMode === 'transition') animateToSlide(0);
1423
1702
  else resetToFirstSlide();
1424
1703
  }
@@ -1432,7 +1711,7 @@
1432
1711
  if (currentSlideIndex < slides.length - 1) {
1433
1712
  animateToSlide(currentSlideIndex + 1);
1434
1713
  } else if (loop) {
1435
- const loopMode = project?.settings?.loopMode ?? 'reset';
1714
+ const loopMode = project?.settings?.loopMode ?? ((project as any)?.mode === 'flow' ? 'reset' : 'transition');
1436
1715
  if (loopMode === 'transition') {
1437
1716
  animateToSlide(0);
1438
1717
  } else {
@@ -1871,13 +2150,13 @@
1871
2150
  {@const arrowHeadPath = `M ${arrowEl.endPoint.x - headSize * Math.cos(endAngle - headAngle)} ${arrowEl.endPoint.y - headSize * Math.sin(endAngle - headAngle)} L ${arrowEl.endPoint.x} ${arrowEl.endPoint.y} L ${arrowEl.endPoint.x - headSize * Math.cos(endAngle + headAngle)} ${arrowEl.endPoint.y - headSize * Math.sin(endAngle + headAngle)}`}
1872
2151
  {@const arrowAnimMode = arrowEl.animation?.mode ?? 'none'}
1873
2152
  {@const arrowAnimDuration = arrowEl.animation?.duration ?? 500}
1874
- {@const isStyledArrow = arrowEl.style !== 'solid'}
1875
2153
  {@const isDrawType = arrowAnimMode === 'draw' || arrowAnimMode === 'undraw' || arrowAnimMode === 'draw-undraw' || arrowAnimMode === 'flow'}
1876
2154
  {@const baseDashArray = arrowEl.style === 'dashed' ? '10,5' : arrowEl.style === 'dotted' ? '2,5' : 'none'}
1877
2155
  <svg class="animot-arrow-element" class:arrow-animate-grow={arrowAnimMode === 'grow'} viewBox="0 0 {arrowEl.size.width} {arrowEl.size.height}" preserveAspectRatio="none" style="--arrow-anim-duration: {arrowAnimDuration}ms;" use:arrowClipDraw={{ enabled: isDrawType, mode: arrowAnimMode, duration: arrowAnimDuration, startX: arrowEl.startPoint.x, startY: arrowEl.startPoint.y, endX: arrowEl.endPoint.x, endY: arrowEl.endPoint.y, loop: !!arrowEl.animation?.loop, reverse: arrowEl.animation?.direction === 'reverse', slideDuration: currentSlide?.duration, key: `arrow-${currentSlideIndex}` }}>
1878
2156
  <path class="arrow-path" d={pathD} fill="none" stroke={arrowEl.color} stroke-width={arrowEl.strokeWidth} stroke-dasharray={baseDashArray} stroke-linecap="round" stroke-linejoin="round" />
1879
2157
  {#if arrowEl.showHead !== false}
1880
- <path class="arrow-head" class:arrow-head-styled-draw={isDrawType && isStyledArrow} class:arrow-head-undraw={arrowAnimMode === 'undraw'} class:arrow-head-draw-undraw={arrowAnimMode === 'draw-undraw'} d={arrowHeadPath} fill="none" stroke={arrowEl.color} stroke-width={arrowEl.strokeWidth} stroke-linecap="round" stroke-linejoin="round" style={isDrawType && isStyledArrow ? `--arrow-anim-duration: ${arrowAnimDuration}ms;` : ''} />
2158
+ <!-- Head opacity during draw/undraw is driven by arrowClipDraw (JS), in step with the path-length reveal. -->
2159
+ <path class="arrow-head" d={arrowHeadPath} fill="none" stroke={arrowEl.color} stroke-width={arrowEl.strokeWidth} stroke-linecap="round" stroke-linejoin="round" />
1881
2160
  {/if}
1882
2161
  {#if arrowEl.flowMarkers?.enabled}
1883
2162
  <FlowMarkers config={arrowEl.flowMarkers} start={arrowEl.startPoint} end={arrowEl.endPoint} controlPoints={cp} slideDuration={currentSlide?.duration} />
@@ -1902,21 +2181,27 @@
1902
2181
  {@const animFill = animated.fillColor?.current ?? shapeEl.fillColor}
1903
2182
  {@const animStroke = animated.strokeColor?.current ?? shapeEl.strokeColor}
1904
2183
  {@const animStrokeWidth = animated.strokeWidth?.current ?? shapeEl.strokeWidth}
1905
- {@const mState = shapeMorphStates.get(element.id)}
1906
- {@const morphProgress = animated.shapeMorph?.current ?? 1}
1907
- {@const effectiveShapeType = mState ? (morphProgress >= 1 ? mState.toType : (morphProgress <= 0 ? mState.fromType : null)) : shapeEl.shapeType}
1908
- {@const isMorphing = mState && morphProgress > 0 && morphProgress < 1}
1909
- <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'}>
1910
- {#if isMorphing}
1911
- {@const w = Math.max(0, animated.width.current)}
1912
- {@const h = Math.max(0, animated.height.current)}
1913
- {@const sw = animStrokeWidth}
1914
- <g style:opacity={1 - morphProgress}>{@html renderShape(mState!.fromType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1915
- <g style:opacity={morphProgress}>{@html renderShape(mState!.toType, w, h, animated.borderRadius.current, animFill, animStroke, sw, shapeEl.strokeStyle, shapeEl.strokeDashGap)}</g>
1916
- {:else}
1917
- {@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)}
1918
- {/if}
1919
- </svg>
2184
+ {@const shapeKpm = kfPathStates.get(element.id)}
2185
+ {@const shapePm = pathMorphStates.get(element.id)}
2186
+ {@const shapePmProg = crossMorphProg.get(elementId) ?? (animated.pathMorph?.current ?? 1)}
2187
+ {@const shapeKpmProg = kfPathProg.get(elementId) ?? (animated.pathMorph?.current ?? 1)}
2188
+ {@const shapeMorphProg = shapeKpm ? shapeKpmProg : shapePmProg}
2189
+ {@const shapeMorphActive = shapeKpm ?? ((shapePm && shapePmProg > 0 && shapePmProg < 1) ? shapePm : restingKfMorph(element))}
2190
+ {#if shapeMorphActive}
2191
+ <svg class="animot-shape-element" width="100%" height="100%" viewBox={shapeMorphActive.viewBox ?? `0 0 ${Math.max(1, animated.width.current)} ${Math.max(1, animated.height.current)}`} preserveAspectRatio="none">
2192
+ {#if shapeMorphActive.parts && shapeMorphActive.parts.length > 1}
2193
+ {#each shapeMorphActive.parts as part}
2194
+ <path d={part.interp(Math.max(0, Math.min(1, shapeMorphProg)))} fill={(element as any).morphColor ?? part.toColor} fill-rule="evenodd" />
2195
+ {/each}
2196
+ {:else}
2197
+ <path d={shapeMorphActive.interp(Math.max(0, Math.min(1, shapeMorphProg)))} fill={animFill} stroke={animStroke} stroke-width={animStrokeWidth} />
2198
+ {/if}
2199
+ </svg>
2200
+ {:else}
2201
+ <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'}>
2202
+ {@html renderShape(shapeEl.shapeType, Math.max(0, animated.width.current), Math.max(0, animated.height.current), animated.borderRadius.current, animFill, animStroke, animStrokeWidth, shapeEl.strokeStyle, shapeEl.strokeDashGap)}
2203
+ </svg>
2204
+ {/if}
1920
2205
  {:else if element.type === 'counter'}
1921
2206
  <CounterRenderer element={element as CounterElement} slideId={currentSlide?.id ?? ''} />
1922
2207
  {:else if element.type === 'chart'}
@@ -1938,28 +2223,41 @@
1938
2223
  <IconRenderer element={element as IconElement} />
1939
2224
  {:else if element.type === 'svg'}
1940
2225
  {@const svgEl = element as SvgElement}
1941
- {@const svgParsed = (() => { const m = svgEl.svgContent.trim().match(/^<svg([^>]*)>([\s\S]*)<\/svg>$/i); if (m) { const vb = m[1].match(/viewBox=["']([^"']+)["']/i); return { inner: m[2], viewBox: vb ? vb[1] : null }; } return { inner: svgEl.svgContent, viewBox: null }; })()}
1942
- {@const svgAnimMode = svgEl.animation?.mode ?? 'none'}
1943
- {@const svgAnimDur = svgEl.animation?.duration ?? 800}
1944
- {@const svgAnimLoop = svgEl.animation?.loop ?? false}
1945
- {@const svgAnimReverse = svgEl.animation?.direction === 'reverse'}
1946
- <div
1947
- class="animot-svg-element"
1948
- use:traceSvgPaths={{
1949
- enabled: svgAnimMode !== 'none',
1950
- mode: svgAnimMode as 'none' | 'draw' | 'undraw' | 'draw-undraw' | 'flow',
1951
- duration: svgAnimDur,
1952
- loop: svgAnimLoop,
1953
- reverse: svgAnimReverse,
1954
- key: `${svgEl.id}-${svgAnimMode}-${svgAnimDur}-${currentSlideIndex}`
1955
- }}
1956
- >
1957
- <svg width="100%" height="100%" viewBox={svgEl.viewBox ?? svgParsed.viewBox ?? `0 0 ${svgEl.size.width} ${svgEl.size.height}`} preserveAspectRatio={svgEl.preserveAspectRatio} xmlns="http://www.w3.org/2000/svg">
1958
- <g style={svgEl.color ? `fill:${svgEl.color};stroke:${svgEl.color}` : ''}>
1959
- {@html svgParsed.inner}
1960
- </g>
1961
- </svg>
1962
- </div>
2226
+ {@const kpm = kfPathStates.get(element.id)}
2227
+ {@const pm = pathMorphStates.get(element.id)}
2228
+ {@const pmProg = crossMorphProg.get(elementId) ?? (animated.pathMorph?.current ?? 1)}
2229
+ {@const kpmProg = kfPathProg.get(elementId) ?? (animated.pathMorph?.current ?? 1)}
2230
+ {@const morphProg = kpm ? kpmProg : pmProg}
2231
+ {@const svgMorph = kpm ?? ((pm && pmProg > 0 && pmProg < 1) ? pm : restingKfMorph(element))}
2232
+ {#if svgMorph}
2233
+ <svg class="animot-svg-element" width="100%" height="100%" viewBox={svgMorph.viewBox ?? morphViewBox(element, animated.width.current, animated.height.current)} preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
2234
+ {#each (svgMorph.parts ?? [{ interp: svgMorph.interp, fromColor: svgMorph.fromColor, toColor: svgMorph.toColor }]) as part}
2235
+ <path d={part.interp(Math.max(0, Math.min(1, morphProg)))} fill={(element as any).morphColor ?? part.toColor} fill-rule="evenodd" />
2236
+ {/each}
2237
+ </svg> {:else}
2238
+ {@const svgParsed = (() => { const m = svgEl.svgContent.trim().match(/^<svg([^>]*)>([\s\S]*)<\/svg>$/i); if (m) { const vb = m[1].match(/viewBox=["']([^"']+)["']/i); return { inner: m[2], viewBox: vb ? vb[1] : null }; } return { inner: svgEl.svgContent, viewBox: null }; })()}
2239
+ {@const svgAnimMode = svgEl.animation?.mode ?? 'none'}
2240
+ {@const svgAnimDur = svgEl.animation?.duration ?? 800}
2241
+ {@const svgAnimLoop = svgEl.animation?.loop ?? false}
2242
+ {@const svgAnimReverse = svgEl.animation?.direction === 'reverse'}
2243
+ <div
2244
+ class="animot-svg-element"
2245
+ use:traceSvgPaths={{
2246
+ enabled: svgAnimMode !== 'none',
2247
+ mode: svgAnimMode as 'none' | 'draw' | 'undraw' | 'draw-undraw' | 'flow',
2248
+ duration: svgAnimDur,
2249
+ loop: svgAnimLoop,
2250
+ reverse: svgAnimReverse,
2251
+ key: `${svgEl.id}-${svgAnimMode}-${svgAnimDur}-${currentSlideIndex}`
2252
+ }}
2253
+ >
2254
+ <svg width="100%" height="100%" viewBox={svgEl.viewBox ?? svgParsed.viewBox ?? `0 0 ${svgEl.size.width} ${svgEl.size.height}`} preserveAspectRatio={svgEl.preserveAspectRatio} xmlns="http://www.w3.org/2000/svg">
2255
+ <g style={svgEl.color ? `fill:${svgEl.color};stroke:${svgEl.color}` : ''}>
2256
+ {@html svgParsed.inner}
2257
+ </g>
2258
+ </svg>
2259
+ </div>
2260
+ {/if}
1963
2261
  {:else if element.type === 'motionPath'}
1964
2262
  {@const mpEl = element as MotionPathElement}
1965
2263
  {#if mpEl.showInPresentation}
@@ -1967,6 +2265,31 @@
1967
2265
  <path d={buildPresenterPathD(mpEl.points, mpEl.closed)} stroke={mpEl.pathColor} stroke-width={mpEl.pathWidth} fill="none" stroke-dasharray="8 4" />
1968
2266
  </svg>
1969
2267
  {/if}
2268
+ {:else if element.type === 'draw'}
2269
+ {@const drawEl = element as DrawElement}
2270
+ {@const dkpm = kfPathStates.get(element.id)}
2271
+ {@const dpm = pathMorphStates.get(element.id)}
2272
+ {@const pmProg = crossMorphProg.get(elementId) ?? (animated.pathMorph?.current ?? 1)}
2273
+ {@const kpmProg = kfPathProg.get(elementId) ?? (animated.pathMorph?.current ?? 1)}
2274
+ {@const morphProg = dkpm ? kpmProg : pmProg}
2275
+ {@const drawMorph = dkpm ?? ((dpm && pmProg > 0 && pmProg < 1) ? dpm : restingKfMorph(element))}
2276
+ {@const drawPath = drawElementToPath(drawEl)}
2277
+ <svg class="animot-draw-element" width="100%" height="100%" viewBox={drawMorph?.viewBox ?? drawPath.viewBox} preserveAspectRatio="none" style="display:block;overflow:visible;" xmlns="http://www.w3.org/2000/svg">
2278
+ {#if drawMorph}
2279
+ {#each (drawMorph.parts ?? [{ interp: drawMorph.interp, fromColor: drawMorph.fromColor, toColor: drawMorph.toColor }]) as part}
2280
+ <path d={part.interp(Math.max(0, Math.min(1, morphProg)))} fill={(element as any).morphColor ?? part.toColor} fill-rule="evenodd" />
2281
+ {/each}
2282
+ {:else}
2283
+ <path d={drawPath.d} fill={drawEl.color} />
2284
+ {/if}
2285
+ </svg>
2286
+ {:else if element.type === 'sticky'}
2287
+ {@const stickyEl = element as StickyElement}
2288
+ <div class="animot-sticky-element" style:background={stickyEl.bgColor} style:color={stickyEl.textColor} style:border-radius="{stickyEl.borderRadius}px" style:padding="{stickyEl.padding}px" style:box-shadow={stickyEl.shadow === false ? 'none' : '0 10px 24px rgba(0,0,0,0.30), 0 3px 6px rgba(0,0,0,0.20)'}>
2289
+ <div class="animot-sticky-sheen"></div>
2290
+ <div class="animot-sticky-fold"></div>
2291
+ <div class="animot-sticky-text" style:font-size="{stickyEl.fontSize}px" style:font-family="'{stickyEl.fontFamily}', sans-serif" style:font-weight={stickyEl.fontWeight} style:text-align={stickyEl.textAlign}>{stickyEl.text}</div>
2292
+ </div>
1970
2293
  {/if}
1971
2294
  </div>
1972
2295
  {/if}