@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.
- package/README.md +12 -0
- package/dist/{build-ZM7IfDO-.js → build-CtmQSpg-.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-D_5nlXFU.d.ts → config-14qk4fP8.d.ts} +2 -1
- package/dist/{config-BAZeaz2P.js → config-Bk2i4eJ1.js} +6 -4
- package/dist/{dev-BQkNTG_t.js → dev-DplvRqZx.js} +1 -1
- package/dist/{format-CYOb2cAQ.js → format-BvBmqbNW.js} +12 -4
- package/dist/index.d.ts +24 -4
- package/dist/index.js +100 -8
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +1 -1
- package/dist/{preview-D8hUtbRA.js → preview-p4gcc8ip.js} +1 -1
- package/dist/{types-AalTbxMj.d.ts → types-D_q_ylIe.d.ts} +2 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/slide-authoring/SKILL.md +42 -0
- package/src/app/app.tsx +1 -1
- package/src/app/components/player.tsx +50 -14
- package/src/app/components/present/use-presenter-channel.ts +2 -0
- package/src/app/components/slide-canvas.tsx +12 -6
- package/src/app/components/slide-transition-layer.tsx +44 -4
- package/src/app/components/thumbnail-rail.tsx +77 -15
- package/src/app/lib/export-pdf.ts +67 -3
- package/src/app/lib/step-context.tsx +261 -0
- package/src/app/routes/presenter.tsx +32 -7
- package/src/app/routes/slide.tsx +26 -4
- package/src/app/virtual.d.ts +1 -0
- package/src/locale/en.ts +2 -0
- package/src/locale/ja.ts +2 -0
- package/src/locale/types.ts +2 -0
- package/src/locale/zh-cn.ts +2 -0
- package/src/locale/zh-tw.ts +2 -0
|
@@ -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({
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
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
|
-
<
|
|
226
|
-
<
|
|
227
|
-
{
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
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[
|
|
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
|
-
<
|
|
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={
|
|
171
|
-
<
|
|
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();
|