@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,286 @@
1
+ import { useCallback } from "react"
2
+ import type { CompiledSlide, SlidesConfig } from './types'
3
+ import { DrawProvider } from "../draw/DrawProvider"
4
+ import { KeyboardController } from "../navigation/KeyboardController"
5
+ import { ShortcutsHelpOverlay } from "../navigation/ShortcutsHelpOverlay"
6
+ import { NotesOverview } from "../overview/NotesOverview"
7
+ import { PresentationNavbar } from "../navigation/PresentationNavbar"
8
+ import { useSlidesNavigation } from "../navigation/useSlidesNavigation"
9
+ import { QuickOverview } from "../overview/QuickOverview"
10
+ import { PresentationStatus } from "../PresentationStatus"
11
+ import { buildPrintExportUrl } from "@slidev-react/core/presentation/export/urls"
12
+ import { buildPresentationEntryUrl, type PresentationSession } from "../session"
13
+ import type { PresentationSyncMode } from "../types"
14
+ import { RevealProvider } from "../reveal/RevealContext"
15
+ import { FlowTimelinePreview } from "./FlowTimelinePreview"
16
+ import { PresenterTopProgress } from "./PresenterTopProgress"
17
+ import { usePresentationFlowRuntime } from "./usePresentationFlowRuntime"
18
+ import { usePresenterChromeRuntime } from "./usePresenterChromeRuntime"
19
+ import { usePresenterSessionState } from "./usePresenterSessionState"
20
+ import { useWakeLock } from "./useWakeLock"
21
+ import { useFullscreen } from "./useFullscreen"
22
+ import { PresenterModeView } from "./PresenterModeView"
23
+ import { StandaloneModeView } from "./StandaloneModeView"
24
+
25
+ function canControlNavigation(session: PresentationSession) {
26
+ return !session.enabled || session.role === "presenter"
27
+ }
28
+ const PRESENTER_BOTTOM_BAR_CLEARANCE = 72
29
+
30
+ export function PresenterShell({
31
+ slides,
32
+ slidesTitle,
33
+ slidesConfig,
34
+ slidesExportFilename,
35
+ slidesSessionSeed,
36
+ drawStorageKey,
37
+ session,
38
+ onSyncModeChange,
39
+ }: {
40
+ slides: CompiledSlide[]
41
+ slidesTitle?: string
42
+ slidesConfig: SlidesConfig
43
+ slidesExportFilename?: string
44
+ slidesSessionSeed: string
45
+ drawStorageKey: string
46
+ session: PresentationSession
47
+ onSyncModeChange: (mode: PresentationSyncMode) => void
48
+ }) {
49
+ const navigation = useSlidesNavigation()
50
+ const currentSlide = slides[navigation.currentIndex]
51
+ const nextSlide = slides[navigation.currentIndex + 1] ?? null
52
+ const canControl = canControlNavigation(session)
53
+ const isPresenterRole = session.role === "presenter"
54
+ const canOpenOverview = canControl || session.role === "viewer"
55
+
56
+ const flow = usePresentationFlowRuntime({ slides, navigation })
57
+ const chrome = usePresenterChromeRuntime({ canControl, canOpenOverview, isPresenterRole })
58
+ const wakeLock = useWakeLock()
59
+ const fullscreen = useFullscreen()
60
+
61
+ const sessionState = usePresenterSessionState({
62
+ slides,
63
+ session,
64
+ navigation,
65
+ flow,
66
+ canControl,
67
+ slidesExportFilename,
68
+ slidesTitle,
69
+ })
70
+
71
+ const handleViewerAdvance = useCallback(() => {
72
+ sessionState.detachFromPresenter()
73
+ flow.advanceReveal()
74
+ }, [sessionState.detachFromPresenter, flow.advanceReveal])
75
+
76
+ const handleViewerRetreat = useCallback(() => {
77
+ sessionState.detachFromPresenter()
78
+ flow.retreatReveal()
79
+ }, [sessionState.detachFromPresenter, flow.retreatReveal])
80
+
81
+ const handleViewerFirst = useCallback(() => {
82
+ sessionState.detachFromPresenter()
83
+ flow.goToSlideAtStart(0)
84
+ }, [sessionState.detachFromPresenter, flow.goToSlideAtStart])
85
+
86
+ const handleViewerLast = useCallback(() => {
87
+ sessionState.detachFromPresenter()
88
+ flow.goToSlideAtStart(Math.max(navigation.total - 1, 0))
89
+ }, [sessionState.detachFromPresenter, flow.goToSlideAtStart, navigation.total])
90
+
91
+ const handleEnterPresenterMode = useCallback(() => {
92
+ const entryUrl = buildPresentationEntryUrl("presenter", slidesSessionSeed)
93
+ if (!entryUrl) return
94
+
95
+ window.location.assign(entryUrl)
96
+ }, [slidesSessionSeed])
97
+
98
+ const handleOpenPrintExport = useCallback(() => {
99
+ const exportUrl = buildPrintExportUrl(window.location.href)
100
+ const exportWindow = window.open(exportUrl, "_blank")
101
+ if (exportWindow) {
102
+ exportWindow.opener = null
103
+ return
104
+ }
105
+
106
+ window.location.assign(exportUrl)
107
+ }, [])
108
+
109
+ const handleOpenMirrorStage = useCallback(() => {
110
+ const targetUrl = session.viewerUrl
111
+ if (!targetUrl) return
112
+
113
+ const mirrorWindow = window.open(targetUrl, "_blank", "noopener,noreferrer")
114
+ if (mirrorWindow) {
115
+ mirrorWindow.opener = null
116
+ return
117
+ }
118
+
119
+ window.location.assign(targetUrl)
120
+ }, [session.viewerUrl])
121
+
122
+ const progressPercent =
123
+ navigation.total > 0 ? ((navigation.currentIndex + 1) / navigation.total) * 100 : 0
124
+
125
+ return (
126
+ <>
127
+ <RevealProvider value={flow.revealContextValue}>
128
+ <KeyboardController
129
+ enabled={canControl || session.role === "viewer"}
130
+ overlayOpen={Boolean(chrome.activeOverlay)}
131
+ onAdvance={!canControl ? handleViewerAdvance : undefined}
132
+ onRetreat={!canControl ? handleViewerRetreat : undefined}
133
+ onFirst={!canControl ? handleViewerFirst : undefined}
134
+ onLast={!canControl ? handleViewerLast : undefined}
135
+ />
136
+ </RevealProvider>
137
+ <DrawProvider
138
+ currentSlideId={currentSlide.id}
139
+ storageKey={drawStorageKey}
140
+ readOnly={!canControl}
141
+ overlayOpen={Boolean(chrome.activeOverlay)}
142
+ remoteStrokes={canControl ? null : sessionState.remoteDrawings}
143
+ onStrokesChange={sessionState.onStrokesChange}
144
+ >
145
+ <div
146
+ className={`relative grid h-dvh max-h-dvh grid-cols-1 grid-rows-[minmax(0,1fr)] overflow-hidden ${
147
+ isPresenterRole ? "bg-slate-50" : "bg-black"
148
+ } ${chrome.hideCursor ? "cursor-none" : ""}`}
149
+ >
150
+ {isPresenterRole && (
151
+ <>
152
+ <div className="pointer-events-none absolute inset-0 bg-slate-50" />
153
+ <PresenterTopProgress total={navigation.total} progressPercent={progressPercent} />
154
+ </>
155
+ )}
156
+ {isPresenterRole && (
157
+ <PresentationStatus
158
+ slideId={currentSlide.id}
159
+ session={session}
160
+ sync={sessionState.sync}
161
+ recorder={sessionState.recorder}
162
+ wakeLock={wakeLock}
163
+ fullscreen={fullscreen}
164
+ chrome={{
165
+ stageScale: chrome.stageScale,
166
+ cursorMode: chrome.cursorMode,
167
+ timelinePreviewOpen: chrome.timelinePreviewOpen,
168
+ overviewOpen: chrome.overviewOpen,
169
+ notesOpen: chrome.notesOverviewOpen,
170
+ shortcutsOpen: chrome.shortcutsHelpOpen,
171
+ canOpenOverview,
172
+ onToggleTimelinePreview: chrome.toggleTimelinePreview,
173
+ onToggleOverview: chrome.toggleOverview,
174
+ onToggleNotes: chrome.toggleNotes,
175
+ onToggleShortcuts: chrome.toggleShortcuts,
176
+ onStageScaleChange: chrome.handleStageScaleChange,
177
+ onCursorModeChange: chrome.handleCursorModeChange,
178
+ }}
179
+ sessionTimerSeconds={canControl ? sessionState.localTimer : sessionState.remoteTimer}
180
+ canRecord={canControl}
181
+ onOpenMirrorStage={handleOpenMirrorStage}
182
+ onOpenPrintExport={handleOpenPrintExport}
183
+ onSyncModeChange={onSyncModeChange}
184
+ />
185
+ )}
186
+ <div
187
+ style={
188
+ isPresenterRole ? { paddingBottom: `${PRESENTER_BOTTOM_BAR_CLEARANCE}px` } : undefined
189
+ }
190
+ className={`relative min-h-0 min-w-0 size-full ${isPresenterRole ? "px-0 pb-0 pt-0 lg:px-0" : ""}`}
191
+ >
192
+ {isPresenterRole ? (
193
+ <PresenterModeView
194
+ currentSlide={currentSlide}
195
+ nextSlide={nextSlide}
196
+ slidesConfig={slidesConfig}
197
+ canControl={canControl}
198
+ remoteCursor={sessionState.remoteCursor}
199
+ localCursor={sessionState.localCursor}
200
+ setLocalCursor={sessionState.setLocalCursor}
201
+ flow={flow}
202
+ chrome={chrome}
203
+ navigation={navigation}
204
+ />
205
+ ) : (
206
+ <StandaloneModeView
207
+ currentSlide={currentSlide}
208
+ slidesConfig={slidesConfig}
209
+ canControl={canControl}
210
+ remoteCursor={sessionState.remoteCursor}
211
+ setLocalCursor={sessionState.setLocalCursor}
212
+ flow={flow}
213
+ />
214
+ )}
215
+ </div>
216
+ {isPresenterRole && chrome.timelinePreviewOpen && (
217
+ <div
218
+ className="absolute inset-x-4 z-30 flex justify-center"
219
+ style={{ bottom: `${PRESENTER_BOTTOM_BAR_CLEARANCE + 16}px` }}
220
+ >
221
+ <FlowTimelinePreview
222
+ slide={currentSlide}
223
+ currentClicks={flow.currentClicks}
224
+ currentClicksTotal={flow.currentClicksTotal}
225
+ slidesConfig={slidesConfig}
226
+ onJumpToCue={(cueIndex) => flow.setSlideClicks(currentSlide.id, cueIndex)}
227
+ onClose={chrome.closeOverlay}
228
+ className="w-full max-w-[min(920px,calc(100vw-2rem))] max-h-[min(60vh,700px)]"
229
+ />
230
+ </div>
231
+ )}
232
+ {!isPresenterRole && (
233
+ <PresentationNavbar
234
+ slideTitle={currentSlide.meta.title}
235
+ currentIndex={navigation.currentIndex}
236
+ total={navigation.total}
237
+ canPrev={flow.canPrev}
238
+ canNext={flow.canNext}
239
+ showPresenterModeButton={session.role !== "presenter"}
240
+ overviewOpen={chrome.overviewOpen}
241
+ notesOpen={chrome.notesOverviewOpen}
242
+ shortcutsOpen={chrome.shortcutsHelpOpen}
243
+ canOpenOverview={canOpenOverview}
244
+ onEnterPresenterMode={
245
+ session.role !== "presenter" ? handleEnterPresenterMode : undefined
246
+ }
247
+ onToggleOverview={chrome.toggleOverview}
248
+ onToggleNotes={chrome.toggleNotes}
249
+ onToggleShortcuts={chrome.toggleShortcuts}
250
+ onPrev={flow.retreatReveal}
251
+ onNext={flow.advanceReveal}
252
+ canControl={canControl}
253
+ />
254
+ )}
255
+ <QuickOverview
256
+ open={chrome.overviewOpen && canOpenOverview}
257
+ slides={slides}
258
+ currentIndex={navigation.currentIndex}
259
+ slidesConfig={slidesConfig}
260
+ onClose={chrome.closeOverlay}
261
+ onSelect={(index) => {
262
+ if (!canControl) sessionState.detachFromPresenter()
263
+ flow.goToSlideAtStart(index)
264
+ chrome.closeOverlay()
265
+ }}
266
+ />
267
+ <NotesOverview
268
+ open={chrome.notesOverviewOpen && canControl}
269
+ slides={slides}
270
+ currentIndex={navigation.currentIndex}
271
+ onClose={chrome.closeOverlay}
272
+ onSelect={(index) => {
273
+ flow.goToSlideAtStart(index)
274
+ chrome.closeOverlay()
275
+ }}
276
+ />
277
+ <ShortcutsHelpOverlay
278
+ open={chrome.shortcutsHelpOpen}
279
+ sections={chrome.shortcutHelpSections}
280
+ onClose={chrome.closeOverlay}
281
+ />
282
+ </div>
283
+ </DrawProvider>
284
+ </>
285
+ )
286
+ }
@@ -0,0 +1,68 @@
1
+ import type { CompiledSlide, SlidesConfig } from "./types";
2
+ import { ChromePanel, chromePanelClassName } from "../../../ui/primitives/ChromePanel";
3
+ import { ChromeTag } from "../../../ui/primitives/ChromeTag";
4
+ import { SlidePreviewSurface } from "../stage/SlidePreviewSurface";
5
+
6
+ export function PresenterSidePreview({
7
+ title,
8
+ indexLabel,
9
+ slide,
10
+ slidesConfig,
11
+ }: {
12
+ title: string;
13
+ indexLabel: string;
14
+ slide: CompiledSlide | null;
15
+ slidesConfig: Pick<SlidesConfig, "slidesViewport" | "slidesLayout" | "slidesBackground">;
16
+ }) {
17
+ if (!slide) {
18
+ return (
19
+ <ChromePanel className="flex h-full min-h-0 min-w-0 flex-col gap-3">
20
+ <div className="flex items-center justify-between gap-2">
21
+ <p className="text-xs font-semibold uppercase tracking-[0.22em] text-emerald-700">
22
+ {title}
23
+ </p>
24
+ <ChromeTag tone="active" weight="semibold">
25
+ {indexLabel}
26
+ </ChromeTag>
27
+ </div>
28
+ <ChromePanel
29
+ as="div"
30
+ tone="dashed"
31
+ radius="frame"
32
+ className="grid min-h-[180px] min-w-0 w-full flex-1 place-items-center overflow-hidden text-sm"
33
+ >
34
+ End of slides
35
+ </ChromePanel>
36
+ </ChromePanel>
37
+ );
38
+ }
39
+
40
+ return (
41
+ <ChromePanel className="flex h-full min-h-0 min-w-0 flex-col gap-3">
42
+ <div className="flex items-center justify-between gap-2">
43
+ <p className="text-xs font-semibold uppercase tracking-[0.22em] text-emerald-700">
44
+ {title}
45
+ </p>
46
+ <ChromeTag tone="active" weight="semibold">
47
+ {indexLabel}
48
+ </ChromeTag>
49
+ </div>
50
+ <SlidePreviewSurface
51
+ Slide={slide.component}
52
+ meta={slide.meta}
53
+ slidesConfig={slidesConfig}
54
+ alignment="top-left"
55
+ viewportClassName={chromePanelClassName({
56
+ tone: "frame",
57
+ radius: "frame",
58
+ padding: "none",
59
+ className: "relative min-h-[180px] min-w-0 w-full flex-1 overflow-hidden",
60
+ })}
61
+ stageClassName="text-black"
62
+ />
63
+ <p className="shrink-0 truncate text-base font-semibold text-slate-900">
64
+ {slide.meta.title ?? "Untitled"}
65
+ </p>
66
+ </ChromePanel>
67
+ );
68
+ }
@@ -0,0 +1,28 @@
1
+ export function PresenterTopProgress({
2
+ total,
3
+ progressPercent,
4
+ }: {
5
+ total: number;
6
+ progressPercent: number;
7
+ }) {
8
+ const segmentCount = Math.max(total, 1);
9
+
10
+ return (
11
+ <div className="pointer-events-none absolute inset-x-0 top-0 z-30">
12
+ <div className="relative h-[4px] w-full overflow-hidden bg-white/30">
13
+ <div
14
+ className="absolute inset-y-0 left-0 bg-[linear-gradient(90deg,#86efac_0%,#22c55e_46%,#15803d_100%)] transition-[width] duration-300"
15
+ style={{ width: `${progressPercent}%` }}
16
+ />
17
+ <div
18
+ className="absolute inset-0 grid gap-px bg-white/12"
19
+ style={{ gridTemplateColumns: `repeat(${segmentCount}, minmax(0, 1fr))` }}
20
+ >
21
+ {Array.from({ length: segmentCount }, (_, index) => (
22
+ <span key={index} aria-hidden className="bg-transparent" />
23
+ ))}
24
+ </div>
25
+ </div>
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,51 @@
1
+ import { ChromePanel } from "../../../ui/primitives/ChromePanel";
2
+ import { ChromeTag } from "../../../ui/primitives/ChromeTag";
3
+
4
+ function renderNotes(notes: string) {
5
+ const sections = notes
6
+ .split(/\n\s*\n/g)
7
+ .map((section) => section.trim())
8
+ .filter(Boolean);
9
+
10
+ return sections.map((section, index) => (
11
+ <p key={`${index}-${section.slice(0, 24)}`} className="whitespace-pre-wrap">
12
+ {section}
13
+ </p>
14
+ ));
15
+ }
16
+
17
+ export function SpeakerNotesPanel({
18
+ currentClicks,
19
+ currentClicksTotal,
20
+ notes,
21
+ }: {
22
+ currentClicks: number;
23
+ currentClicksTotal: number;
24
+ notes?: string;
25
+ }) {
26
+ return (
27
+ <ChromePanel className="flex flex-col">
28
+ <div className="mb-3 flex items-center justify-between gap-3">
29
+ <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500">
30
+ Notes
31
+ </p>
32
+ <ChromeTag>
33
+ {currentClicksTotal > 0 ? `Clicks ${currentClicks}/${currentClicksTotal}` : "Slide cue"}
34
+ </ChromeTag>
35
+ </div>
36
+ <ChromePanel tone="inset" radius="frame" className="flex-1 p-4 text-sm leading-7">
37
+ {notes ? (
38
+ <div className="space-y-4 text-slate-600">{renderNotes(notes)}</div>
39
+ ) : (
40
+ <>
41
+ <p className="font-medium text-slate-900">No notes yet.</p>
42
+ <p className="mt-3 text-slate-500">
43
+ Add slide-level frontmatter with <code>notes: |</code> to keep your phrasing,
44
+ punchlines, and handoff lines close to the slide.
45
+ </p>
46
+ </>
47
+ )}
48
+ </ChromePanel>
49
+ </ChromePanel>
50
+ );
51
+ }
@@ -0,0 +1,36 @@
1
+ import type { PresentationCursorState } from "../types"
2
+ import { RevealProvider } from "../reveal/RevealContext"
3
+ import { SlideStage } from "../stage/SlideStage"
4
+ import type { CompiledSlide, SlidesConfig } from "./types"
5
+ import type { PresentationFlowRuntime } from "./usePresentationFlowRuntime"
6
+
7
+ export function StandaloneModeView({
8
+ currentSlide,
9
+ slidesConfig,
10
+ canControl,
11
+ remoteCursor,
12
+ setLocalCursor,
13
+ flow,
14
+ }: {
15
+ currentSlide: CompiledSlide
16
+ slidesConfig: SlidesConfig
17
+ canControl: boolean
18
+ remoteCursor: PresentationCursorState | null
19
+ setLocalCursor: (cursor: PresentationCursorState | null) => void
20
+ flow: PresentationFlowRuntime
21
+ }) {
22
+ const CurrentSlide = currentSlide.component
23
+
24
+ return (
25
+ <RevealProvider value={flow.revealContextValue}>
26
+ <SlideStage
27
+ Slide={CurrentSlide}
28
+ slideId={currentSlide.id}
29
+ meta={currentSlide.meta}
30
+ slidesConfig={slidesConfig}
31
+ remoteCursor={canControl ? null : remoteCursor}
32
+ onCursorChange={canControl ? setLocalCursor : undefined}
33
+ />
34
+ </RevealProvider>
35
+ )
36
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ parsePersistedPresenterCursorMode,
4
+ parsePersistedPresenterSidebarWidth,
5
+ parsePersistedPresenterStageScale,
6
+ } from "./persistence";
7
+
8
+ describe("presenter persistence", () => {
9
+ it("parses allowed stage scale values", () => {
10
+ expect(parsePersistedPresenterStageScale("1.08")).toBe(1.08);
11
+ expect(parsePersistedPresenterStageScale("1")).toBe(1);
12
+ expect(parsePersistedPresenterStageScale("0.95")).toBeNull();
13
+ });
14
+
15
+ it("parses cursor mode values", () => {
16
+ expect(parsePersistedPresenterCursorMode("always")).toBe("always");
17
+ expect(parsePersistedPresenterCursorMode("idle-hide")).toBe("idle-hide");
18
+ expect(parsePersistedPresenterCursorMode("hidden")).toBeNull();
19
+ });
20
+
21
+ it("parses finite sidebar widths only", () => {
22
+ expect(parsePersistedPresenterSidebarWidth("320")).toBe(320);
23
+ expect(parsePersistedPresenterSidebarWidth("Infinity")).toBeNull();
24
+ expect(parsePersistedPresenterSidebarWidth("abc")).toBeNull();
25
+ });
26
+ });
@@ -0,0 +1,31 @@
1
+ import { z } from "zod";
2
+ import type { PresenterCursorMode } from "./usePresenterChromeRuntime";
3
+
4
+ export const PRESENTER_STAGE_SCALE_STORAGE_KEY = "slide-react:presenter-stage-scale";
5
+ export const PRESENTER_CURSOR_MODE_STORAGE_KEY = "slide-react:presenter-cursor-mode";
6
+ export const PRESENTER_SIDEBAR_WIDTH_STORAGE_KEY = "slide-react:presenter-sidebar-width";
7
+
8
+ const presenterStageScaleSchema = z.union([z.literal(0.9), z.literal(1), z.literal(1.08)]);
9
+ const presenterCursorModeSchema = z.enum(["always", "idle-hide"]);
10
+ const presenterSidebarWidthSchema = z.number().finite();
11
+
12
+ export function parsePersistedPresenterStageScale(raw: string | null) {
13
+ if (!raw) return null;
14
+
15
+ const value = Number(raw);
16
+ const result = presenterStageScaleSchema.safeParse(value);
17
+ return result.success ? result.data : null;
18
+ }
19
+
20
+ export function parsePersistedPresenterCursorMode(raw: string | null): PresenterCursorMode | null {
21
+ const result = presenterCursorModeSchema.safeParse(raw);
22
+ return result.success ? result.data : null;
23
+ }
24
+
25
+ export function parsePersistedPresenterSidebarWidth(raw: string | null) {
26
+ if (!raw) return null;
27
+
28
+ const value = Number(raw);
29
+ const result = presenterSidebarWidthSchema.safeParse(value);
30
+ return result.success ? result.data : null;
31
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { buildPresentationSharedState, mapRemotePresentationPatch } from "./presentationSyncBridge";
3
+
4
+ describe("presentationSyncBridge", () => {
5
+ it("builds a shared state payload without mutating timestamps", () => {
6
+ expect(
7
+ buildPresentationSharedState({
8
+ page: 2,
9
+ cue: 1,
10
+ cueTotal: 3,
11
+ timer: 9,
12
+ cursor: { x: 10, y: 20 },
13
+ drawings: {
14
+ "slide-1": [],
15
+ },
16
+ drawingsRevision: 4,
17
+ }),
18
+ ).toEqual({
19
+ page: 2,
20
+ cue: 1,
21
+ cueTotal: 3,
22
+ timer: 9,
23
+ cursor: { x: 10, y: 20 },
24
+ drawings: {
25
+ "slide-1": [],
26
+ },
27
+ drawingsRevision: 4,
28
+ lastUpdate: 0,
29
+ });
30
+ });
31
+
32
+ it("maps remote patches back to presenter-side effects", () => {
33
+ vi.useFakeTimers();
34
+ vi.setSystemTime(new Date("2026-03-08T10:00:00.000Z"));
35
+
36
+ expect(
37
+ mapRemotePresentationPatch({
38
+ patch: {
39
+ timer: 12,
40
+ cursor: { x: 4, y: 8 },
41
+ cue: 2,
42
+ cueTotal: 5,
43
+ drawings: {
44
+ "slide-2": [],
45
+ },
46
+ },
47
+ remotePage: 1,
48
+ currentPage: 1,
49
+ resolveSlideId: (index) => `slide-${index + 1}`,
50
+ }),
51
+ ).toEqual({
52
+ remoteTimer: 12,
53
+ remoteCursor: { x: 4, y: 8 },
54
+ slideClicks: {
55
+ slideId: "slide-2",
56
+ clicks: 2,
57
+ },
58
+ slideClicksTotal: {
59
+ slideId: "slide-2",
60
+ clicksTotal: 5,
61
+ },
62
+ remoteDrawings: {
63
+ revision: new Date("2026-03-08T10:00:00.000Z").getTime(),
64
+ strokesBySlideId: {
65
+ "slide-2": [],
66
+ },
67
+ },
68
+ });
69
+
70
+ vi.useRealTimers();
71
+ });
72
+
73
+ it("clears remote cursors when the remote page differs from the current page", () => {
74
+ expect(
75
+ mapRemotePresentationPatch({
76
+ patch: {
77
+ cursor: { x: 1, y: 1 },
78
+ },
79
+ remotePage: 2,
80
+ currentPage: 1,
81
+ resolveSlideId: () => null,
82
+ }),
83
+ ).toEqual({
84
+ remoteCursor: null,
85
+ });
86
+ });
87
+ });