@particle-academy/fancy-slides 0.3.0 → 0.5.0

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.
package/dist/index.cjs CHANGED
@@ -72,13 +72,211 @@ function resolveTheme(theme) {
72
72
  function cn(...parts) {
73
73
  return parts.filter(Boolean).join(" ");
74
74
  }
75
+
76
+ // src/utils/builds.ts
77
+ function splitParagraphs(content) {
78
+ const lines = content.split("\n");
79
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
80
+ return lines;
81
+ }
82
+ function isByParagraph(element, animation) {
83
+ if (!animation.byParagraph || element.type !== "text") return false;
84
+ return splitParagraphs(element.content).length > 1;
85
+ }
86
+ var DEFAULT_BUILD_DURATION = 500;
87
+ function collectBuilds(slide) {
88
+ if (!slide) return [];
89
+ const builds = [];
90
+ slide.elements.forEach((element, index) => {
91
+ if (element.animation) {
92
+ builds.push({ element, animation: element.animation, index });
93
+ }
94
+ });
95
+ const ordered = builds.sort((a, b) => {
96
+ const ao = a.animation.order ?? 0;
97
+ const bo = b.animation.order ?? 0;
98
+ if (ao !== bo) return ao - bo;
99
+ return a.index - b.index;
100
+ });
101
+ const expanded = [];
102
+ for (const build of ordered) {
103
+ if (isByParagraph(build.element, build.animation)) {
104
+ const paras = splitParagraphs(build.element.content);
105
+ paras.forEach((_, paraIndex) => {
106
+ const animation = paraIndex === 0 ? build.animation : { ...build.animation, trigger: "on-click" };
107
+ expanded.push({ element: build.element, animation, index: build.index, paraIndex });
108
+ });
109
+ } else {
110
+ expanded.push(build);
111
+ }
112
+ }
113
+ return expanded;
114
+ }
115
+ function buildSteps(slide) {
116
+ const builds = collectBuilds(slide);
117
+ const steps = [];
118
+ for (const build of builds) {
119
+ const trigger = build.animation.trigger ?? "on-click";
120
+ if (steps.length === 0 || trigger === "on-click") {
121
+ steps.push({ builds: [build] });
122
+ } else {
123
+ steps[steps.length - 1].builds.push(build);
124
+ }
125
+ }
126
+ return steps;
127
+ }
128
+ function totalBuildSteps(slide) {
129
+ return buildSteps(slide).length;
130
+ }
131
+ function visibleElementIds(slide, buildStep) {
132
+ const visible = /* @__PURE__ */ new Set();
133
+ if (!slide) return visible;
134
+ const steps = buildSteps(slide);
135
+ const stepOfElement = /* @__PURE__ */ new Map();
136
+ steps.forEach((step, i) => {
137
+ for (const b of step.builds) {
138
+ if (!stepOfElement.has(b.element.id)) stepOfElement.set(b.element.id, i + 1);
139
+ }
140
+ });
141
+ for (const element of slide.elements) {
142
+ const revealStep = stepOfElement.get(element.id);
143
+ if (revealStep === void 0) {
144
+ visible.add(element.id);
145
+ } else if (buildStep >= revealStep) {
146
+ visible.add(element.id);
147
+ }
148
+ }
149
+ return visible;
150
+ }
151
+ function paragraphReveals(slide, buildStep) {
152
+ const out = /* @__PURE__ */ new Map();
153
+ if (!slide) return out;
154
+ const steps = buildSteps(slide);
155
+ steps.forEach((step, i) => {
156
+ const stepNum = i + 1;
157
+ for (const b of step.builds) {
158
+ if (b.paraIndex === void 0) continue;
159
+ const fired = buildStep >= stepNum;
160
+ const prev = out.get(b.element.id) ?? { revealed: 0 };
161
+ if (fired) {
162
+ prev.revealed = Math.max(prev.revealed, b.paraIndex + 1);
163
+ if (stepNum === buildStep) prev.firingParaIndex = b.paraIndex;
164
+ }
165
+ out.set(b.element.id, prev);
166
+ }
167
+ });
168
+ return out;
169
+ }
170
+ function buildsForStep(slide, buildStep) {
171
+ const steps = buildSteps(slide);
172
+ const step = steps[buildStep - 1];
173
+ return step ? step.builds : [];
174
+ }
175
+ function stepDelays(builds) {
176
+ const delays = /* @__PURE__ */ new Map();
177
+ const lead = builds[0];
178
+ if (!lead) return delays;
179
+ const leadDelay = lead.animation.delay ?? 0;
180
+ const leadDuration = lead.animation.duration ?? DEFAULT_BUILD_DURATION;
181
+ delays.set(lead.element.id, leadDelay);
182
+ for (let i = 1; i < builds.length; i++) {
183
+ const b = builds[i];
184
+ const own = b.animation.delay ?? 0;
185
+ const trigger = b.animation.trigger ?? "on-click";
186
+ const base = trigger === "after-prev" ? leadDelay + leadDuration : leadDelay;
187
+ delays.set(b.element.id, base + own);
188
+ }
189
+ return delays;
190
+ }
191
+
192
+ // src/components/Slide/builds-style.ts
193
+ var DEFAULT_BUILD_DURATION2 = 500;
194
+ var EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
195
+ function buildEnterStyle(animation, effectiveDelay) {
196
+ const duration = animation.duration ?? DEFAULT_BUILD_DURATION2;
197
+ const dir = animation.direction ?? "left";
198
+ let name;
199
+ switch (animation.effect) {
200
+ case "fade":
201
+ name = "fs-build-fade";
202
+ break;
203
+ case "zoom":
204
+ name = "fs-build-zoom";
205
+ break;
206
+ case "fly-in":
207
+ name = `fs-build-fly-${dir}`;
208
+ break;
209
+ case "wipe":
210
+ name = `fs-build-wipe-${dir}`;
211
+ break;
212
+ default:
213
+ name = "fs-build-fade";
214
+ }
215
+ return {
216
+ animationName: name,
217
+ animationDuration: `${duration}ms`,
218
+ animationDelay: `${effectiveDelay}ms`,
219
+ animationTimingFunction: EASE,
220
+ animationFillMode: "both"
221
+ };
222
+ }
223
+ var BUILD_KEYFRAMES = `
224
+ @media (prefers-reduced-motion: reduce) {
225
+ .fs-build-enter { animation: none !important; }
226
+ }
227
+ @media (prefers-reduced-motion: no-preference) {
228
+ @keyframes fs-build-fade {
229
+ from { opacity: 0; }
230
+ to { opacity: 1; }
231
+ }
232
+ @keyframes fs-build-zoom {
233
+ from { opacity: 0; transform: scale(0.8); }
234
+ to { opacity: 1; transform: scale(1); }
235
+ }
236
+ @keyframes fs-build-fly-left {
237
+ from { opacity: 0; transform: translateX(-24%); }
238
+ to { opacity: 1; transform: translateX(0); }
239
+ }
240
+ @keyframes fs-build-fly-right {
241
+ from { opacity: 0; transform: translateX(24%); }
242
+ to { opacity: 1; transform: translateX(0); }
243
+ }
244
+ @keyframes fs-build-fly-up {
245
+ from { opacity: 0; transform: translateY(24%); }
246
+ to { opacity: 1; transform: translateY(0); }
247
+ }
248
+ @keyframes fs-build-fly-down {
249
+ from { opacity: 0; transform: translateY(-24%); }
250
+ to { opacity: 1; transform: translateY(0); }
251
+ }
252
+ /* wipe: clip-path inset reveals from the named edge toward the opposite one.
253
+ inset(top right bottom left) \u2014 start fully clipped on the far side. */
254
+ @keyframes fs-build-wipe-left {
255
+ from { clip-path: inset(0 100% 0 0); }
256
+ to { clip-path: inset(0 0 0 0); }
257
+ }
258
+ @keyframes fs-build-wipe-right {
259
+ from { clip-path: inset(0 0 0 100%); }
260
+ to { clip-path: inset(0 0 0 0); }
261
+ }
262
+ @keyframes fs-build-wipe-up {
263
+ from { clip-path: inset(100% 0 0 0); }
264
+ to { clip-path: inset(0 0 0 0); }
265
+ }
266
+ @keyframes fs-build-wipe-down {
267
+ from { clip-path: inset(0 0 100% 0); }
268
+ to { clip-path: inset(0 0 0 0); }
269
+ }
270
+ }
271
+ `;
75
272
  function TextElementRenderer({
76
273
  element,
77
274
  theme,
78
275
  slideWidthPx,
79
276
  editing = false,
80
277
  selected = false,
81
- onContentChange
278
+ onContentChange,
279
+ paraReveal
82
280
  }) {
83
281
  const t = resolveTheme(theme);
84
282
  const style = element.style ?? {};
@@ -125,30 +323,53 @@ function TextElementRenderer({
125
323
  }
126
324
  );
127
325
  }
326
+ const proseScope = `[data-fs-text-scope="${scopeId}"]`;
327
+ const doubleScope = `${proseScope}${proseScope}`;
328
+ const proseStyle = /* @__PURE__ */ jsxRuntime.jsx("style", { children: `
329
+ ${proseScope} > div { width: 100%; height: 100%; font-size: inherit; }
330
+ ${doubleScope} :is(p, ul, ol, li, blockquote, h1, h2, h3, h4, h5, h6, pre, code, strong, em, a) {
331
+ font-size: inherit;
332
+ }
333
+ ${doubleScope} h1 { font-size: 1.6em; font-weight: 700; }
334
+ ${doubleScope} h2 { font-size: 1.35em; font-weight: 700; }
335
+ ${doubleScope} h3 { font-size: 1.15em; font-weight: 600; }
336
+ ${proseScope} :where(p, ul, ol, h1, h2, h3, h4, h5, h6, pre, blockquote) {
337
+ margin: 0;
338
+ padding: 0;
339
+ }
340
+ ${proseScope} :where(p, li) + :where(p, li, ul, ol) { margin-top: 0.4em; }
341
+ ${proseScope} :where(ul, ol) { padding-left: 1.4em; }
342
+ ${proseScope} :where(strong) { font-weight: ${Math.max(700, weight(style.weight) ?? 400 + 200)}; }
343
+ ${proseScope} :where(a) { color: inherit; text-decoration: underline; }
344
+ ${proseScope} :where(code) { font-family: ${t.fonts?.mono ?? "monospace"}; }
345
+ ` });
346
+ const renderChunk = (content) => format === "plain" ? content : /* @__PURE__ */ jsxRuntime.jsx(reactFancy.ContentRenderer, { value: content, format: format === "html" ? "html" : "markdown" });
347
+ if (paraReveal) {
348
+ const paras = splitParagraphs(element.content);
349
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-fs-text-scope": scopeId, style: css, children: [
350
+ proseStyle,
351
+ paras.map((para, i) => {
352
+ if (i >= paraReveal.revealed) return null;
353
+ const firing = i === paraReveal.firingParaIndex && !!element.animation;
354
+ const enter = firing ? buildEnterStyle(element.animation, element.animation.delay ?? 0) : null;
355
+ return /* @__PURE__ */ jsxRuntime.jsx(
356
+ "div",
357
+ {
358
+ className: firing ? "fs-build-enter" : void 0,
359
+ style: { whiteSpace: format === "plain" ? "pre-wrap" : "normal", ...enter },
360
+ "data-fancy-slides-paragraph": i,
361
+ children: renderChunk(para)
362
+ },
363
+ i
364
+ );
365
+ })
366
+ ] });
367
+ }
128
368
  if (format === "plain") {
129
369
  return /* @__PURE__ */ jsxRuntime.jsx("div", { style: css, children: element.content });
130
370
  }
