@slidev-react/client 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +21 -0
  3. package/README.md +16 -0
  4. package/package.json +44 -0
  5. package/src/addons/AddonProvider.tsx +25 -0
  6. package/src/addons/g2/G2Chart.tsx +370 -0
  7. package/src/addons/g2/chartPresets.ts +43 -0
  8. package/src/addons/g2/chartThemeTokens.ts +124 -0
  9. package/src/addons/g2/index.ts +36 -0
  10. package/src/addons/g2/style.css +31 -0
  11. package/src/addons/insight/Insight.tsx +10 -0
  12. package/src/addons/insight/InsightAddonProvider.tsx +20 -0
  13. package/src/addons/insight/SpotlightLayout.tsx +11 -0
  14. package/src/addons/insight/index.ts +17 -0
  15. package/src/addons/insight/style.css +34 -0
  16. package/src/addons/mermaid/MermaidDiagram.tsx +379 -0
  17. package/src/addons/mermaid/index.ts +10 -0
  18. package/src/addons/registry.test.ts +28 -0
  19. package/src/addons/registry.ts +61 -0
  20. package/src/addons/types.ts +6 -0
  21. package/src/app/App.tsx +125 -0
  22. package/src/app/README.md +18 -0
  23. package/src/app/providers/SlidesNavigationProvider.tsx +82 -0
  24. package/src/app/usePresentationBootstrap.ts +85 -0
  25. package/src/features/presentation/PresentationStatus.tsx +514 -0
  26. package/src/features/presentation/PrintSlidesView.tsx +350 -0
  27. package/src/features/presentation/browser.ts +5 -0
  28. package/src/features/presentation/draw/DrawOverlay.tsx +170 -0
  29. package/src/features/presentation/draw/DrawProvider.tsx +394 -0
  30. package/src/features/presentation/draw/persistence.test.ts +80 -0
  31. package/src/features/presentation/draw/persistence.ts +54 -0
  32. package/src/features/presentation/exportArtifacts.test.ts +48 -0
  33. package/src/features/presentation/exportArtifacts.ts +6 -0
  34. package/src/features/presentation/location.test.ts +73 -0
  35. package/src/features/presentation/location.ts +113 -0
  36. package/src/features/presentation/navigation/KeyboardController.tsx +73 -0
  37. package/src/features/presentation/navigation/PresentationNavbar.tsx +162 -0
  38. package/src/features/presentation/navigation/ShortcutsHelpOverlay.test.tsx +24 -0
  39. package/src/features/presentation/navigation/ShortcutsHelpOverlay.tsx +111 -0
  40. package/src/features/presentation/navigation/keyboardShortcuts.test.ts +74 -0
  41. package/src/features/presentation/navigation/keyboardShortcuts.ts +221 -0
  42. package/src/features/presentation/navigation/useSlidesNavigation.ts +15 -0
  43. package/src/features/presentation/overview/NotesOverview.tsx +200 -0
  44. package/src/features/presentation/overview/QuickOverview.tsx +126 -0
  45. package/src/features/presentation/path.ts +137 -0
  46. package/src/features/presentation/presenter/FlowTimelinePreview.test.tsx +54 -0
  47. package/src/features/presentation/presenter/FlowTimelinePreview.tsx +274 -0
  48. package/src/features/presentation/presenter/PresenterModeView.tsx +93 -0
  49. package/src/features/presentation/presenter/PresenterShell.tsx +286 -0
  50. package/src/features/presentation/presenter/PresenterSidePreview.tsx +68 -0
  51. package/src/features/presentation/presenter/PresenterTopProgress.tsx +28 -0
  52. package/src/features/presentation/presenter/SpeakerNotesPanel.tsx +51 -0
  53. package/src/features/presentation/presenter/StandaloneModeView.tsx +36 -0
  54. package/src/features/presentation/presenter/persistence.test.ts +26 -0
  55. package/src/features/presentation/presenter/persistence.ts +31 -0
  56. package/src/features/presentation/presenter/presentationSyncBridge.test.ts +87 -0
  57. package/src/features/presentation/presenter/presentationSyncBridge.ts +82 -0
  58. package/src/features/presentation/presenter/stage.ts +15 -0
  59. package/src/features/presentation/presenter/types.ts +30 -0
  60. package/src/features/presentation/presenter/useFullscreen.ts +58 -0
  61. package/src/features/presentation/presenter/useIdleCursor.ts +37 -0
  62. package/src/features/presentation/presenter/usePresentationFlowRuntime.ts +238 -0
  63. package/src/features/presentation/presenter/usePresenterChromeRuntime.ts +358 -0
  64. package/src/features/presentation/presenter/usePresenterSessionState.ts +226 -0
  65. package/src/features/presentation/presenter/useWakeLock.ts +110 -0
  66. package/src/features/presentation/recordingFilename.test.ts +46 -0
  67. package/src/features/presentation/recordingFilename.ts +56 -0
  68. package/src/features/presentation/reveal/Reveal.tsx +119 -0
  69. package/src/features/presentation/reveal/RevealContext.tsx +29 -0
  70. package/src/features/presentation/reveal/useRevealStep.ts +35 -0
  71. package/src/features/presentation/session.test.ts +122 -0
  72. package/src/features/presentation/session.ts +124 -0
  73. package/src/features/presentation/stage/SlidePreviewSurface.tsx +92 -0
  74. package/src/features/presentation/stage/SlideStage.tsx +159 -0
  75. package/src/features/presentation/stage/slideSurface.ts +71 -0
  76. package/src/features/presentation/stage/slideViewport.tsx +47 -0
  77. package/src/features/presentation/sync/adapters/broadcastChannelTransport.ts +40 -0
  78. package/src/features/presentation/sync/adapters/websocketTransport.ts +128 -0
  79. package/src/features/presentation/sync/model/presence.test.ts +42 -0
  80. package/src/features/presentation/sync/model/presence.ts +33 -0
  81. package/src/features/presentation/sync/model/replication.test.ts +72 -0
  82. package/src/features/presentation/sync/model/replication.ts +113 -0
  83. package/src/features/presentation/sync/model/status.test.ts +52 -0
  84. package/src/features/presentation/sync/model/status.ts +33 -0
  85. package/src/features/presentation/types.ts +1 -0
  86. package/src/features/presentation/usePresentationRecorder.ts +194 -0
  87. package/src/features/presentation/usePresentationSync.ts +423 -0
  88. package/src/index.ts +7 -0
  89. package/src/main.tsx +12 -0
  90. package/src/theme/ThemeProvider.test.ts +36 -0
  91. package/src/theme/ThemeProvider.tsx +79 -0
  92. package/src/theme/__mocks__/active-theme.ts +3 -0
  93. package/src/theme/base.css +14 -0
  94. package/src/theme/components.css +231 -0
  95. package/src/theme/index.css +11 -0
  96. package/src/theme/layouts/center.tsx +9 -0
  97. package/src/theme/layouts/cover.tsx +9 -0
  98. package/src/theme/layouts/default.tsx +5 -0
  99. package/src/theme/layouts/defaultLayouts.ts +20 -0
  100. package/src/theme/layouts/helpers.tsx +12 -0
  101. package/src/theme/layouts/image-right.tsx +21 -0
  102. package/src/theme/layouts/immersive.tsx +9 -0
  103. package/src/theme/layouts/resolveLayout.ts +9 -0
  104. package/src/theme/layouts/section.tsx +9 -0
  105. package/src/theme/layouts/statement.tsx +9 -0
  106. package/src/theme/layouts/two-cols.tsx +21 -0
  107. package/src/theme/layouts/types.ts +1 -0
  108. package/src/theme/layouts.css +133 -0
  109. package/src/theme/mark.css +379 -0
  110. package/src/theme/print.css +106 -0
  111. package/src/theme/prose.css +263 -0
  112. package/src/theme/registry.test.ts +21 -0
  113. package/src/theme/registry.ts +40 -0
  114. package/src/theme/tokens.css +148 -0
  115. package/src/theme/transitions.css +141 -0
  116. package/src/theme/types.ts +9 -0
  117. package/src/theme/useResolvedLayout.ts +24 -0
  118. package/src/types/generated-slides.d.ts +7 -0
  119. package/src/types/mdx-components.ts +7 -0
  120. package/src/types/plantuml-encoder.d.ts +7 -0
  121. package/src/ui/diagrams/PlantUmlDiagram.tsx +33 -0
  122. package/src/ui/mdx/MagicMoveDemo.tsx +114 -0
  123. package/src/ui/mdx/index.ts +21 -0
  124. package/src/ui/primitives/Annotate.test.tsx +64 -0
  125. package/src/ui/primitives/Annotate.tsx +82 -0
  126. package/src/ui/primitives/Badge.tsx +5 -0
  127. package/src/ui/primitives/Callout.tsx +24 -0
  128. package/src/ui/primitives/ChromeIconButton.tsx +58 -0
  129. package/src/ui/primitives/ChromePanel.tsx +79 -0
  130. package/src/ui/primitives/ChromeTag.tsx +70 -0
  131. package/src/ui/primitives/FormSelect.tsx +51 -0
