@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,221 @@
1
+ export const SHORTCUT_HELP_DOUBLE_SHIFT_MS = 400;
2
+
3
+ export type NavigationShortcutAction = "advance" | "retreat" | "first" | "last";
4
+
5
+ export type ShortcutHelpItem = {
6
+ keys: string;
7
+ action: string;
8
+ };
9
+
10
+ export type ShortcutHelpSection = {
11
+ title: string;
12
+ description: string;
13
+ items: ShortcutHelpItem[];
14
+ };
15
+
16
+ export type ShortcutHelpTriggerState = {
17
+ shiftPressed: boolean;
18
+ shiftChordActive: boolean;
19
+ lastIsolatedShiftAt: number | null;
20
+ };
21
+
22
+ export function resolveNavigationShortcutAction({
23
+ key,
24
+ shiftKey,
25
+ }: {
26
+ key: string;
27
+ shiftKey: boolean;
28
+ }): NavigationShortcutAction | null {
29
+ if (key === "ArrowRight" || key === "PageDown") return "advance";
30
+ if (key === "ArrowLeft" || key === "PageUp") return "retreat";
31
+ if (key === "Home") return "first";
32
+ if (key === "End") return "last";
33
+ if (key === " ") return shiftKey ? "retreat" : "advance";
34
+
35
+ return null;
36
+ }
37
+
38
+ export function createShortcutHelpTriggerState(): ShortcutHelpTriggerState {
39
+ return {
40
+ shiftPressed: false,
41
+ shiftChordActive: false,
42
+ lastIsolatedShiftAt: null,
43
+ };
44
+ }
45
+
46
+ export function registerShortcutHelpKeyDown(
47
+ state: ShortcutHelpTriggerState,
48
+ key: string,
49
+ ): ShortcutHelpTriggerState {
50
+ if (key === "Shift") {
51
+ return {
52
+ ...state,
53
+ shiftPressed: true,
54
+ shiftChordActive: false,
55
+ };
56
+ }
57
+
58
+ if (!state.shiftPressed) return state;
59
+
60
+ return {
61
+ ...state,
62
+ shiftChordActive: true,
63
+ };
64
+ }
65
+
66
+ export function registerShortcutHelpKeyUp(
67
+ state: ShortcutHelpTriggerState,
68
+ key: string,
69
+ releasedAt: number,
70
+ thresholdMs = SHORTCUT_HELP_DOUBLE_SHIFT_MS,
71
+ ) {
72
+ if (key !== "Shift") {
73
+ return {
74
+ nextState: state,
75
+ shouldToggle: false,
76
+ };
77
+ }
78
+
79
+ const isolatedTap = state.shiftPressed && !state.shiftChordActive;
80
+ const shouldToggle =
81
+ isolatedTap &&
82
+ state.lastIsolatedShiftAt != null &&
83
+ releasedAt - state.lastIsolatedShiftAt <= thresholdMs;
84
+
85
+ return {
86
+ nextState: {
87
+ shiftPressed: false,
88
+ shiftChordActive: false,
89
+ lastIsolatedShiftAt: isolatedTap
90
+ ? shouldToggle
91
+ ? null
92
+ : releasedAt
93
+ : state.lastIsolatedShiftAt,
94
+ },
95
+ shouldToggle,
96
+ };
97
+ }
98
+
99
+ export function isShortcutHelpOpenKey({
100
+ key,
101
+ shiftKey,
102
+ metaKey,
103
+ ctrlKey,
104
+ altKey,
105
+ }: {
106
+ key: string;
107
+ shiftKey: boolean;
108
+ metaKey: boolean;
109
+ ctrlKey: boolean;
110
+ altKey: boolean;
111
+ }) {
112
+ if (metaKey || ctrlKey || altKey) return false;
113
+
114
+ return key === "?" || (key === "/" && shiftKey);
115
+ }
116
+
117
+ export function buildShortcutHelpSections({
118
+ canControl,
119
+ canOpenOverview,
120
+ }: {
121
+ canControl: boolean;
122
+ canOpenOverview: boolean;
123
+ }): ShortcutHelpSection[] {
124
+ const sections: ShortcutHelpSection[] = [
125
+ {
126
+ title: "Navigation",
127
+ description: "Move through the slides without leaving the keyboard.",
128
+ items: [
129
+ {
130
+ keys: "Right / Space / PageDown",
131
+ action: "Next cue or next slide",
132
+ },
133
+ {
134
+ keys: "Left / Shift + Space / PageUp",
135
+ action: "Previous cue or previous slide",
136
+ },
137
+ {
138
+ keys: "Home",
139
+ action: "Jump to the first slide",
140
+ },
141
+ {
142
+ keys: "End",
143
+ action: "Jump to the last slide",
144
+ },
145
+ ],
146
+ },
147
+ {
148
+ title: "Overlays",
149
+ description: "Open, switch, and close the main workspaces.",
150
+ items: [
151
+ {
152
+ keys: "?",
153
+ action: "Toggle keyboard shortcuts help",
154
+ },
155
+ {
156
+ keys: "Shift Shift",
157
+ action: "Toggle keyboard shortcuts help",
158
+ },
159
+ {
160
+ keys: "Esc",
161
+ action: "Close the current overlay",
162
+ },
163
+ ...(canOpenOverview
164
+ ? [
165
+ {
166
+ keys: "O",
167
+ action: "Toggle quick overview",
168
+ },
169
+ ]
170
+ : []),
171
+ ...(canControl
172
+ ? [
173
+ {
174
+ keys: "N",
175
+ action: "Toggle notes workspace",
176
+ },
177
+ ]
178
+ : []),
179
+ ],
180
+ },
181
+ ];
182
+
183
+ if (canControl) {
184
+ sections.push({
185
+ title: "Draw",
186
+ description: "Control live annotation tools while presenting.",
187
+ items: [
188
+ {
189
+ keys: "D",
190
+ action: "Toggle draw mode",
191
+ },
192
+ {
193
+ keys: "P",
194
+ action: "Switch to pen",
195
+ },
196
+ {
197
+ keys: "B",
198
+ action: "Switch to circle",
199
+ },
200
+ {
201
+ keys: "R",
202
+ action: "Switch to rectangle",
203
+ },
204
+ {
205
+ keys: "E",
206
+ action: "Switch to eraser",
207
+ },
208
+ {
209
+ keys: "C",
210
+ action: "Clear strokes on the current slide",
211
+ },
212
+ {
213
+ keys: "Cmd/Ctrl + Z",
214
+ action: "Undo the last stroke",
215
+ },
216
+ ],
217
+ });
218
+ }
219
+
220
+ return sections;
221
+ }
@@ -0,0 +1,15 @@
1
+ import { useSlidesState } from "../../../app/providers/SlidesNavigationProvider";
2
+
3
+ export function useSlidesNavigation() {
4
+ const slides = useSlidesState();
5
+
6
+ return {
7
+ currentIndex: slides.currentIndex,
8
+ total: slides.total,
9
+ next: slides.next,
10
+ prev: slides.prev,
11
+ first: slides.first,
12
+ last: slides.last,
13
+ goTo: slides.goTo,
14
+ };
15
+ }
@@ -0,0 +1,200 @@
1
+ import { NotebookText, X } from "lucide-react";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import type { CompiledSlide } from "../presenter/types";
4
+ import { ChromePanel } from "../../../ui/primitives/ChromePanel";
5
+
6
+ function summarizeNotes(notes?: string) {
7
+ if (!notes) return "No speaker notes yet.";
8
+
9
+ const normalized = notes.replace(/\s+/g, " ").trim();
10
+ if (normalized.length <= 140) return normalized;
11
+
12
+ return `${normalized.slice(0, 137)}...`;
13
+ }
14
+
15
+ function toParagraphs(notes?: string) {
16
+ if (!notes) return [];
17
+
18
+ return notes
19
+ .split(/\n\s*\n/g)
20
+ .map((section) => section.trim())
21
+ .filter(Boolean);
22
+ }
23
+
24
+ export function NotesOverview({
25
+ open,
26
+ slides,
27
+ currentIndex,
28
+ onClose,
29
+ onSelect,
30
+ }: {
31
+ open: boolean;
32
+ slides: CompiledSlide[];
33
+ currentIndex: number;
34
+ onClose: () => void;
35
+ onSelect: (index: number) => void;
36
+ }) {
37
+ const [selectedIndex, setSelectedIndex] = useState(currentIndex);
38
+
39
+ useEffect(() => {
40
+ if (!open) return;
41
+
42
+ setSelectedIndex(currentIndex);
43
+ }, [currentIndex, open]);
44
+
45
+ const selectedSlide = slides[selectedIndex] ?? slides[currentIndex] ?? null;
46
+ const notedSlidesCount = useMemo(
47
+ () => slides.filter((slide) => Boolean(slide.meta.notes?.trim())).length,
48
+ [slides],
49
+ );
50
+ const selectedNotes = toParagraphs(selectedSlide?.meta.notes);
51
+
52
+ if (!open) return null;
53
+
54
+ return (
55
+ <div className="absolute inset-0 z-50 bg-slate-100/84 backdrop-blur-sm">
56
+ <div className="mx-auto flex h-full w-full max-w-[1800px] flex-col px-6 py-6">
57
+ <header className="mb-4 flex items-start justify-between gap-6">
58
+ <div className="text-slate-900">
59
+ <div className="mb-2 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/90 px-3 py-1 text-xs font-medium text-slate-600">
60
+ <NotebookText size={14} />
61
+ <span>
62
+ {notedSlidesCount}/{slides.length} slides have notes
63
+ </span>
64
+ </div>
65
+ <h2 className="text-lg font-semibold">Notes Workspace</h2>
66
+ <p className="text-sm text-slate-600">
67
+ Review the whole narrative, then jump directly to the slide you want to rehearse.
68
+ Press `N` or `Esc` to close.
69
+ </p>
70
+ </div>
71
+ <button
72
+ type="button"
73
+ onClick={onClose}
74
+ aria-label="Close notes workspace"
75
+ className="inline-flex size-10 items-center justify-center rounded-lg border border-slate-300 bg-white text-slate-700 hover:bg-slate-50"
76
+ >
77
+ <X size={18} />
78
+ </button>
79
+ </header>
80
+ <div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[minmax(340px,420px)_minmax(0,1fr)]">
81
+ <ChromePanel tone="solid" radius="section" padding="none" className="overflow-hidden">
82
+ <div className="border-b border-slate-200/80 px-5 py-4">
83
+ <h3 className="text-sm font-semibold text-slate-900">Slide Notes Index</h3>
84
+ <p className="mt-1 text-sm text-slate-500">
85
+ Every slide is listed here, including the ones that still need notes.
86
+ </p>
87
+ </div>
88
+ <div className="min-h-0 overflow-auto p-3">
89
+ <div className="space-y-2">
90
+ {slides.map((slide, index) => {
91
+ const active = index === selectedIndex;
92
+ const isCurrent = index === currentIndex;
93
+ const hasNotes = Boolean(slide.meta.notes?.trim());
94
+
95
+ return (
96
+ <button
97
+ key={slide.id}
98
+ type="button"
99
+ onClick={() => setSelectedIndex(index)}
100
+ className={`w-full rounded-lg border p-4 text-left transition ${
101
+ active
102
+ ? "border-emerald-400 bg-emerald-50 "
103
+ : "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50"
104
+ }`}
105
+ >
106
+ <div className="mb-2 flex items-center justify-between gap-3">
107
+ <div className="flex items-center gap-2">
108
+ <span className="rounded-md bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-700">
109
+ {index + 1}
110
+ </span>
111
+ {isCurrent && (
112
+ <span className="rounded-md bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
113
+ Current
114
+ </span>
115
+ )}
116
+ </div>
117
+ <span
118
+ className={`rounded-md px-2 py-0.5 text-xs font-medium ${
119
+ hasNotes
120
+ ? "bg-emerald-100 text-emerald-700"
121
+ : "bg-slate-100 text-slate-500"
122
+ }`}
123
+ >
124
+ {hasNotes ? "Notes ready" : "No notes"}
125
+ </span>
126
+ </div>
127
+ <div className="text-sm font-semibold text-slate-900">
128
+ {slide.meta.title ?? `Slide ${index + 1}`}
129
+ </div>
130
+ <p className="mt-2 text-sm leading-6 text-slate-500">
131
+ {summarizeNotes(slide.meta.notes)}
132
+ </p>
133
+ </button>
134
+ );
135
+ })}
136
+ </div>
137
+ </div>
138
+ </ChromePanel>
139
+ <ChromePanel
140
+ tone="solid"
141
+ radius="section"
142
+ padding="none"
143
+ className="flex flex-col overflow-hidden"
144
+ >
145
+ <div className="border-b border-slate-200/80 px-6 py-5">
146
+ <div className="mb-3 flex flex-wrap items-center gap-2">
147
+ <span className="rounded-md bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-700">
148
+ {selectedIndex + 1}
149
+ </span>
150
+ {selectedSlide?.meta.layout && (
151
+ <span className="rounded-md bg-slate-100 px-2 py-0.5 text-xs text-slate-500">
152
+ {selectedSlide.meta.layout}
153
+ </span>
154
+ )}
155
+ </div>
156
+ <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
157
+ <div>
158
+ <h3 className="text-lg font-semibold text-slate-900">
159
+ {selectedSlide?.meta.title ?? `Slide ${selectedIndex + 1}`}
160
+ </h3>
161
+ <p className="mt-1 text-sm text-slate-500">
162
+ {selectedSlide?.meta.notes?.trim()
163
+ ? "Full speaker notes for the selected slide."
164
+ : "This slide still needs presenter notes."}
165
+ </p>
166
+ </div>
167
+ <button
168
+ type="button"
169
+ onClick={() => onSelect(selectedIndex)}
170
+ className="inline-flex items-center justify-center rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800"
171
+ >
172
+ Jump To Slide
173
+ </button>
174
+ </div>
175
+ </div>
176
+ <div className="min-h-0 flex-1 overflow-auto px-6 py-5">
177
+ {selectedNotes.length > 0 ? (
178
+ <div className="space-y-4 text-[15px] leading-7 text-slate-600">
179
+ {selectedNotes.map((paragraph, index) => (
180
+ <p key={`${index}-${paragraph.slice(0, 24)}`} className="whitespace-pre-wrap">
181
+ {paragraph}
182
+ </p>
183
+ ))}
184
+ </div>
185
+ ) : (
186
+ <div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-6">
187
+ <p className="font-medium text-slate-900">No notes on this slide yet.</p>
188
+ <p className="mt-2 text-sm leading-6 text-slate-500">
189
+ Add slide frontmatter with <code>notes: |</code> to capture your framing,
190
+ punchlines, and the line you do not want to improvise live.
191
+ </p>
192
+ </div>
193
+ )}
194
+ </div>
195
+ </ChromePanel>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ );
200
+ }
@@ -0,0 +1,126 @@
1
+ import type { KeyboardEvent } from "react";
2
+ import { X } from "lucide-react";
3
+ import { formatViewportAspectRatio } from "@slidev-react/core/slides/viewport";
4
+ import type { CompiledSlide, SlidesConfig } from "../presenter/types";
5
+ import { ChromeIconButton } from "../../../ui/primitives/ChromeIconButton";
6
+ import { ChromePanel } from "../../../ui/primitives/ChromePanel";
7
+ import { ChromeTag } from "../../../ui/primitives/ChromeTag";
8
+ import { SlidePreviewSurface } from "../stage/SlidePreviewSurface";
9
+
10
+ function OverviewSlidePreview({
11
+ index,
12
+ active,
13
+ slide,
14
+ slidesConfig,
15
+ }: {
16
+ index: number;
17
+ active: boolean;
18
+ slide: CompiledSlide;
19
+ slidesConfig: Pick<SlidesConfig, "slidesViewport" | "slidesLayout" | "slidesBackground">;
20
+ }) {
21
+ const { slidesViewport, slidesLayout, slidesBackground } = slidesConfig;
22
+ return (
23
+ <div
24
+ style={{ aspectRatio: formatViewportAspectRatio(slidesViewport) }}
25
+ className={`relative mb-0 w-full overflow-hidden rounded-t-md rounded-b-none bg-slate-100/72 ${active ? "ring-1 ring-emerald-200/80" : ""}`}
26
+ >
27
+ <span className="absolute top-2 left-2 z-10">
28
+ <ChromeTag tone={active ? "active" : "default"} size="xs" weight="semibold">
29
+ {index + 1}
30
+ </ChromeTag>
31
+ </span>
32
+ {slide.meta.layout && (
33
+ <span className="absolute top-2 right-2 z-10">
34
+ <ChromeTag size="xs">{slide.meta.layout}</ChromeTag>
35
+ </span>
36
+ )}
37
+ <SlidePreviewSurface
38
+ Slide={slide.component}
39
+ meta={slide.meta}
40
+ slidesConfig={slidesConfig}
41
+ viewportClassName="size-full"
42
+ stageClassName="pointer-events-none select-none"
43
+ />
44
+ </div>
45
+ );
46
+ }
47
+
48
+ export function QuickOverview({
49
+ open,
50
+ slides,
51
+ currentIndex,
52
+ slidesConfig,
53
+ onClose,
54
+ onSelect,
55
+ }: {
56
+ open: boolean;
57
+ slides: CompiledSlide[];
58
+ currentIndex: number;
59
+ slidesConfig: Pick<SlidesConfig, "slidesViewport" | "slidesLayout" | "slidesBackground">;
60
+ onClose: () => void;
61
+ onSelect: (index: number) => void;
62
+ }) {
63
+ function handleSelectKeyDown(event: KeyboardEvent<HTMLElement>, index: number) {
64
+ if (event.key !== "Enter" && event.key !== " ") return;
65
+ event.preventDefault();
66
+ onSelect(index);
67
+ }
68
+
69
+ if (!open) return null;
70
+
71
+ return (
72
+ <div className="absolute inset-0 z-50 bg-slate-100/84 backdrop-blur-md">
73
+ <div className="mx-auto flex h-full w-full max-w-[2200px] flex-col px-6 py-6">
74
+ <header className="mb-5 flex items-center justify-between">
75
+ <div className="text-slate-900">
76
+ <h2 className="text-lg font-semibold">Quick Overview</h2>
77
+ <p className="text-sm text-slate-600">
78
+ Click a slide to jump. Press `O` or `Esc` to close.
79
+ </p>
80
+ </div>
81
+ <ChromeIconButton
82
+ onClick={onClose}
83
+ aria-label="Close quick overview"
84
+ className="rounded-full "
85
+ >
86
+ <X size={18} />
87
+ </ChromeIconButton>
88
+ </header>
89
+ <div className="min-h-0 flex-1 overflow-auto pr-1">
90
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(360px,1fr))] gap-5">
91
+ {slides.map((slide, index) => {
92
+ const active = index === currentIndex;
93
+ return (
94
+ <ChromePanel
95
+ key={slide.id}
96
+ as="article"
97
+ role="button"
98
+ tabIndex={0}
99
+ onClick={() => onSelect(index)}
100
+ onKeyDown={(event: KeyboardEvent<HTMLElement>) =>
101
+ handleSelectKeyDown(event, index)
102
+ }
103
+ className={`group cursor-pointer overflow-hidden p-0 text-left transition focus:outline-none focus:ring-2 focus:ring-emerald-300/70 ${active ? "bg-white ring-1 ring-emerald-300/70" : "bg-white/90 ring-1 ring-transparent hover:bg-white/92 hover:ring-slate-300/70"}`}
104
+ aria-label={`Go to slide ${index + 1}`}
105
+ tone="solid"
106
+ radius="section"
107
+ padding="none"
108
+ >
109
+ <OverviewSlidePreview
110
+ index={index}
111
+ active={active}
112
+ slide={slide}
113
+ slidesConfig={slidesConfig}
114
+ />
115
+ <div className="truncate px-2.5 py-2 text-sm font-medium text-slate-900">
116
+ {slide.meta.title ?? `Slide ${index + 1}`}
117
+ </div>
118
+ </ChromePanel>
119
+ );
120
+ })}
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ );
126
+ }
@@ -0,0 +1,137 @@
1
+ export type PresentationPathRole = "presenter";
2
+
3
+ const ROLE_SET: ReadonlySet<PresentationPathRole> = new Set(["presenter"]);
4
+
5
+ export interface ParsedPresentationPath {
6
+ role: PresentationPathRole;
7
+ slideNumber: number | null;
8
+ basePath: string;
9
+ }
10
+
11
+ export interface ParsedStandalonePath {
12
+ slideNumber: number;
13
+ basePath: string;
14
+ }
15
+
16
+ function normalizePath(pathname: string) {
17
+ if (!pathname) return "/";
18
+
19
+ if (pathname.length > 1 && pathname.endsWith("/")) return pathname.slice(0, -1);
20
+
21
+ return pathname;
22
+ }
23
+
24
+ function normalizeBasePath(basePath: string) {
25
+ const normalized = normalizePath(basePath);
26
+ if (normalized === "/") return "";
27
+
28
+ return normalized;
29
+ }
30
+
31
+ function toSegments(pathname: string) {
32
+ return normalizePath(pathname).split("/").filter(Boolean);
33
+ }
34
+
35
+ function toPath(segments: string[]) {
36
+ if (segments.length === 0) return "";
37
+
38
+ return `/${segments.join("/")}`;
39
+ }
40
+
41
+ export function parsePresentationPath(pathname: string): ParsedPresentationPath | null {
42
+ const segments = toSegments(pathname);
43
+ if (segments.length === 0) return null;
44
+
45
+ const last = segments[segments.length - 1];
46
+ if (ROLE_SET.has(last as PresentationPathRole)) {
47
+ return {
48
+ role: last as PresentationPathRole,
49
+ slideNumber: null,
50
+ basePath: toPath(segments.slice(0, -1)),
51
+ };
52
+ }
53
+
54
+ const maybeSlideNumber = Number.parseInt(last, 10);
55
+ if (!Number.isNaN(maybeSlideNumber) && maybeSlideNumber > 0 && segments.length >= 2) {
56
+ const maybeRole = segments[segments.length - 2];
57
+ if (ROLE_SET.has(maybeRole as PresentationPathRole)) {
58
+ return {
59
+ role: maybeRole as PresentationPathRole,
60
+ slideNumber: maybeSlideNumber,
61
+ basePath: toPath(segments.slice(0, -2)),
62
+ };
63
+ }
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ export function parseStandalonePath(pathname: string): ParsedStandalonePath | null {
70
+ const parsedPresentation = parsePresentationPath(pathname);
71
+ if (parsedPresentation) return null;
72
+
73
+ const segments = toSegments(pathname);
74
+ if (segments.length === 0) return null;
75
+
76
+ const maybeSlideNumber = Number.parseInt(segments[segments.length - 1], 10);
77
+ if (Number.isNaN(maybeSlideNumber) || maybeSlideNumber < 1) return null;
78
+
79
+ return {
80
+ slideNumber: maybeSlideNumber,
81
+ basePath: toPath(segments.slice(0, -1)),
82
+ };
83
+ }
84
+
85
+ export function resolvePresentationBasePath(pathname: string) {
86
+ const parsed = parsePresentationPath(pathname);
87
+ if (parsed) return normalizeBasePath(parsed.basePath);
88
+
89
+ const parsedStandalone = parseStandalonePath(pathname);
90
+ if (parsedStandalone) return normalizeBasePath(parsedStandalone.basePath);
91
+
92
+ const normalized = normalizePath(pathname);
93
+ if (normalized === "/") return "";
94
+
95
+ if (normalized === "/index.html") return "";
96
+
97
+ if (normalized.endsWith("/index.html"))
98
+ return normalizeBasePath(normalized.slice(0, -"/index.html".length));
99
+
100
+ return normalizeBasePath(normalized);
101
+ }
102
+
103
+ export function buildRolePathFromBase(
104
+ basePath: string,
105
+ role: PresentationPathRole,
106
+ slideNumber: number,
107
+ ) {
108
+ const normalizedBase = normalizeBasePath(basePath);
109
+ const safeSlideNumber =
110
+ Number.isFinite(slideNumber) && slideNumber > 0 ? Math.floor(slideNumber) : 1;
111
+
112
+ if (!normalizedBase) return `/${role}/${safeSlideNumber}`;
113
+
114
+ return `${normalizedBase}/${role}/${safeSlideNumber}`;
115
+ }
116
+
117
+ export function buildRolePathFromPathname(
118
+ pathname: string,
119
+ role: PresentationPathRole,
120
+ slideNumber: number,
121
+ ) {
122
+ return buildRolePathFromBase(resolvePresentationBasePath(pathname), role, slideNumber);
123
+ }
124
+
125
+ export function buildStandalonePathFromBase(basePath: string, slideNumber: number) {
126
+ const normalizedBase = normalizeBasePath(basePath);
127
+ const safeSlideNumber =
128
+ Number.isFinite(slideNumber) && slideNumber > 0 ? Math.floor(slideNumber) : 1;
129
+
130
+ if (!normalizedBase) return `/${safeSlideNumber}`;
131
+
132
+ return `${normalizedBase}/${safeSlideNumber}`;
133
+ }
134
+
135
+ export function buildStandalonePathFromPathname(pathname: string, slideNumber: number) {
136
+ return buildStandalonePathFromBase(resolvePresentationBasePath(pathname), slideNumber);
137
+ }