131
- const proseScope = `[data-fs-text-scope="${scopeId}"]`;
132
- const doubleScope = `${proseScope}${proseScope}`;
133
371
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-fs-text-scope": scopeId, style: css, children: [
134
- /* @__PURE__ */ jsxRuntime.jsx("style", { children: `
135
- ${proseScope} > div { width: 100%; height: 100%; font-size: inherit; }
136
- ${doubleScope} :is(p, ul, ol, li, blockquote, h1, h2, h3, h4, h5, h6, pre, code, strong, em, a) {
137
- font-size: inherit;
138
- }
139
- ${doubleScope} h1 { font-size: 1.6em; font-weight: 700; }
140
- ${doubleScope} h2 { font-size: 1.35em; font-weight: 700; }
141
- ${doubleScope} h3 { font-size: 1.15em; font-weight: 600; }
142
- ${proseScope} :where(p, ul, ol, h1, h2, h3, h4, h5, h6, pre, blockquote) {
143
- margin: 0;
144
- padding: 0;
145
- }
146
- ${proseScope} :where(p, li) + :where(p, li, ul, ol) { margin-top: 0.4em; }
147
- ${proseScope} :where(ul, ol) { padding-left: 1.4em; }
148
- ${proseScope} :where(strong) { font-weight: ${Math.max(700, weight(style.weight) ?? 400 + 200)}; }
149
- ${proseScope} :where(a) { color: inherit; text-decoration: underline; }
150
- ${proseScope} :where(code) { font-family: ${t.fonts?.mono ?? "monospace"}; }
151
- ` }),
372
+ proseStyle,
152
373
  /* @__PURE__ */ jsxRuntime.jsx(reactFancy.ContentRenderer, { value: element.content, format: format === "html" ? "html" : "markdown" })