@@ -0,0 +1,394 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { isTypingElement } from "../browser";
11
+ import { createPersistedDrawState, parsePersistedDrawState } from "./persistence";
12
+
13
+ export interface DrawPoint {
14
+ x: number;
15
+ y: number;
16
+ }
17
+
18
+ export interface DrawStroke {
19
+ id: string;
20
+ color: string;
21
+ width: number;
22
+ kind?: "pen" | "circle" | "rectangle";
23
+ points: DrawPoint[];
24
+ }
25
+
26
+ export type DrawTool = "pen" | "circle" | "rectangle" | "eraser";
27
+
28
+ interface DrawContextValue {
29
+ enabled: boolean;
30
+ setEnabled: (value: boolean) => void;
31
+ toggleEnabled: () => void;
32
+ tool: DrawTool;
33
+ setTool: (tool: DrawTool) => void;
34
+ color: string;
35
+ setColor: (color: string) => void;
36
+ width: number;
37
+ setWidth: (width: number) => void;
38
+ strokesBySlideId: Record<string, DrawStroke[]>;
39
+ replaceAllStrokes: (next: Record<string, DrawStroke[]>) => void;
40
+ startStroke: (slideId: string, point: DrawPoint) => string;
41
+ appendStrokePoint: (slideId: string, strokeId: string, point: DrawPoint) => void;
42
+ eraseAtPoint: (slideId: string, point: DrawPoint) => void;
43
+ undo: (slideId: string) => void;
44
+ clear: (slideId: string) => void;
45
+ }
46
+
47
+ const DrawContext = createContext<DrawContextValue | null>(null);
48
+
49
+ function strokeContainsPoint(
50
+ stroke: DrawStroke,
51
+ point: DrawPoint,
52
+ radius: number,
53
+ radiusSquare: number,
54
+ ) {
55
+ if (stroke.kind === "circle") {
56
+ const center = stroke.points[0];
57
+ const edge = stroke.points[stroke.points.length - 1] ?? center;
58
+ const dxEdge = edge.x - center.x;
59
+ const dyEdge = edge.y - center.y;
60
+ const strokeRadius = Math.hypot(dxEdge, dyEdge);
61
+ const dxPoint = point.x - center.x;
62
+ const dyPoint = point.y - center.y;
63
+ const distance = Math.hypot(dxPoint, dyPoint);
64
+
65
+ return distance <= strokeRadius + radius;
66
+ }
67
+
68
+ if (stroke.kind === "rectangle") {
69
+ const start = stroke.points[0];
70
+ const end = stroke.points[stroke.points.length - 1] ?? start;
71
+ const minX = Math.min(start.x, end.x) - radius;
72
+ const maxX = Math.max(start.x, end.x) + radius;
73
+ const minY = Math.min(start.y, end.y) - radius;
74
+ const maxY = Math.max(start.y, end.y) + radius;
75
+
76
+ return point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY;
77
+ }
78
+
79
+ for (const drawPoint of stroke.points) {
80
+ const dx = drawPoint.x - point.x;
81
+ const dy = drawPoint.y - point.y;
82
+ if (dx * dx + dy * dy <= radiusSquare) return true;
83
+ }
84
+
85
+ return false;
86
+ }
87
+
88
+ export function DrawProvider({
89
+ currentSlideId,
90
+ storageKey,
91
+ readOnly = false,
92
+ overlayOpen = false,
93
+ remoteStrokes,
94
+ onStrokesChange,
95
+ children,
96
+ }: {
97
+ currentSlideId: string;
98
+ storageKey: string;
99
+ readOnly?: boolean;
100
+ overlayOpen?: boolean;
101
+ remoteStrokes?: {
102
+ revision: number;
103
+ strokesBySlideId: Record<string, DrawStroke[]>;
104
+ } | null;
105
+ onStrokesChange?: (strokesBySlideId: Record<string, DrawStroke[]>) => void;
106
+ children: ReactNode;
107
+ }) {
108
+ const [enabled, setEnabled] = useState(false);
109
+ const [tool, setTool] = useState<DrawTool>("pen");
110
+ const [color, setColor] = useState("#ef4444");
111
+ const [width, setWidth] = useState(4);
112
+ const [strokesBySlideId, setStrokesBySlideId] = useState<Record<string, DrawStroke[]>>({});
113
+ const lastAppliedRemoteRevisionRef = useRef(0);
114
+
115
+ const eraseAtPoint = (slideId: string, point: DrawPoint) => {
116
+ if (readOnly) return;
117
+
118
+ const radius = Math.max(width * 2.2, 8);
119
+ const radiusSquare = radius * radius;
120
+
121
+ setStrokesBySlideId((prev) => {
122
+ const strokes = prev[slideId];
123
+ if (!strokes || strokes.length === 0) return prev;
124
+
125
+ const next = strokes.filter((stroke) => {
126
+ return !strokeContainsPoint(stroke, point, radius, radiusSquare);
127
+ });
128
+
129
+ if (next.length === strokes.length) return prev;
130
+
131
+ return {
132
+ ...prev,
133
+ [slideId]: next,
134
+ };
135
+ });
136
+ };
137
+
138
+ const startStroke = (slideId: string, point: DrawPoint) => {
139
+ if (readOnly) return `readonly-${Date.now()}`;
140
+
141
+ if (tool === "eraser") {
142
+ eraseAtPoint(slideId, point);
143
+ return `eraser-${Date.now()}`;
144
+ }
145
+
146
+ const id = `stroke-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
147
+ const kind: DrawStroke["kind"] =
148
+ tool === "circle" ? "circle" : tool === "rectangle" ? "rectangle" : "pen";
149
+
150
+ const stroke: DrawStroke = {
151
+ id,
152
+ color,
153
+ width,
154
+ kind,
155
+ points: kind === "pen" ? [point] : [point, point],
156
+ };
157
+
158
+ setStrokesBySlideId((prev) => {
159
+ const next = prev[slideId] ? [...prev[slideId], stroke] : [stroke];
160
+ return {
161
+ ...prev,
162
+ [slideId]: next,
163
+ };
164
+ });
165
+
166
+ return id;
167
+ };
168
+
169
+ const appendStrokePoint = (slideId: string, strokeId: string, point: DrawPoint) => {
170
+ if (readOnly) return;
171
+
172
+ if (strokeId.startsWith("eraser-")) {
173
+ eraseAtPoint(slideId, point);
174
+ return;
175
+ }
176
+
177
+ setStrokesBySlideId((prev) => {
178
+ const strokes = prev[slideId];
179
+ if (!strokes || strokes.length === 0) return prev;
180
+
181
+ const index = strokes.findIndex((stroke) => stroke.id === strokeId);
182
+ if (index < 0) return prev;
183
+
184
+ const target = strokes[index];
185
+ const kind = target.kind ?? "pen";
186
+ const nextStroke: DrawStroke = {
187
+ ...target,
188
+ points: kind === "pen" ? [...target.points, point] : [target.points[0], point],
189
+ };
190
+ const next = [...strokes];
191
+ next[index] = nextStroke;
192
+
193
+ return {
194
+ ...prev,
195
+ [slideId]: next,
196
+ };
197
+ });
198
+ };
199
+
200
+ const undo = (slideId: string) => {
201
+ if (readOnly) return;
202
+
203
+ setStrokesBySlideId((prev) => {
204
+ const strokes = prev[slideId];
205
+ if (!strokes || strokes.length === 0) return prev;
206
+
207
+ return {
208
+ ...prev,
209
+ [slideId]: strokes.slice(0, -1),
210
+ };
211
+ });
212
+ };
213
+
214
+ const clear = (slideId: string) => {
215
+ if (readOnly) return;
216
+
217
+ setStrokesBySlideId((prev) => {
218
+ if (!prev[slideId]?.length) return prev;
219
+
220
+ return {
221
+ ...prev,
222
+ [slideId]: [],
223
+ };
224
+ });
225
+ };
226
+
227
+ useEffect(() => {
228
+ if (!readOnly) return;
229
+
230
+ setEnabled(false);
231
+ }, [readOnly]);
232
+
233
+ useEffect(() => {
234
+ if (!remoteStrokes) return;
235
+
236
+ if (remoteStrokes.revision <= lastAppliedRemoteRevisionRef.current) return;
237
+
238
+ lastAppliedRemoteRevisionRef.current = remoteStrokes.revision;
239
+ setStrokesBySlideId(remoteStrokes.strokesBySlideId);
240
+ }, [remoteStrokes]);
241
+
242
+ useEffect(() => {
243
+ if (readOnly) return;
244
+
245
+ const onKeyDown = (event: KeyboardEvent) => {
246
+ if (isTypingElement(event.target)) return;
247
+ if (overlayOpen) return;
248
+
249
+ const key = event.key.toLowerCase();
250
+ const hasModifier = event.metaKey || event.ctrlKey || event.altKey;
251
+
252
+ if (key === "d" && !hasModifier) {
253
+ event.preventDefault();
254
+ setEnabled((value) => !value);
255
+ return;
256
+ }
257
+
258
+ if (key === "escape" && enabled && !hasModifier) {
259
+ event.preventDefault();
260
+ setEnabled(false);
261
+ return;
262
+ }
263
+
264
+ if (key === "e" && !hasModifier) {
265
+ event.preventDefault();
266
+ setTool("eraser");
267
+ setEnabled(true);
268
+ return;
269
+ }
270
+
271
+ if (key === "p" && !hasModifier) {
272
+ event.preventDefault();
273
+ setTool("pen");
274
+ setEnabled(true);
275
+ return;
276
+ }
277
+
278
+ if (key === "r" && !hasModifier) {
279
+ event.preventDefault();
280
+ setTool("rectangle");
281
+ setEnabled(true);
282
+ return;
283
+ }
284
+
285
+ if (key === "b" && !hasModifier) {
286
+ event.preventDefault();
287
+ setTool("circle");
288
+ setEnabled(true);
289
+ return;
290
+ }
291
+
292
+ if ((event.metaKey || event.ctrlKey) && key === "z") {
293
+ event.preventDefault();
294
+ undo(currentSlideId);
295
+ return;
296
+ }
297
+
298
+ if (enabled && key === "c" && !hasModifier) {
299
+ event.preventDefault();
300
+ clear(currentSlideId);
301
+ }
302
+ };
303
+
304
+ window.addEventListener("keydown", onKeyDown);
305
+ return () => window.removeEventListener("keydown", onKeyDown);
306
+ }, [currentSlideId, enabled, overlayOpen, readOnly]);
307
+
308
+ useEffect(() => {
309
+ try {
310
+ const raw = window.localStorage.getItem(storageKey);
311
+ if (!raw) {
312
+ setStrokesBySlideId({});
313
+ return;
314
+ }
315
+
316
+ const parsed = parsePersistedDrawState(raw);
317
+ if (!parsed) {
318
+ setStrokesBySlideId({});
319
+ return;
320
+ }
321
+
322
+ setStrokesBySlideId(parsed.strokesBySlideId);
323
+ } catch {
324
+ setStrokesBySlideId({});
325
+ }
326
+ }, [storageKey]);
327
+
328
+ useEffect(() => {
329
+ const payload = createPersistedDrawState(strokesBySlideId);
330
+
331
+ try {
332
+ window.localStorage.setItem(storageKey, JSON.stringify(payload));
333
+ } catch {
334
+ // Ignore storage write errors (private mode, quota, etc.)
335
+ }
336
+ }, [storageKey, strokesBySlideId]);
337
+
338
+ useEffect(() => {
339
+ onStrokesChange?.(strokesBySlideId);
340
+ }, [onStrokesChange, strokesBySlideId]);
341
+
342
+ const value = useMemo<DrawContextValue>(
343
+ () => ({
344
+ enabled,
345
+ setEnabled: (nextEnabled) => {
346
+ if (readOnly) return;
347
+
348
+ setEnabled(nextEnabled);
349
+ },
350
+ toggleEnabled: () => {
351
+ if (readOnly) return;
352
+
353
+ setEnabled((enabledState) => !enabledState);
354
+ },
355
+ tool,
356
+ setTool: (nextTool) => {
357
+ if (readOnly) return;
358
+
359
+ setTool(nextTool);
360
+ },
361
+ color,
362
+ setColor: (nextColor) => {
363
+ if (readOnly) return;
364
+
365
+ setColor(nextColor);
366
+ },
367
+ width,
368
+ setWidth: (nextWidth) => {
369
+ if (readOnly) return;
370
+
371
+ setWidth(nextWidth);
372
+ },
373
+ strokesBySlideId,
374
+ replaceAllStrokes: (next) => {
375
+ setStrokesBySlideId(next);
376
+ },
377
+ startStroke,
378
+ appendStrokePoint,
379
+ eraseAtPoint,
380
+ undo,
381
+ clear,
382
+ }),
383
+ [color, enabled, readOnly, strokesBySlideId, tool, width],
384
+ );
385
+
386
+ return <DrawContext.Provider value={value}>{children}</DrawContext.Provider>;
387
+ }
388
+
389
+ export function useDraw() {
390
+ const context = useContext(DrawContext);
391
+ if (!context) throw new Error("useDraw must be used inside DrawProvider");
392
+
393
+ return context;
394
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ DRAW_STORAGE_VERSION,
4
+ createPersistedDrawState,
5
+ parsePersistedDrawState,
6
+ } from "./persistence";
7
+
8
+ describe("draw persistence", () => {
9
+ it("parses persisted draw state", () => {
10
+ expect(
11
+ parsePersistedDrawState(
12
+ JSON.stringify({
13
+ version: DRAW_STORAGE_VERSION,
14
+ strokesBySlideId: {
15
+ "slide-1": [
16
+ {
17
+ id: "stroke-1",
18
+ color: "#ef4444",
19
+ width: 4,
20
+ kind: "pen",
21
+ points: [{ x: 1, y: 2 }],
22
+ },
23
+ ],
24
+ },
25
+ }),
26
+ ),
27
+ ).toEqual({
28
+ version: DRAW_STORAGE_VERSION,
29
+ strokesBySlideId: {
30
+ "slide-1": [
31
+ {
32
+ id: "stroke-1",
33
+ color: "#ef4444",
34
+ width: 4,
35
+ kind: "pen",
36
+ points: [{ x: 1, y: 2 }],
37
+ },
38
+ ],
39
+ },
40
+ });
41
+ });
42
+
43
+ it("rejects invalid persisted state", () => {
44
+ expect(
45
+ parsePersistedDrawState(
46
+ JSON.stringify({
47
+ version: DRAW_STORAGE_VERSION,
48
+ strokesBySlideId: {
49
+ "slide-1": [
50
+ {
51
+ id: "stroke-1",
52
+ color: "#ef4444",
53
+ width: "4",
54
+ points: [{ x: 1, y: 2 }],
55
+ },
56
+ ],
57
+ },
58
+ }),
59
+ ),
60
+ ).toBeNull();
61
+ });
62
+
63
+ it("rejects unexpected storage versions", () => {
64
+ expect(
65
+ parsePersistedDrawState(
66
+ JSON.stringify({
67
+ version: 2,
68
+ strokesBySlideId: {},
69
+ }),
70
+ ),
71
+ ).toBeNull();
72
+ });
73
+
74
+ it("creates versioned persisted state", () => {
75
+ expect(createPersistedDrawState({})).toEqual({
76
+ version: DRAW_STORAGE_VERSION,
77
+ strokesBySlideId: {},
78
+ });
79
+ });
80
+ });
@@ -0,0 +1,54 @@
1
+ import { z } from "zod";
2
+ import type { PresentationDrawingsState } from "../types";
3
+
4
+ export const DRAW_STORAGE_VERSION = 1;
5
+
6
+ export interface PersistedDrawState {
7
+ version: typeof DRAW_STORAGE_VERSION;
8
+ strokesBySlideId: PresentationDrawingsState;
9
+ }
10
+
11
+ function isRecord(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null;
13
+ }
14
+
15
+ const drawPointSchema = z.object({
16
+ x: z.number(),
17
+ y: z.number(),
18
+ });
19
+
20
+ const drawStrokeSchema = z.object({
21
+ id: z.string(),
22
+ color: z.string(),
23
+ width: z.number(),
24
+ kind: z.enum(["pen", "circle", "rectangle"]).optional(),
25
+ points: z.array(drawPointSchema),
26
+ });
27
+
28
+ const drawingsStateSchema = z.record(z.string(), z.array(drawStrokeSchema));
29
+
30
+ export function parsePersistedDrawState(raw: string): PersistedDrawState | null {
31
+ try {
32
+ const parsed = JSON.parse(raw) as unknown;
33
+ if (!isRecord(parsed) || parsed.version !== DRAW_STORAGE_VERSION) return null;
34
+
35
+ const result = drawingsStateSchema.safeParse(parsed.strokesBySlideId);
36
+ if (!result.success) return null;
37
+
38
+ return {
39
+ version: DRAW_STORAGE_VERSION,
40
+ strokesBySlideId: result.data,
41
+ };
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function createPersistedDrawState(
48
+ strokesBySlideId: PresentationDrawingsState,
49
+ ): PersistedDrawState {
50
+ return {
51
+ version: DRAW_STORAGE_VERSION,
52
+ strokesBySlideId,
53
+ };
54
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createSlideImageFileName,
4
+ createSlideSnapshotFileName,
5
+ resolveExportSlidesBaseName,
6
+ trimPdfExtension,
7
+ } from "./exportArtifacts";
8
+
9
+ describe("presentation export artifacts", () => {
10
+ it("removes a trailing pdf extension from document titles", () => {
11
+ expect(trimPdfExtension("client-demo.pdf")).toBe("client-demo");
12
+ });
13
+
14
+ it("resolves a stable slides base name", () => {
15
+ expect(resolveExportSlidesBaseName("Q4 Review Deck.pdf")).toBe("q4-review-deck");
16
+ });
17
+
18
+ it("falls back when the document title is empty", () => {
19
+ expect(resolveExportSlidesBaseName(" ")).toBe("slide-react-slides");
20
+ });
21
+
22
+ it("builds slide image names from the slide index and title", () => {
23
+ expect(
24
+ createSlideImageFileName({
25
+ index: 3,
26
+ title: "API Boundary & Tradeoffs",
27
+ }),
28
+ ).toBe("003-api-boundary-tradeoffs.png");
29
+ });
30
+
31
+ it("falls back to the slide index when no title exists", () => {
32
+ expect(
33
+ createSlideImageFileName({
34
+ index: 7,
35
+ }),
36
+ ).toBe("007-slide-7.png");
37
+ });
38
+
39
+ it("adds click-step suffixes for snapshot exports", () => {
40
+ expect(
41
+ createSlideSnapshotFileName({
42
+ index: 3,
43
+ title: "API Boundary & Tradeoffs",
44
+ clickStep: 2,
45
+ }),
46
+ ).toBe("003-api-boundary-tradeoffs-click-2.png");
47
+ });
48
+ });
@@ -0,0 +1,6 @@
1
+ export {
2
+ createSlideImageFileName,
3
+ createSlideSnapshotFileName,
4
+ resolveExportSlidesBaseName,
5
+ trimPdfExtension,
6
+ } from "@slidev-react/core/presentation/export/fileNames";
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildSlidesPath,
4
+ normalizePathname,
5
+ resolveSessionLocationState,
6
+ resolveSlidesLocationState,
7
+ } from "./location";
8
+
9
+ describe("presentation location", () => {
10
+ it("resolves presenter routes for slides navigation", () => {
11
+ expect(resolveSlidesLocationState("/deck/presenter/3", 10)).toEqual({
12
+ index: 2,
13
+ mode: {
14
+ kind: "role",
15
+ role: "presenter",
16
+ basePath: "/deck",
17
+ },
18
+ });
19
+ });
20
+
21
+ it("defaults non-slide routes to the first standalone slide", () => {
22
+ expect(resolveSlidesLocationState("/", 10)).toEqual({
23
+ index: 0,
24
+ mode: {
25
+ kind: "standalone",
26
+ basePath: "",
27
+ },
28
+ });
29
+
30
+ expect(resolveSessionLocationState("/")).toEqual({
31
+ enabled: false,
32
+ role: "viewer",
33
+ currentSlideNumber: 1,
34
+ normalizedPath: null,
35
+ });
36
+ });
37
+
38
+ it("resolves standalone routes for sessions", () => {
39
+ expect(resolveSessionLocationState("/deck/4")).toEqual({
40
+ enabled: true,
41
+ role: "viewer",
42
+ currentSlideNumber: 4,
43
+ normalizedPath: "/deck/4",
44
+ });
45
+ });
46
+
47
+ it("builds paths from route modes", () => {
48
+ expect(
49
+ buildSlidesPath(
50
+ {
51
+ kind: "role",
52
+ role: "presenter",
53
+ basePath: "/deck",
54
+ },
55
+ 1,
56
+ ),
57
+ ).toBe("/deck/presenter/2");
58
+
59
+ expect(
60
+ buildSlidesPath(
61
+ {
62
+ kind: "standalone",
63
+ basePath: "/deck",
64
+ },
65
+ 1,
66
+ ),
67
+ ).toBe("/deck/2");
68
+ });
69
+
70
+ it("normalizes trailing slashes", () => {
71
+ expect(normalizePathname("/deck/")).toBe("/deck");
72
+ });
73
+ });