@open-slide/core 1.9.0 → 1.11.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.
@@ -1,6 +1,12 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { type MutableRefObject, useEffect, useRef, useState } from 'react';
2
2
  import { SlidePageProvider } from '../lib/page-context';
3
3
  import type { Page } from '../lib/sdk';
4
+ import {
5
+ type EntryDirection,
6
+ type StepAggregate,
7
+ type StepController,
8
+ StepHost,
9
+ } from '../lib/step-context';
4
10
  import { resolveTransition, type SlideTransition, type TransitionPhase } from '../lib/transition';
5
11
 
6
12
  type Props = {
@@ -9,6 +15,9 @@ type Props = {
9
15
  total: number;
10
16
  moduleTransition?: SlideTransition;
11
17
  disabled?: boolean;
18
+ stepControllerRef?: MutableRefObject<StepController | null>;
19
+ entryDirection?: EntryDirection;
20
+ onStepAggregateChange?: (aggregate: StepAggregate) => void;
12
21
  };
13
22
 
14
23
  type Direction = 'forward' | 'backward';
@@ -30,7 +39,16 @@ function runPhase(
30
39
  });
31
40
  }
32
41
 
33
- export function SlideTransitionLayer({ pages, index, total, moduleTransition, disabled }: Props) {
42
+ export function SlideTransitionLayer({
43
+ pages,
44
+ index,
45
+ total,
46
+ moduleTransition,
47
+ disabled,
48
+ stepControllerRef,
49
+ entryDirection = 'jump',
50
+ onStepAggregateChange,
51
+ }: Props) {
34
52
  const [current, setCurrent] = useState(index);
35
53
  const [outgoing, setOutgoing] = useState<number | null>(null);
36
54
  const [direction, setDirection] = useState<Direction>('forward');
@@ -129,6 +147,15 @@ export function SlideTransitionLayer({ pages, index, total, moduleTransition, di
129
147
  const CurrentPage = pages[current];
130
148
  const OutgoingPage = outgoing !== null ? pages[outgoing] : null;
131
149
 
150
+ // Outgoing layer mirrors the direction we just navigated so its <Steps>
151
+ // re-mounts in the state the audience just saw: forward nav → outgoing was
152
+ // fully revealed; backward nav → outgoing was at zero reveals.
153
+ const outgoingEntryDirection: EntryDirection =
154
+ entryDirection === 'backward' ? 'forward' : 'backward';
155
+
156
+ const noopControllerRef = useRef<StepController | null>(null);
157
+ const activeControllerRef = stepControllerRef ?? noopControllerRef;
158
+
132
159
  return (
133
160
  <div
134
161
  ref={wrapperRef}
@@ -138,14 +165,27 @@ export function SlideTransitionLayer({ pages, index, total, moduleTransition, di
138
165
  {OutgoingPage && outgoing !== null ? (
139
166
  <div ref={outgoingLayerRef} className="absolute inset-0">
140
167
  <SlidePageProvider index={outgoing} total={total}>
141
- <OutgoingPage />
168
+ <StepHost
169
+ isActivePage={false}
170
+ entryDirection={outgoingEntryDirection}
171
+ controllerRef={activeControllerRef}
172
+ >
173
+ <OutgoingPage />
174
+ </StepHost>
142
175
  </SlidePageProvider>
143
176
  </div>
144
177
  ) : null}
145
178
  {CurrentPage ? (
146
179
  <div ref={incomingLayerRef} className="absolute inset-0">
147
180
  <SlidePageProvider index={current} total={total}>
148
- <CurrentPage />
181
+ <StepHost
182
+ isActivePage
183
+ entryDirection={entryDirection}
184
+ controllerRef={activeControllerRef}
185
+ onAggregateChange={onStepAggregateChange}
186
+ >
187
+ <CurrentPage />
188
+ </StepHost>
149
189
  </SlidePageProvider>
150
190
  </div>
151
191
  ) : null}
@@ -15,8 +15,8 @@ import {
15
15
  verticalListSortingStrategy,
16
16
  } from '@dnd-kit/sortable';
17
17
  import { CSS } from '@dnd-kit/utilities';
18
- import { Copy, Trash2 } from 'lucide-react';
19
- import { Fragment, useEffect, useRef } from 'react';
18
+ import { Copy, ListOrdered, type LucideIcon, Sparkles, Trash2 } from 'lucide-react';
19
+ import { Fragment, useEffect, useRef, useState } from 'react';
20
20
  import {
21
21
  ContextMenu,
22
22
  ContextMenuContent,
@@ -25,12 +25,14 @@ import {
25
25
  ContextMenuTrigger,
26
26
  } from '@/components/ui/context-menu';
27
27
  import { ScrollArea } from '@/components/ui/scroll-area';
28
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
28
29
  import { format, useLocale } from '@/lib/use-locale';
29
30
  import { cn } from '@/lib/utils';
30
31
  import type { DesignSystem } from '../lib/design';
31
32
  import { SlidePageProvider } from '../lib/page-context';
32
33
  import type { Page } from '../lib/sdk';
33
34
  import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
35
+ import type { SlideTransition } from '../lib/transition';
34
36
  import { SlideCanvas } from './slide-canvas';
35
37
 
36
38
  type Orientation = 'vertical' | 'horizontal';
@@ -50,6 +52,8 @@ type Props = {
50
52
  orientation?: Orientation;
51
53
  /** Vertical-only: total rail width in px. Thumbnails scale to fit. */
52
54
  width?: number;
55
+ /** Deck-level transition default; used to flag pages that inherit a transition. */
56
+ moduleTransition?: SlideTransition;
53
57
  };
54
58
 
55
59
  const DEFAULT_VERTICAL_THUMB_WIDTH = 184;
@@ -66,6 +70,7 @@ export function ThumbnailRail({
66
70
  actions,
67
71
  orientation = 'vertical',
68
72
  width,
73
+ moduleTransition,
69
74
  }: Props) {
70
75
  const activeRef = useRef<HTMLButtonElement | null>(null);
71
76
  const t = useLocale();
@@ -165,6 +170,7 @@ export function ThumbnailRail({
165
170
  scale={scale}
166
171
  thumbWidth={thumbWidth}
167
172
  height={height}
173
+ moduleTransition={moduleTransition}
168
174
  />
169
175
  );
170
176
 
@@ -218,15 +224,21 @@ export function ThumbnailRail({
218
224
  );
219
225
 
220
226
  if (!onReorder) {
221
- return <ScrollArea className="h-full border-r border-hairline bg-sidebar">{list}</ScrollArea>;
227
+ return (
228
+ <TooltipProvider delayDuration={200}>
229
+ <ScrollArea className="h-full border-r border-hairline bg-sidebar">{list}</ScrollArea>
230
+ </TooltipProvider>
231
+ );
222
232
  }
223
233
 
224
234
  return (
225
- <ScrollArea className="h-full border-r border-hairline bg-sidebar">
226
- <SortableRail pages={pages} onReorder={onReorder} onSelect={onSelect}>
227
- {list}
228
- </SortableRail>
229
- </ScrollArea>
235
+ <TooltipProvider delayDuration={200}>
236
+ <ScrollArea className="h-full border-r border-hairline bg-sidebar">
237
+ <SortableRail pages={pages} onReorder={onReorder} onSelect={onSelect}>
238
+ {list}
239
+ </SortableRail>
240
+ </ScrollArea>
241
+ </TooltipProvider>
230
242
  );
231
243
  }
232
244
 
@@ -247,6 +259,7 @@ function ThumbContents({
247
259
  scale,
248
260
  thumbWidth,
249
261
  height,
262
+ moduleTransition,
250
263
  }: {
251
264
  index: number;
252
265
  total: number;
@@ -256,18 +269,45 @@ function ThumbContents({
256
269
  scale: number;
257
270
  thumbWidth: number;
258
271
  height: number;
272
+ moduleTransition?: SlideTransition;
259
273
  }) {
274
+ const t = useLocale();
275
+ const boxRef = useRef<HTMLDivElement | null>(null);
276
+ const [hasSteps, setHasSteps] = useState(false);
277
+
278
+ // Steps live in JSX and can't be introspected statically — detect them from
279
+ // the already-rendered thumbnail DOM, where each Step emits `data-osd-step`.
280
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-detect when the page at this slot changes (reorder/edit reuses the index)
281
+ useEffect(() => {
282
+ setHasSteps(boxRef.current?.querySelector('[data-osd-step]') != null);
283
+ }, [PageComp]);
284
+
285
+ const hasTransition = Boolean(PageComp.transition ?? moduleTransition);
286
+
260
287
  return (
261
288
  <>
262
- <span
263
- className={cn(
264
- 'mt-1.5 w-7 shrink-0 text-right font-mono text-[10px] font-medium tracking-[0.06em] tabular-nums uppercase',
265
- active ? 'text-brand' : 'text-muted-foreground/70',
289
+ <div className="mt-1.5 flex w-7 shrink-0 flex-col items-end gap-1">
290
+ <span
291
+ className={cn(
292
+ 'font-mono text-[10px] font-medium tracking-[0.06em] tabular-nums uppercase',
293
+ active ? 'text-brand' : 'text-muted-foreground/70',
294
+ )}
295
+ >
296
+ {(index + 1).toString().padStart(2, '0')}
297
+ </span>
298
+ {(hasTransition || hasSteps) && (
299
+ <div className="flex flex-col items-end gap-0.5">
300
+ {hasTransition && (
301
+ <ThumbIndicator icon={Sparkles} label={t.thumbnailRail.transitionIndicator} />
302
+ )}
303
+ {hasSteps && (
304
+ <ThumbIndicator icon={ListOrdered} label={t.thumbnailRail.stepsIndicator} />
305
+ )}
306
+ </div>
266
307
  )}
267
- >
268
- {(index + 1).toString().padStart(2, '0')}
269
- </span>
308
+ </div>
270
309
  <div
310
+ ref={boxRef}
271
311
  className={cn(
272
312
  'relative shrink-0 overflow-hidden rounded-[4px] border bg-card motion-safe:transition-[border-color,box-shadow]',
273
313
  active
@@ -292,6 +332,28 @@ function ThumbContents({
292
332
  );
293
333
  }
294
334
 
335
+ function ThumbIndicator({ icon: Icon, label }: { icon: LucideIcon; label: string }) {
336
+ return (
337
+ <Tooltip>
338
+ <TooltipTrigger asChild>
339
+ <span
340
+ role="img"
341
+ aria-label={label}
342
+ className={cn(
343
+ 'flex size-3.5 items-center justify-center text-muted-foreground/55',
344
+ 'motion-safe:transition-colors group-hover/thumb:text-muted-foreground/80',
345
+ )}
346
+ >
347
+ <Icon className="size-3" strokeWidth={2} />
348
+ </span>
349
+ </TooltipTrigger>
350
+ <TooltipContent side="right" sideOffset={6}>
351
+ {label}
352
+ </TooltipContent>
353
+ </Tooltip>
354
+ );
355
+ }
356
+
295
357
  function ThumbContextMenu({
296
358
  index,
297
359
  actions,
@@ -195,11 +195,75 @@ export async function exportSlideAsPdf(
195
195
  function neutralizeGradientBackgrounds(root: HTMLElement): void {
196
196
  const elements = root.querySelectorAll<HTMLElement>('*');
197
197
  for (const el of elements) {
198
- const bg = getComputedStyle(el).backgroundImage;
199
- if (bg?.includes('gradient(')) {
200
- el.style.backgroundImage = 'none';
198
+ const styles = getComputedStyle(el);
199
+ const bg = styles.backgroundImage;
200
+ if (!bg?.includes('gradient(')) continue;
201
+
202
+ const result = removeGradientBackgroundLayers(bg);
203
+ const size = styles.backgroundSize;
204
+ const position = styles.backgroundPosition;
205
+ const repeat = styles.backgroundRepeat;
206
+
207
+ el.style.backgroundImage = result.backgroundImage;
208
+ if (result.keptIndices.length === 0 || result.keptIndices.length === result.layerCount)
209
+ continue;
210
+
211
+ el.style.backgroundSize = reindexBackgroundLayerValues(size, result.keptIndices);
212
+ el.style.backgroundPosition = reindexBackgroundLayerValues(position, result.keptIndices);
213
+ el.style.backgroundRepeat = reindexBackgroundLayerValues(repeat, result.keptIndices);
214
+ }
215
+ }
216
+
217
+ function removeGradientBackgroundLayers(backgroundImage: string): {
218
+ backgroundImage: string;
219
+ keptIndices: number[];
220
+ layerCount: number;
221
+ } {
222
+ const layers = splitBackgroundImageLayers(backgroundImage);
223
+ const keptLayers: string[] = [];
224
+ const keptIndices: number[] = [];
225
+
226
+ for (let i = 0; i < layers.length; i++) {
227
+ const layer = layers[i];
228
+ if (!layer) continue;
229
+ const value = layer.trim();
230
+ if (value.startsWith('url(') && !value.includes('gradient(')) {
231
+ keptLayers.push(value);
232
+ keptIndices.push(i);
201
233
  }
202
234
  }
235
+
236
+ return {
237
+ backgroundImage: keptLayers.length > 0 ? keptLayers.join(', ') : 'none',
238
+ keptIndices,
239
+ layerCount: layers.length,
240
+ };
241
+ }
242
+
243
+ function reindexBackgroundLayerValues(value: string, keptIndices: number[]): string {
244
+ const layers = splitBackgroundImageLayers(value);
245
+ if (layers.length === 0) return value;
246
+
247
+ return keptIndices.map((index) => layers[index % layers.length]).join(', ');
248
+ }
249
+
250
+ function splitBackgroundImageLayers(backgroundImage: string): string[] {
251
+ const layers: string[] = [];
252
+ let depth = 0;
253
+ let layerStart = 0;
254
+
255
+ for (let i = 0; i < backgroundImage.length; i++) {
256
+ const char = backgroundImage[i];
257
+ if (char === '(') depth++;
258
+ if (char === ')') depth = Math.max(0, depth - 1);
259
+ if (char === ',' && depth === 0) {
260
+ layers.push(backgroundImage.slice(layerStart, i).trim());
261
+ layerStart = i + 1;
262
+ }
263
+ }
264
+
265
+ layers.push(backgroundImage.slice(layerStart).trim());
266
+ return layers;
203
267
  }
204
268
 
205
269
  function sleep(ms: number): Promise<void> {
@@ -0,0 +1,261 @@
1
+ import {
2
+ Children,
3
+ type Context,
4
+ cloneElement,
5
+ createContext,
6
+ isValidElement,
7
+ type MutableRefObject,
8
+ type PropsWithChildren,
9
+ type ReactElement,
10
+ useCallback,
11
+ useContext,
12
+ useLayoutEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from 'react';
17
+ import { usePrefersReducedMotion } from './use-prefers-reduced-motion';
18
+
19
+ export type EntryDirection = 'forward' | 'backward' | 'jump';
20
+
21
+ export type StepController = {
22
+ advance: () => boolean;
23
+ retreat: () => boolean;
24
+ };
25
+
26
+ export type StepAggregate = {
27
+ revealed: number;
28
+ stepCount: number;
29
+ };
30
+
31
+ type Registration = {
32
+ id: object;
33
+ stepCount: number;
34
+ initialRevealed: number;
35
+ controller: StepController;
36
+ setRevealed: (n: number) => void;
37
+ };
38
+
39
+ type StepHostContextValue = {
40
+ register: (reg: Registration) => () => void;
41
+ reportRevealed: (id: object, revealed: number) => void;
42
+ entryDirection: EntryDirection;
43
+ controlled: boolean;
44
+ };
45
+
46
+ const GLOBAL_KEY = '__open_slide_step_host_context__';
47
+ type GlobalWithCtx = typeof globalThis & {
48
+ [GLOBAL_KEY]?: Context<StepHostContextValue | null>;
49
+ };
50
+ const g = globalThis as GlobalWithCtx;
51
+ if (!g[GLOBAL_KEY]) {
52
+ g[GLOBAL_KEY] = createContext<StepHostContextValue | null>(null);
53
+ }
54
+ const StepHostContext = g[GLOBAL_KEY];
55
+
56
+ type StepHostProps = PropsWithChildren<{
57
+ isActivePage: boolean;
58
+ entryDirection: EntryDirection;
59
+ controllerRef: MutableRefObject<StepController | null>;
60
+ // When set, the host distributes this count across <Steps> children in
61
+ // mount order — first fills to its stepCount, next takes the remainder.
62
+ controlledRevealed?: number;
63
+ onAggregateChange?: (aggregate: StepAggregate) => void;
64
+ }>;
65
+
66
+ export function StepHost({
67
+ isActivePage,
68
+ entryDirection,
69
+ controllerRef,
70
+ controlledRevealed,
71
+ onAggregateChange,
72
+ children,
73
+ }: StepHostProps) {
74
+ type Tracked = Registration & { revealed: number };
75
+ const registrationsRef = useRef<Tracked[]>([]);
76
+
77
+ const onAggregateChangeRef = useRef(onAggregateChange);
78
+ onAggregateChangeRef.current = onAggregateChange;
79
+ const controlledRevealedRef = useRef(controlledRevealed);
80
+ controlledRevealedRef.current = controlledRevealed;
81
+
82
+ const composite = useMemo<StepController>(
83
+ () => ({
84
+ advance: () => {
85
+ for (const r of registrationsRef.current) {
86
+ if (r.controller.advance()) return true;
87
+ }
88
+ return false;
89
+ },
90
+ retreat: () => {
91
+ for (let i = registrationsRef.current.length - 1; i >= 0; i--) {
92
+ if (registrationsRef.current[i].controller.retreat()) return true;
93
+ }
94
+ return false;
95
+ },
96
+ }),
97
+ [],
98
+ );
99
+
100
+ // useLayoutEffect cleanup-then-mount ordering keeps the registry slot
101
+ // continuous across page swaps — the outgoing host clears its composite
102
+ // before the next active host installs its own, with no gap and no overlap.
103
+ useLayoutEffect(() => {
104
+ if (!isActivePage) return;
105
+ controllerRef.current = composite;
106
+ return () => {
107
+ if (controllerRef.current === composite) controllerRef.current = null;
108
+ };
109
+ }, [isActivePage, composite, controllerRef]);
110
+
111
+ const notifyAggregate = useCallback(() => {
112
+ const cb = onAggregateChangeRef.current;
113
+ if (!cb) return;
114
+ let revealed = 0;
115
+ let stepCount = 0;
116
+ for (const r of registrationsRef.current) {
117
+ revealed += r.revealed;
118
+ stepCount += r.stepCount;
119
+ }
120
+ cb({ revealed, stepCount });
121
+ }, []);
122
+
123
+ const distributeControlled = useCallback(() => {
124
+ const target = controlledRevealedRef.current;
125
+ if (target == null) return;
126
+ let remaining = target;
127
+ for (const r of registrationsRef.current) {
128
+ const share = Math.max(0, Math.min(r.stepCount, remaining));
129
+ remaining -= share;
130
+ if (r.revealed !== share) {
131
+ r.revealed = share;
132
+ r.setRevealed(share);
133
+ }
134
+ }
135
+ }, []);
136
+
137
+ useLayoutEffect(() => {
138
+ if (controlledRevealed == null) return;
139
+ distributeControlled();
140
+ notifyAggregate();
141
+ }, [controlledRevealed, distributeControlled, notifyAggregate]);
142
+
143
+ const value = useMemo<StepHostContextValue>(
144
+ () => ({
145
+ register: (reg) => {
146
+ const tracked: Tracked = { ...reg, revealed: reg.initialRevealed };
147
+ registrationsRef.current.push(tracked);
148
+ if (controlledRevealedRef.current != null) {
149
+ distributeControlled();
150
+ }
151
+ notifyAggregate();
152
+ return () => {
153
+ const i = registrationsRef.current.indexOf(tracked);
154
+ if (i !== -1) registrationsRef.current.splice(i, 1);
155
+ notifyAggregate();
156
+ };
157
+ },
158
+ reportRevealed: (id, revealed) => {
159
+ const r = registrationsRef.current.find((x) => x.id === id);
160
+ if (!r) return;
161
+ r.revealed = revealed;
162
+ notifyAggregate();
163
+ },
164
+ entryDirection,
165
+ controlled: controlledRevealed != null,
166
+ }),
167
+ [entryDirection, controlledRevealed, distributeControlled, notifyAggregate],
168
+ );
169
+
170
+ return <StepHostContext.Provider value={value}>{children}</StepHostContext.Provider>;
171
+ }
172
+
173
+ export type StepsProps = PropsWithChildren;
174
+
175
+ export function Steps({ children }: StepsProps) {
176
+ const host = useContext(StepHostContext);
177
+ const flat = Children.toArray(children);
178
+ const stepCount = flat.filter((c) => isValidElement(c) && c.type === Step).length;
179
+
180
+ // Controlled mode waits for the host to assign a slice in the registration
181
+ // layout-effect; otherwise the entry direction picks the initial reveal.
182
+ const initial = host?.controlled ? 0 : host?.entryDirection === 'forward' ? 0 : stepCount;
183
+ const revealedRef = useRef(initial);
184
+ const [revealed, setRevealed] = useState(initial);
185
+
186
+ const idRef = useRef<object>({});
187
+
188
+ const applyRevealed = useCallback((n: number) => {
189
+ revealedRef.current = n;
190
+ setRevealed(n);
191
+ }, []);
192
+
193
+ useLayoutEffect(() => {
194
+ if (!host) return;
195
+ const id = idRef.current;
196
+ const ctrl: StepController = {
197
+ advance: () => {
198
+ if (revealedRef.current >= stepCount) return false;
199
+ applyRevealed(revealedRef.current + 1);
200
+ host.reportRevealed(id, revealedRef.current);
201
+ return true;
202
+ },
203
+ retreat: () => {
204
+ if (revealedRef.current <= 0) return false;
205
+ applyRevealed(revealedRef.current - 1);
206
+ host.reportRevealed(id, revealedRef.current);
207
+ return true;
208
+ },
209
+ };
210
+ return host.register({
211
+ id,
212
+ stepCount,
213
+ initialRevealed: revealedRef.current,
214
+ controller: ctrl,
215
+ setRevealed: applyRevealed,
216
+ });
217
+ }, [host, stepCount, applyRevealed]);
218
+
219
+ const effectiveRevealed = host ? revealed : stepCount;
220
+
221
+ let stepIdx = 0;
222
+ return (
223
+ <>
224
+ {flat.map((child, key) => {
225
+ if (isValidElement(child) && child.type === Step) {
226
+ const idx = stepIdx++;
227
+ return cloneElement(child as ReactElement<{ _revealed?: boolean }>, {
228
+ key: child.key ?? key,
229
+ _revealed: idx < effectiveRevealed,
230
+ });
231
+ }
232
+ return child;
233
+ })}
234
+ </>
235
+ );
236
+ }
237
+
238
+ export type StepProps = PropsWithChildren<{
239
+ duration?: number;
240
+ }>;
241
+
242
+ type InternalStepProps = StepProps & { _revealed?: boolean };
243
+
244
+ export function Step({ children, duration = 180, _revealed }: InternalStepProps) {
245
+ const reduceMotion = usePrefersReducedMotion();
246
+ const revealed = _revealed ?? true;
247
+ const ms = reduceMotion ? 0 : duration;
248
+
249
+ return (
250
+ <div
251
+ data-osd-step={revealed ? 'revealed' : 'pending'}
252
+ style={{
253
+ opacity: revealed ? 1 : 0,
254
+ visibility: revealed ? 'visible' : 'hidden',
255
+ transition: `opacity ${ms}ms cubic-bezier(0, 0, 0.2, 1)`,
256
+ }}
257
+ >
258
+ {children}
259
+ </div>
260
+ );
261
+ }
@@ -1,5 +1,5 @@
1
1
  import { ChevronLeft, ChevronRight, RotateCcw, Square, Sun } from 'lucide-react';
2
- import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { useParams } from 'react-router-dom';
4
4
  import { Button } from '@/components/ui/button';
5
5
  import { format, useLocale } from '@/lib/use-locale';
@@ -11,6 +11,7 @@ import {
11
11
  import { SlideCanvas } from '../components/slide-canvas';
12
12
  import { SlidePageProvider } from '../lib/page-context';
13
13
  import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
14
+ import { type StepController, StepHost } from '../lib/step-context';
14
15
  import { useSlideModule } from '../lib/use-slide-module';
15
16
 
16
17
  export function Presenter() {
@@ -114,14 +115,20 @@ export function Presenter() {
114
115
  const pages = slide.default;
115
116
  const total = pages.length;
116
117
  const index = Math.max(0, Math.min(total - 1, state?.index ?? 0));
117
- const nextIndex = Math.min(total - 1, index + 1);
118
- const hasNext = index < total - 1;
119
118
  const note = slide.notes?.[index];
120
119
  const blackout = state?.blackout ?? null;
121
120
  const startedAt = state?.startedAt ?? localStart;
121
+ const stepIndex = Math.max(0, state?.stepIndex ?? 0);
122
+ const stepCount = Math.max(0, state?.stepCount ?? 0);
123
+
124
+ const stepsRemaining = stepIndex < stepCount;
125
+ const hasNextSlide = index < total - 1;
126
+ const hasNext = stepsRemaining || hasNextSlide;
127
+ const nextPageIndex = stepsRemaining ? index : Math.min(total - 1, index + 1);
128
+ const nextRevealed = stepsRemaining ? stepIndex + 1 : 0;
122
129
 
123
130
  const CurrentPage = pages[index];
124
- const NextPage = hasNext ? pages[nextIndex] : null;
131
+ const NextPage = hasNext ? pages[nextPageIndex] : null;
125
132
 
126
133
  return (
127
134
  <div className="dark flex h-dvh w-screen flex-col overflow-hidden bg-background text-foreground">
@@ -140,7 +147,9 @@ export function Presenter() {
140
147
  <div className="relative min-h-0 flex-1 overflow-hidden rounded-[8px] bg-black ring-1 ring-border">
141
148
  <SlideCanvas flat design={slide.design}>
142
149
  <SlidePageProvider index={index} total={total}>
143
- <CurrentPage />
150
+ <PreviewStepHost revealed={stepIndex}>
151
+ <CurrentPage />
152
+ </PreviewStepHost>
144
153
  </SlidePageProvider>
145
154
  </SlideCanvas>
146
155
  {blackout && (
@@ -167,8 +176,10 @@ export function Presenter() {
167
176
  >
168
177
  {NextPage ? (
169
178
  <SlideCanvas flat freezeMotion design={slide.design}>
170
- <SlidePageProvider index={nextIndex} total={total}>
171
- <NextPage />
179
+ <SlidePageProvider index={nextPageIndex} total={total}>
180
+ <PreviewStepHost revealed={nextRevealed}>
181
+ <NextPage />
182
+ </PreviewStepHost>
172
183
  </SlidePageProvider>
173
184
  </SlideCanvas>
174
185
  ) : (
@@ -350,6 +361,20 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
350
361
  return <span className="eyebrow">{children}</span>;
351
362
  }
352
363
 
364
+ function PreviewStepHost({ revealed, children }: { revealed: number; children: ReactNode }) {
365
+ const noopControllerRef = useRef<StepController | null>(null);
366
+ return (
367
+ <StepHost
368
+ isActivePage={false}
369
+ entryDirection="jump"
370
+ controllerRef={noopControllerRef}
371
+ controlledRevealed={revealed}
372
+ >
373
+ {children}
374
+ </StepHost>
375
+ );
376
+ }
377
+
353
378
  function Clock() {
354
379
  const [now, setNow] = useState(() => new Date());
355
380
  const t = useLocale();