153
374
  ] });
154
375
  }
@@ -286,6 +507,7 @@ function Slide({
286
507
  width,
287
508
  aspectRatio,
288
509
  editing = false,
510
+ buildStep,
289
511
  onElementContentChange,
290
512
  onElementSelect,
291
513
  selectedElementId,
@@ -329,7 +551,24 @@ function Slide({
329
551
  }),
330
552
  [t, effectiveBg, slideWidthPx]
331
553
  );
332
- return /* @__PURE__ */ jsxRuntime.jsx(SlideContext.Provider, { value: slideContext, children: /* @__PURE__ */ jsxRuntime.jsx(
554
+ const buildInfo = react.useMemo(() => {
555
+ if (editing) return null;
556
+ const steps = buildSteps(slide);
557
+ if (steps.length === 0) return null;
558
+ const revealStep = /* @__PURE__ */ new Map();
559
+ steps.forEach((step, i) => {
560
+ for (const b of step.builds) {
561
+ if (!revealStep.has(b.element.id)) revealStep.set(b.element.id, i + 1);
562
+ }
563
+ });
564
+ const driven = buildStep !== void 0;
565
+ const currentStep = driven ? buildStep : steps.length;
566
+ const firing = driven ? steps[currentStep - 1] : void 0;
567
+ const delays = firing ? stepDelays(firing.builds) : /* @__PURE__ */ new Map();
568
+ const paraReveals = driven ? paragraphReveals(slide, currentStep) : /* @__PURE__ */ new Map();
569
+ return { revealStep, currentStep, delays, paraReveals, driven };
570
+ }, [editing, slide, buildStep]);
571
+ return /* @__PURE__ */ jsxRuntime.jsx(SlideContext.Provider, { value: slideContext, children: /* @__PURE__ */ jsxRuntime.jsxs(
333
572
  "div",
334
573
  {
335
574
  ref,
@@ -347,23 +586,47 @@ function Slide({
347
586
  onClick: (e) => {
348
587
  if (e.target === e.currentTarget && onElementSelect) onElementSelect(null);
349
588
  },
350
- children: orderedElements(slide.elements).map((element) => /* @__PURE__ */ jsxRuntime.jsx(
351
- SlideElementHost,
352
- {
353
- element,
354
- theme: t,
355
- slideWidthPx,
356
- slideHeightPx,
357
- editing,
358
- selected: selectedElementId === element.id,
359
- onContentChange: onElementContentChange,
360
- onSelect: onElementSelect,
361
- onMove: onElementMove,
362
- onResize: onElementResize,
363
- renderElement
364
- },
365
- element.id
366
- ))
589
+ children: [
590
+ buildInfo && /* @__PURE__ */ jsxRuntime.jsx("style", { children: BUILD_KEYFRAMES }),
591
+ orderedElements(slide.elements).map((element) => {
592
+ let buildHidden = false;
593
+ let buildAnimation;
594
+ let buildDelay = 0;
595
+ const paraReveal = buildInfo?.paraReveals.get(element.id);
596
+ if (buildInfo) {
597
+ const step = buildInfo.revealStep.get(element.id);
598
+ if (step !== void 0) {
599
+ if (buildInfo.currentStep < step) {
600
+ buildHidden = true;
601
+ } else if (paraReveal) ; else if (buildInfo.currentStep === step && element.animation) {
602
+ buildAnimation = element.animation;
603
+ buildDelay = buildInfo.delays.get(element.id) ?? 0;
604
+ }
605
+ }
606
+ }
607
+ if (buildHidden) return null;
608
+ return /* @__PURE__ */ jsxRuntime.jsx(
609
+ SlideElementHost,
610
+ {
611
+ element,
612
+ theme: t,
613
+ slideWidthPx,
614
+ slideHeightPx,
615
+ editing,
616
+ selected: selectedElementId === element.id,
617
+ onContentChange: onElementContentChange,
618
+ onSelect: onElementSelect,
619
+ onMove: onElementMove,
620
+ onResize: onElementResize,
621
+ renderElement,
622
+ buildAnimation,
623
+ buildDelay,
624
+ paraReveal
625
+ },
626
+ element.id
627
+ );
628
+ })
629
+ ]
367
630
  }
368
631
  ) });
369
632
  }
@@ -380,7 +643,10 @@ function SlideElementHost({
380
643
  onSelect,
381
644
  onMove,
382
645
  onResize,
383
- renderElement
646
+ renderElement,
647
+ buildAnimation,
648
+ buildDelay = 0,
649
+ paraReveal
384
650
  }) {
385
651
  const dragRef = react.useRef(null);
386
652
  if (element.hidden) return null;
@@ -449,15 +715,18 @@ function SlideElementHost({
449
715
  outline: selected ? "2px solid #8b5cf6" : void 0,
450
716
  outlineOffset: selected ? 2 : void 0,
451
717
  cursor: canMove ? "move" : interactive ? "pointer" : "default",
452
- touchAction: canMove ? "none" : void 0
718
+ touchAction: canMove ? "none" : void 0,
719
+ ...buildAnimation ? buildEnterStyle(buildAnimation, buildDelay) : null
453
720
  };
454
- const inner = renderInner({ element, theme, slideWidthPx, editing, selected, onContentChange }) ?? renderElement?.(element, slideWidthPx);
721
+ const inner = renderInner({ element, theme, slideWidthPx, editing, selected, onContentChange, paraReveal }) ?? renderElement?.(element, slideWidthPx);
455
722
  return /* @__PURE__ */ jsxRuntime.jsxs(
456
723
  "div",
457
724
  {
725
+ className: buildAnimation ? "fs-build-enter" : void 0,
458
726
  style: box,
459
727
  "data-fancy-slides-element": element.id,
460
728
  "data-fancy-slides-element-type": element.type,
729
+ "data-fancy-slides-build": buildAnimation ? "" : void 0,
461
730
  onPointerDown: canMove ? startDrag("move") : void 0,
462
731
  onPointerMove: canMove ? onPointerMove : void 0,
463
732
  onPointerUp: canMove ? endDrag : void 0,
@@ -516,7 +785,7 @@ function ResizeHandles({ onStart, onMove, onEnd }) {
516
785
  anchor
517
786
  )) });
518
787
  }
519
- function renderInner({ element, theme, slideWidthPx, editing, selected, onContentChange }) {
788
+ function renderInner({ element, theme, slideWidthPx, editing, selected, onContentChange, paraReveal }) {
520
789
  switch (element.type) {
521
790
  case "text":
522
791
  return /* @__PURE__ */ jsxRuntime.jsx(
@@ -527,7 +796,8 @@ function renderInner({ element, theme, slideWidthPx, editing, selected, onConten
527
796
  slideWidthPx,
528
797
  editing,
529
798
  selected,
530
- onContentChange: onContentChange ? (c) => onContentChange(element.id, c) : void 0
799
+ onContentChange: onContentChange ? (c) => onContentChange(element.id, c) : void 0,
800
+ paraReveal
531
801
  }
532
802
  );
533
803
  case "image":
@@ -579,6 +849,8 @@ function useSlideKeyboard({
579
849
  total,
580
850
  index,
581
851
  goTo,
852
+ onAdvance,
853
+ onRetreat,
582
854
  onExit,
583
855
  onBlank,
584
856
  onFullscreen,
@@ -595,13 +867,15 @@ function useSlideKeyboard({
595
867
  case "ArrowLeft":
596
868
  case "PageUp":
597
869
  e.preventDefault();
598
- if (index > 0) goTo(index - 1);
870
+ if (onRetreat) onRetreat();
871
+ else if (index > 0) goTo(index - 1);
599
872
  return;
600
873
  case "ArrowRight":
601
874
  case "PageDown":
602
875
  case " ":
603
876
  e.preventDefault();
604
- if (index < total - 1) goTo(index + 1);
877
+ if (onAdvance) onAdvance();
878
+ else if (index < total - 1) goTo(index + 1);
605
879
  return;
606
880
  case "Home":
607
881
  e.preventDefault();
@@ -643,7 +917,7 @@ function useSlideKeyboard({
643
917
  };
644
918
  window.addEventListener("keydown", handler);
645
919
  return () => window.removeEventListener("keydown", handler);
646
- }, [enabled, index, total, goTo, onExit, onBlank, onFullscreen]);
920
+ }, [enabled, index, total, goTo, onAdvance, onRetreat, onExit, onBlank, onFullscreen]);
647
921
  }
648
922
  function SlideViewer({
649
923
  deck,
@@ -671,13 +945,34 @@ function SlideViewer({
671
945
  const containerRef = react.useRef(null);
672
946
  const prevIndexRef = react.useRef(index);
673
947
  const forward = index >= prevIndexRef.current;
948
+ const slide = deck.slides[index];
949
+ const totalSteps = totalBuildSteps(slide);
950
+ const [buildStep, setBuildStep] = react.useState(0);
951
+ const nextFreshRef = react.useRef(false);
674
952
  react.useEffect(() => {
953
+ if (index === prevIndexRef.current) return;
675
954
  prevIndexRef.current = index;
676
- }, [index]);
955
+ const fresh = nextFreshRef.current;
956
+ nextFreshRef.current = false;
957
+ setBuildStep(fresh ? 0 : totalBuildSteps(deck.slides[index]));
958
+ }, [index, deck.slides]);
959
+ const advance = react.useCallback(() => {
960
+ if (buildStep < totalSteps) {
961
+ setBuildStep((s) => s + 1);
962
+ } else if (index < deck.slides.length - 1) {
963
+ nextFreshRef.current = true;
964
+ goTo(index + 1);
965
+ }
966
+ }, [buildStep, totalSteps, index, deck.slides.length, goTo]);
967
+ const retreat = react.useCallback(() => {
968
+ if (index > 0) goTo(index - 1);
969
+ }, [index, goTo]);
677
970
  useSlideKeyboard({
678
971
  total: deck.slides.length,
679
972
  index,
680
973
  goTo,
974
+ onAdvance: advance,
975
+ onRetreat: retreat,
681
976
  onExit,
682
977
  onBlank: () => setBlanked((b) => !b),
683
978
  onFullscreen: () => {
@@ -690,11 +985,15 @@ function SlideViewer({
690
985
  react.useEffect(() => {
691
986
  if (!autoAdvanceMs || deck.slides.length <= 1) return;
692
987
  const t = setTimeout(() => {
693
- goTo(index + 1 < deck.slides.length ? index + 1 : 0);
988
+ if (buildStep < totalSteps) {
989
+ setBuildStep((s) => s + 1);
990
+ } else {
991
+ nextFreshRef.current = true;
992
+ goTo(index + 1 < deck.slides.length ? index + 1 : 0);
993
+ }
694
994
  }, autoAdvanceMs);
695
995
  return () => clearTimeout(t);
696
- }, [autoAdvanceMs, index, deck.slides.length, goTo]);
697
- const slide = deck.slides[index];
996
+ }, [autoAdvanceMs, index, deck.slides.length, goTo, buildStep, totalSteps]);
698
997
  const theme = resolveTheme(deck.theme);
699
998
  const aspectRatio = theme.aspectRatio ?? 16 / 9;
700
999
  const transition = slide?.transition ?? theme.defaultTransition;
@@ -716,6 +1015,11 @@ function SlideViewer({
716
1015
  },
717
1016
  tabIndex: 0,
718
1017
  "data-fancy-slides-viewer": deck.id,
1018
+ "data-fancy-slides-build-step": buildStep,
1019
+ onClick: () => {
1020
+ if (blanked) return;
1021
+ advance();
1022
+ },
719
1023
  children: [
720
1024
  /* @__PURE__ */ jsxRuntime.jsx("style", { children: TRANSITION_KEYFRAMES }),
721
1025
  !blanked && slide && /* @__PURE__ */ jsxRuntime.jsx(
@@ -729,7 +1033,7 @@ function SlideViewer({
729
1033
  ["--fs-ratio"]: aspectRatio.toString(),
730
1034
  boxShadow: "0 8px 30px rgba(0,0,0,0.35)"
731
1035
  },
732
- children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fs-slide-enter", style: enterStyle, children: /* @__PURE__ */ jsxRuntime.jsx(Slide, { slide, theme, renderElement }) }, index)
1036
+ children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fs-slide-enter", style: enterStyle, children: /* @__PURE__ */ jsxRuntime.jsx(Slide, { slide, theme, buildStep, renderElement }) }, index)
733
1037
  }
734
1038
  ),
735
1039
  !hideChrome && !blanked && /* @__PURE__ */ jsxRuntime.jsxs(
@@ -760,7 +1064,7 @@ function SlideViewer({
760
1064
  );
761
1065
  }
762
1066
  var DEFAULT_DURATION = 400;
763
- var EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
1067
+ var EASE2 = "cubic-bezier(0.16, 1, 0.3, 1)";
764
1068
  function transitionEnterStyle(transition, forward) {
765
1069
  const kind = transition?.kind ?? "none";
766
1070
  if (kind === "none") return { width: "100%", height: "100%" };
@@ -786,7 +1090,7 @@ function transitionEnterStyle(transition, forward) {
786
1090
  height: "100%",
787
1091
  animationName: name,
788
1092
  animationDuration: `${duration}ms`,
789
- animationTimingFunction: EASE,
1093
+ animationTimingFunction: EASE2,
790
1094
  animationFillMode: "both"
791
1095
  };
792
1096
  }
@@ -842,14 +1146,38 @@ function PresenterView({
842
1146
  },
843
1147
  [deck.slides.length, isControlled, onIndexChange]
844
1148
  );
1149
+ const slide = deck.slides[index];
1150
+ const totalSteps = totalBuildSteps(slide);
1151
+ const [buildStep, setBuildStep] = react.useState(0);
1152
+ const prevIndexRef = react.useRef(index);
1153
+ const nextFreshRef = react.useRef(false);
1154
+ react.useEffect(() => {
1155
+ if (index === prevIndexRef.current) return;
1156
+ prevIndexRef.current = index;
1157
+ const fresh = nextFreshRef.current;
1158
+ nextFreshRef.current = false;
1159
+ setBuildStep(fresh ? 0 : totalBuildSteps(deck.slides[index]));
1160
+ }, [index, deck.slides]);
1161
+ const advance = react.useCallback(() => {
1162
+ if (buildStep < totalSteps) {
1163
+ setBuildStep((s) => s + 1);
1164
+ } else if (index < deck.slides.length - 1) {
1165
+ nextFreshRef.current = true;
1166
+ goTo(index + 1);
1167
+ }
1168
+ }, [buildStep, totalSteps, index, deck.slides.length, goTo]);
1169
+ const retreat = react.useCallback(() => {
1170
+ if (index > 0) goTo(index - 1);
1171
+ }, [index, goTo]);
845
1172
  useSlideKeyboard({
846
1173
  total: deck.slides.length,
847
1174
  index,
848
1175
  goTo,
1176
+ onAdvance: advance,
1177
+ onRetreat: retreat,
849
1178
  onExit
850
1179
  });
851
1180
  const theme = resolveTheme(deck.theme);
852
- const slide = deck.slides[index];
853
1181
  const nextSlide = deck.slides[index + 1];
854
1182
  const [now, setNow] = react.useState(() => Date.now());
855
1183
  react.useEffect(() => {
@@ -896,7 +1224,7 @@ function PresenterView({
896
1224
  borderRadius: 8,
897
1225
  overflow: "hidden"
898
1226
  },
899
- children: slide ? /* @__PURE__ */ jsxRuntime.jsx(Slide, { slide, theme, renderElement }) : null
1227
+ children: slide ? /* @__PURE__ */ jsxRuntime.jsx(Slide, { slide, theme, buildStep, renderElement }) : null
900
1228
  }
901
1229
  )
902
1230
  }
@@ -1000,8 +1328,8 @@ function PresenterView({
1000
1328
  /* @__PURE__ */ jsxRuntime.jsx(StatusChip, { label: "Elapsed", children: formatElapsed(now - startedAtRef) }),
1001
1329
  /* @__PURE__ */ jsxRuntime.jsx(StatusChip, { label: "Clock", children: formatClock(now) }),
1002
1330
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { marginLeft: "auto", display: "flex", gap: 8 }, children: [
1003
- /* @__PURE__ */ jsxRuntime.jsx(NavButton, { onClick: () => goTo(index - 1), disabled: index === 0, children: "\u2190 Prev" }),
1004
- /* @__PURE__ */ jsxRuntime.jsx(NavButton, { onClick: () => goTo(index + 1), disabled: index >= deck.slides.length - 1, children: "Next \u2192" })
1331
+ /* @__PURE__ */ jsxRuntime.jsx(NavButton, { onClick: retreat, disabled: index === 0, children: "\u2190 Prev" }),
1332
+ /* @__PURE__ */ jsxRuntime.jsx(NavButton, { onClick: advance, disabled: index >= deck.slides.length - 1 && buildStep >= totalSteps, children: "Next \u2192" })
1005
1333
  ] })
1006
1334
  ]
1007
1335
  }
@@ -1184,6 +1512,7 @@ function useDeckState({ value, onChange, onOp }) {
1184
1512
  updateElement: (slideIdArg, elementIdArg, patch) => apply({ kind: "element_update", slideId: slideIdArg, elementId: elementIdArg, patch }),
1185
1513
  moveElement: (slideIdArg, elementIdArg, x, y) => apply({ kind: "element_move", slideId: slideIdArg, elementId: elementIdArg, x, y }),
1186
1514
  resizeElement: (slideIdArg, elementIdArg, w, h) => apply({ kind: "element_resize", slideId: slideIdArg, elementId: elementIdArg, w, h }),
1515
+ setAnimation: (slideIdArg, elementIdArg, animation) => apply({ kind: "element_set_animation", slideId: slideIdArg, elementId: elementIdArg, animation }),
1187
1516
  getSlide: (id) => value.slides.find((s) => s.id === id),
1188
1517
  getElement: (slideIdArg, elementIdArg) => value.slides.find((s) => s.id === slideIdArg)?.elements.find((e) => e.id === elementIdArg)
1189
1518
  };
@@ -1251,6 +1580,23 @@ function reduce(deck, op) {
1251
1580
  (s) => s.id === op.slideId ? { ...s, elements: s.elements.map((e) => e.id === op.elementId ? { ...e, w: op.w, h: op.h } : e) } : s
1252
1581
  )
1253
1582
  };
1583
+ case "element_set_animation":
1584
+ return {
1585
+ ...deck,
1586
+ slides: deck.slides.map(
1587
+ (s) => s.id === op.slideId ? {
1588
+ ...s,
1589
+ elements: s.elements.map((e) => {
1590
+ if (e.id !== op.elementId) return e;
1591
+ if (op.animation === void 0) {
1592
+ const { animation: _drop, ...rest } = e;
1593
+ return rest;
1594
+ }
1595
+ return { ...e, animation: op.animation };
1596
+ })
1597
+ } : s
1598
+ )
1599
+ };
1254
1600
  }
1255
1601
  }
1256
1602
 
@@ -1592,10 +1938,10 @@ function EditorToolbar({
1592
1938
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "ml-auto flex items-center gap-2", children: /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Tooltip, { content: "Present (F)", children: /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Action, { color: "violet", size: "sm", icon: "play", onClick: onPresent, children: "Present" }) }) })
1593
1939
  ] });
1594
1940
  }
1595
- function ElementInspector({ element, onPatch, onDelete, onLockToggle, slide, onSetTransition, onSetBackground }) {
1941
+ function ElementInspector({ element, onPatch, onDelete, onLockToggle, slide, onSetTransition, onSetBackground, onSetAnimation, onSetElementAnimation }) {
1596
1942
  if (!element) {
1597
1943
  if (slide) {
1598
- return /* @__PURE__ */ jsxRuntime.jsx(SlideSettings, { slide, onSetTransition, onSetBackground });
1944
+ return /* @__PURE__ */ jsxRuntime.jsx(SlideSettings, { slide, onSetTransition, onSetBackground, onSetElementAnimation });
1599
1945
  }
1600
1946
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fs-inspector flex h-full flex-col border-l border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900", children: [
1601
1947
  /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Heading, { as: "h3", size: "xs", className: "!uppercase !tracking-wider !text-zinc-500", children: "Inspector" }),
@@ -1620,10 +1966,12 @@ function ElementInspector({ element, onPatch, onDelete, onLockToggle, slide, onS
1620
1966
  /* @__PURE__ */ jsxRuntime.jsxs(reactFancy.Tabs.List, { children: [
1621
1967
  /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Tabs.Tab, { value: "style", children: "Style" }),
1622
1968
  /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Tabs.Tab, { value: "layout", children: "Layout" }),
1969
+ /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Tabs.Tab, { value: "build", children: "Build" }),
1623
1970
  /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Tabs.Tab, { value: "advanced", children: "Advanced" })
1624
1971
  ] }),
1625
1972
  /* @__PURE__ */ jsxRuntime.jsxs(reactFancy.Tabs.Panels, { children: [
1626
1973
  /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Tabs.Panel, { value: "style", children: /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Card, { padding: "md", className: "!bg-white dark:!bg-zinc-950", children: /* @__PURE__ */ jsxRuntime.jsx(StyleSection, { element, onPatch }) }) }),
1974
+ /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Tabs.Panel, { value: "build", children: /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Card, { padding: "md", className: "!bg-white dark:!bg-zinc-950", children: /* @__PURE__ */ jsxRuntime.jsx(AnimateSection, { animation: element.animation, onSetAnimation, isText: element.type === "text" }) }) }),
1627
1975
  /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Tabs.Panel, { value: "layout", children: /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Card, { padding: "md", className: "!bg-white dark:!bg-zinc-950", children: /* @__PURE__ */ jsxRuntime.jsx(LayoutSection, { element, onPatch }) }) }),
1628
1976
  /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Tabs.Panel, { value: "advanced", children: /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Card, { padding: "md", className: "!bg-white dark:!bg-zinc-950", children: /* @__PURE__ */ jsxRuntime.jsx(AdvancedSection, { element, onPatch }) }) })
1629
1977
  ] })
@@ -1633,7 +1981,8 @@ function ElementInspector({ element, onPatch, onDelete, onLockToggle, slide, onS
1633
1981
  function SlideSettings({
1634
1982
  slide,
1635
1983
  onSetTransition,
1636
- onSetBackground
1984
+ onSetBackground,
1985
+ onSetElementAnimation
1637
1986
  }) {
1638
1987
  const transition = slide.transition;
1639
1988
  const kind = transition?.kind ?? "none";
@@ -1700,10 +2049,54 @@ function SlideSettings({
1700
2049
  onChange: (c) => onSetBackground({ ...slide.background, color: c })
1701
2050
  }
1702
2051
  ) })
1703
- ] }) })
2052
+ ] }) }),
2053
+ onSetElementAnimation && /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Card, { padding: "md", className: "mt-3 !bg-white dark:!bg-zinc-950", children: /* @__PURE__ */ jsxRuntime.jsx(BuildOrderList, { slide, onSetElementAnimation }) })
1704
2054
  ] })
1705
2055
  ] });
1706
2056
  }
2057
+ function BuildOrderList({
2058
+ slide,
2059
+ onSetElementAnimation
2060
+ }) {
2061
+ const builds = collectBuilds(slide);
2062
+ const move = (from, to) => {
2063
+ if (to < 0 || to >= builds.length) return;
2064
+ const reordered = [...builds];
2065
+ const [item] = reordered.splice(from, 1);
2066
+ reordered.splice(to, 0, item);
2067
+ reordered.forEach((b, i) => {
2068
+ if ((b.animation.order ?? 0) !== i) {
2069
+ onSetElementAnimation(b.element.id, { ...b.animation, order: i });
2070
+ }
2071
+ });
2072
+ };
2073
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", children: [
2074
+ /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Heading, { as: "h4", size: "xs", className: "!uppercase !tracking-wider !text-zinc-500", children: "Build order" }),
2075
+ builds.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Text, { size: "xs", className: "!text-zinc-500", children: "No animated elements yet. Select an element and add a build under its Build tab." }) : builds.map((b, i) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
2076
+ /* @__PURE__ */ jsxRuntime.jsxs(reactFancy.Text, { size: "xs", className: "!font-mono !text-zinc-400 w-5", children: [
2077
+ i + 1,
2078
+ "."
2079
+ ] }),
2080
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex-1 min-w-0", children: [
2081
+ /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Text, { size: "sm", className: "truncate", children: buildLabel(b.element) }),
2082
+ /* @__PURE__ */ jsxRuntime.jsxs(reactFancy.Text, { size: "xs", className: "!font-mono !text-zinc-400", children: [
2083
+ b.animation.effect,
2084
+ " \xB7 ",
2085
+ b.animation.trigger ?? "on-click"
2086
+ ] })
2087
+ ] }),
2088
+ /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Action, { size: "xs", variant: "ghost", icon: "chevron-up", onClick: () => move(i, i - 1), disabled: i === 0, "aria-label": "Move earlier" }),
2089
+ /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Action, { size: "xs", variant: "ghost", icon: "chevron-down", onClick: () => move(i, i + 1), disabled: i === builds.length - 1, "aria-label": "Move later" })
2090
+ ] }, b.element.id))
2091
+ ] });
2092
+ }
2093
+ function buildLabel(element) {
2094
+ if (element.type === "text") {
2095
+ const text = element.content.replace(/\s+/g, " ").trim();
2096
+ return text ? text.length > 28 ? `${text.slice(0, 28)}\u2026` : text : "Text";
2097
+ }
2098
+ return `${element.type} #${element.id.slice(-6)}`;
2099
+ }
1707
2100
  function LayoutSection({ element, onPatch }) {
1708
2101
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-3", children: [
1709
2102
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid grid-cols-2 gap-2", children: [
@@ -1725,6 +2118,109 @@ function AdvancedSection({ element, onPatch }) {
1725
2118
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center gap-2", children: /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Action, { size: "sm", variant: element.hidden ? "default" : "ghost", onClick: () => onPatch({ hidden: !element.hidden }), children: element.hidden ? "Hidden \u2014 show" : "Hide on slide" }) })
1726
2119
  ] });
1727
2120
  }
2121
+ var NO_ANIMATION = "none";
2122
+ function AnimateSection({
2123
+ animation,
2124
+ onSetAnimation,
2125
+ isText
2126
+ }) {
2127
+ if (!onSetAnimation) {
2128
+ return /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Text, { size: "sm", className: "!text-zinc-500", children: "Build animations aren't wired up in this editor." });
2129
+ }
2130
+ const effect = animation?.effect;
2131
+ const set = (next) => {
2132
+ const base = animation ?? { effect: "fade" };
2133
+ onSetAnimation({ ...base, ...next });
2134
+ };
2135
+ const showDirection = effect === "fly-in" || effect === "wipe";
2136
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-3", children: [
2137
+ /* @__PURE__ */ jsxRuntime.jsx(
2138
+ reactFancy.Select,
2139
+ {
2140
+ label: "Effect",
2141
+ list: [
2142
+ { value: NO_ANIMATION, label: "None" },
2143
+ { value: "fade", label: "Fade" },
2144
+ { value: "fly-in", label: "Fly in" },
2145
+ { value: "zoom", label: "Zoom" },
2146
+ { value: "wipe", label: "Wipe" }
2147
+ ],
2148
+ value: effect ?? NO_ANIMATION,
2149
+ onValueChange: (v) => {
2150
+ if (v === NO_ANIMATION) onSetAnimation(void 0);
2151
+ else set({ effect: v });
2152
+ }
2153
+ }
2154
+ ),
2155
+ effect && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2156
+ /* @__PURE__ */ jsxRuntime.jsx(
2157
+ reactFancy.Select,
2158
+ {
2159
+ label: "Trigger",
2160
+ list: [
2161
+ { value: "on-click", label: "On click" },
2162
+ { value: "with-prev", label: "With previous" },
2163
+ { value: "after-prev", label: "After previous" }
2164
+ ],
2165
+ value: animation?.trigger ?? "on-click",
2166
+ onValueChange: (v) => set({ trigger: v })
2167
+ }
2168
+ ),
2169
+ showDirection && /* @__PURE__ */ jsxRuntime.jsx(
2170
+ reactFancy.Select,
2171
+ {
2172
+ label: "Direction",
2173
+ list: [
2174
+ { value: "left", label: "From left" },
2175
+ { value: "right", label: "From right" },
2176
+ { value: "up", label: "From bottom" },
2177
+ { value: "down", label: "From top" }
2178
+ ],
2179
+ value: animation?.direction ?? "left",
2180
+ onValueChange: (v) => set({ direction: v })
2181
+ }
2182
+ ),
2183
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid grid-cols-2 gap-2", children: [
2184
+ /* @__PURE__ */ jsxRuntime.jsx(
2185
+ reactFancy.Input,
2186
+ {
2187
+ label: "Duration (ms)",
2188
+ type: "number",
2189
+ value: String(animation?.duration ?? 500),
2190
+ onChange: (e) => set({ duration: parseInt(e.target.value, 10) || 500 })
2191
+ }
2192
+ ),
2193
+ /* @__PURE__ */ jsxRuntime.jsx(
2194
+ reactFancy.Input,
2195
+ {
2196
+ label: "Delay (ms)",
2197
+ type: "number",
2198
+ value: String(animation?.delay ?? 0),
2199
+ onChange: (e) => set({ delay: parseInt(e.target.value, 10) || 0 })
2200
+ }
2201
+ )
2202
+ ] }),
2203
+ /* @__PURE__ */ jsxRuntime.jsx(
2204
+ reactFancy.Input,
2205
+ {
2206
+ label: "Order",
2207
+ type: "number",
2208
+ value: String(animation?.order ?? 0),
2209
+ onChange: (e) => set({ order: parseInt(e.target.value, 10) || 0 })
2210
+ }
2211
+ ),
2212
+ isText && /* @__PURE__ */ jsxRuntime.jsx(
2213
+ reactFancy.Switch,
2214
+ {
2215
+ label: "Animate by paragraph (one line per click)",
2216
+ checked: !!animation?.byParagraph,
2217
+ onCheckedChange: (v) => set({ byParagraph: v })
2218
+ }
2219
+ ),
2220
+ /* @__PURE__ */ jsxRuntime.jsx(reactFancy.Text, { size: "xs", className: "!text-zinc-500", children: `Builds reveal in ascending order. "On click" starts a new step; "with previous" plays alongside the step's lead; "after previous" follows it. Honors prefers-reduced-motion.` })
2221
+ ] })
2222
+ ] });
2223
+ }
1728
2224
  function StyleSection({ element, onPatch }) {
1729
2225
  switch (element.type) {
1730
2226
  case "text":
@@ -2400,7 +2896,9 @@ function DeckEditor({
2400
2896
  },
2401
2897
  onLockToggle: (locked) => slide && elementIdSelected && ops.updateElement(slide.id, elementIdSelected, { locked }),
2402
2898
  onSetTransition: (transition) => slide && ops.setTransition(slide.id, transition),
2403
- onSetBackground: (background) => slide && ops.setBackground(slide.id, background)
2899
+ onSetBackground: (background) => slide && ops.setBackground(slide.id, background),
2900
+ onSetAnimation: (animation) => slide && elementIdSelected && ops.setAnimation(slide.id, elementIdSelected, animation),
2901
+ onSetElementAnimation: (eid, animation) => slide && ops.setAnimation(slide.id, eid, animation)
2404
2902
  }
2405
2903
  ) })
2406
2904
  ] }),
@@ -2422,22 +2920,31 @@ exports.SlideThumbnail = SlideThumbnail;
2422
2920
  exports.SlideViewer = SlideViewer;
2423
2921
  exports.SpeakerNotes = SpeakerNotes;
2424
2922
  exports.TextElementRenderer = TextElementRenderer;
2923
+ exports.buildSteps = buildSteps;
2924
+ exports.buildsForStep = buildsForStep;
2425
2925
  exports.builtinThemes = builtinThemes;
2426
2926
  exports.chartStarterOption = chartStarterOption;
2927
+ exports.collectBuilds = collectBuilds;
2427
2928
  exports.darkTheme = darkTheme;
2428
2929
  exports.deckId = deckId;
2429
2930
  exports.defaultTheme = defaultTheme;
2430
2931
  exports.defineTheme = defineTheme;
2431
2932
  exports.elementId = elementId;
2933
+ exports.isByParagraph = isByParagraph;
2432
2934
  exports.nextId = nextId;
2935
+ exports.paragraphReveals = paragraphReveals;
2433
2936
  exports.reduceDeck = reduce;
2434
2937
  exports.resolveTheme = resolveTheme;
2435
2938
  exports.slideId = slideId;
2939
+ exports.splitParagraphs = splitParagraphs;
2940
+ exports.stepDelays = stepDelays;
2941
+ exports.totalBuildSteps = totalBuildSteps;
2436
2942
  exports.useDeckState = useDeckState;
2437
2943
  exports.useIsDarkSlide = useIsDarkSlide;
2438
2944
  exports.useSlideContext = useSlideContext;
2439
2945
  exports.useSlideKeyboard = useSlideKeyboard;
2440
2946
  exports.useSlideTheme = useSlideTheme;
2947
+ exports.visibleElementIds = visibleElementIds;
2441
2948
  exports.vividTheme = vividTheme;
2442
2949
  //# sourceMappingURL=index.cjs.map
2443
2950
  //# sourceMappingURL=index.cjs.map