@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,358 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type KeyboardEvent as ReactKeyboardEvent,
8
+ type PointerEvent as ReactPointerEvent,
9
+ } from "react";
10
+ import {
11
+ buildShortcutHelpSections,
12
+ createShortcutHelpTriggerState,
13
+ isShortcutHelpOpenKey,
14
+ registerShortcutHelpKeyDown,
15
+ registerShortcutHelpKeyUp,
16
+ } from "../navigation/keyboardShortcuts";
17
+ import { useIdleCursor } from "./useIdleCursor";
18
+ import { isTypingElement } from "../browser";
19
+ import {
20
+ parsePersistedPresenterCursorMode,
21
+ parsePersistedPresenterSidebarWidth,
22
+ parsePersistedPresenterStageScale,
23
+ PRESENTER_CURSOR_MODE_STORAGE_KEY,
24
+ PRESENTER_SIDEBAR_WIDTH_STORAGE_KEY,
25
+ PRESENTER_STAGE_SCALE_STORAGE_KEY,
26
+ } from "./persistence";
27
+
28
+ const PRESENTER_SIDEBAR_WIDTH_MIN = 280;
29
+ const PRESENTER_SIDEBAR_WIDTH_MAX = 420;
30
+ const PRESENTER_STAGE_MIN_WIDTH = 720;
31
+ const PRESENTER_DIVIDER_WIDTH = 10;
32
+ const PRESENTER_DESKTOP_BREAKPOINT = 1024;
33
+
34
+ export type PresenterCursorMode = "always" | "idle-hide";
35
+
36
+ export type PresenterOverlay =
37
+ | "quick-overview"
38
+ | "notes-overview"
39
+ | "timeline-preview"
40
+ | "shortcuts-help"
41
+ | null;
42
+
43
+ function readInitialStageScale() {
44
+ try {
45
+ return parsePersistedPresenterStageScale(
46
+ window.localStorage.getItem(PRESENTER_STAGE_SCALE_STORAGE_KEY),
47
+ ) ?? 1;
48
+ } catch {
49
+ return 1;
50
+ }
51
+ }
52
+
53
+ function readInitialCursorMode(): PresenterCursorMode {
54
+ try {
55
+ return (
56
+ parsePersistedPresenterCursorMode(
57
+ window.localStorage.getItem(PRESENTER_CURSOR_MODE_STORAGE_KEY),
58
+ ) ?? "always"
59
+ );
60
+ } catch {
61
+ return "always";
62
+ }
63
+ }
64
+
65
+ function clampPresenterSidebarWidth(value: number, containerWidth: number) {
66
+ const maxWidth = Math.min(
67
+ PRESENTER_SIDEBAR_WIDTH_MAX,
68
+ Math.max(
69
+ PRESENTER_SIDEBAR_WIDTH_MIN,
70
+ containerWidth - PRESENTER_STAGE_MIN_WIDTH - PRESENTER_DIVIDER_WIDTH,
71
+ ),
72
+ );
73
+
74
+ return Math.min(Math.max(Math.round(value), PRESENTER_SIDEBAR_WIDTH_MIN), maxWidth);
75
+ }
76
+
77
+ function readInitialSidebarWidth() {
78
+ try {
79
+ const parsedValue = parsePersistedPresenterSidebarWidth(
80
+ window.localStorage.getItem(PRESENTER_SIDEBAR_WIDTH_STORAGE_KEY),
81
+ );
82
+ if (parsedValue !== null) return clampPresenterSidebarWidth(parsedValue, window.innerWidth);
83
+ } catch {
84
+ // Ignore storage read failures.
85
+ }
86
+
87
+ return clampPresenterSidebarWidth(window.innerWidth * 0.23, window.innerWidth);
88
+ }
89
+
90
+ export function usePresenterChromeRuntime({
91
+ canControl,
92
+ canOpenOverview,
93
+ isPresenterRole,
94
+ }: {
95
+ canControl: boolean;
96
+ canOpenOverview: boolean;
97
+ isPresenterRole: boolean;
98
+ }) {
99
+ const [activeOverlay, setActiveOverlay] = useState<PresenterOverlay>(null);
100
+ const [stageScale, setStageScale] = useState(readInitialStageScale);
101
+ const [cursorMode, setCursorMode] = useState<PresenterCursorMode>(readInitialCursorMode);
102
+ const [sidebarWidth, setSidebarWidth] = useState(readInitialSidebarWidth);
103
+ const [isWidePresenterLayout, setIsWidePresenterLayout] = useState(
104
+ () => window.innerWidth >= PRESENTER_DESKTOP_BREAKPOINT,
105
+ );
106
+ const [isResizingSidebar, setIsResizingSidebar] = useState(false);
107
+ const shortcutHelpTriggerRef = useRef(createShortcutHelpTriggerState());
108
+ const presenterLayoutRef = useRef<HTMLDivElement | null>(null);
109
+
110
+ useEffect(() => {
111
+ try {
112
+ window.localStorage.setItem(PRESENTER_STAGE_SCALE_STORAGE_KEY, String(stageScale));
113
+ } catch {
114
+ // Ignore storage write failures.
115
+ }
116
+ }, [stageScale]);
117
+
118
+ useEffect(() => {
119
+ try {
120
+ window.localStorage.setItem(PRESENTER_CURSOR_MODE_STORAGE_KEY, cursorMode);
121
+ } catch {
122
+ // Ignore storage write failures.
123
+ }
124
+ }, [cursorMode]);
125
+
126
+ useEffect(() => {
127
+ try {
128
+ window.localStorage.setItem(PRESENTER_SIDEBAR_WIDTH_STORAGE_KEY, String(sidebarWidth));
129
+ } catch {
130
+ // Ignore storage write failures.
131
+ }
132
+ }, [sidebarWidth]);
133
+
134
+ useEffect(() => {
135
+ const updatePresenterLayoutMode = () => {
136
+ setIsWidePresenterLayout(window.innerWidth >= PRESENTER_DESKTOP_BREAKPOINT);
137
+
138
+ const containerWidth =
139
+ presenterLayoutRef.current?.getBoundingClientRect().width ?? window.innerWidth;
140
+ setSidebarWidth((currentWidth) => clampPresenterSidebarWidth(currentWidth, containerWidth));
141
+ };
142
+
143
+ updatePresenterLayoutMode();
144
+ window.addEventListener("resize", updatePresenterLayoutMode);
145
+ return () => window.removeEventListener("resize", updatePresenterLayoutMode);
146
+ }, []);
147
+
148
+ const setSidebarWidthFromPointer = useCallback((clientX: number) => {
149
+ const bounds = presenterLayoutRef.current?.getBoundingClientRect();
150
+ if (!bounds) return;
151
+
152
+ const nextWidth = clampPresenterSidebarWidth(bounds.right - clientX, bounds.width);
153
+ setSidebarWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth));
154
+ }, []);
155
+
156
+ useEffect(() => {
157
+ if (!isResizingSidebar) return;
158
+
159
+ const handlePointerMove = (event: PointerEvent) => {
160
+ setSidebarWidthFromPointer(event.clientX);
161
+ };
162
+
163
+ const handlePointerUp = () => {
164
+ setIsResizingSidebar(false);
165
+ };
166
+
167
+ window.addEventListener("pointermove", handlePointerMove);
168
+ window.addEventListener("pointerup", handlePointerUp);
169
+ document.body.style.cursor = "col-resize";
170
+ document.body.style.userSelect = "none";
171
+
172
+ return () => {
173
+ window.removeEventListener("pointermove", handlePointerMove);
174
+ window.removeEventListener("pointerup", handlePointerUp);
175
+ document.body.style.cursor = "";
176
+ document.body.style.userSelect = "";
177
+ };
178
+ }, [isResizingSidebar, setSidebarWidthFromPointer]);
179
+
180
+ const handleSidebarResizeStart = useCallback(
181
+ (event: ReactPointerEvent<HTMLDivElement>) => {
182
+ if (event.button !== 0) return;
183
+
184
+ event.preventDefault();
185
+ setSidebarWidthFromPointer(event.clientX);
186
+ setIsResizingSidebar(true);
187
+ },
188
+ [setSidebarWidthFromPointer],
189
+ );
190
+
191
+ const handleSidebarResizeKeyDown = useCallback(
192
+ (event: ReactKeyboardEvent<HTMLDivElement>) => {
193
+ if (!isWidePresenterLayout) return;
194
+
195
+ let delta = 0;
196
+ if (event.key === "ArrowLeft") delta = 16;
197
+ if (event.key === "ArrowRight") delta = -16;
198
+ if (event.key === "Home") delta = PRESENTER_SIDEBAR_WIDTH_MIN - sidebarWidth;
199
+ if (event.key === "End") delta = PRESENTER_SIDEBAR_WIDTH_MAX - sidebarWidth;
200
+ if (!delta) return;
201
+
202
+ event.preventDefault();
203
+ const containerWidth =
204
+ presenterLayoutRef.current?.getBoundingClientRect().width ?? window.innerWidth;
205
+ setSidebarWidth((currentWidth) =>
206
+ clampPresenterSidebarWidth(currentWidth + delta, containerWidth),
207
+ );
208
+ },
209
+ [isWidePresenterLayout, sidebarWidth],
210
+ );
211
+
212
+ const toggleOverlay = useCallback((overlay: Exclude<PresenterOverlay, null>) => {
213
+ setActiveOverlay((value) => (value === overlay ? null : overlay));
214
+ }, []);
215
+
216
+ const closeOverlay = useCallback(() => {
217
+ setActiveOverlay(null);
218
+ }, []);
219
+
220
+ useEffect(() => {
221
+ const onKeyDown = (event: KeyboardEvent) => {
222
+ if (isTypingElement(event.target)) return;
223
+
224
+ if (!event.repeat) {
225
+ shortcutHelpTriggerRef.current = registerShortcutHelpKeyDown(
226
+ shortcutHelpTriggerRef.current,
227
+ event.key,
228
+ );
229
+ }
230
+
231
+ const key = event.key.toLowerCase();
232
+
233
+ if (
234
+ isShortcutHelpOpenKey({
235
+ key: event.key,
236
+ shiftKey: event.shiftKey,
237
+ metaKey: event.metaKey,
238
+ ctrlKey: event.ctrlKey,
239
+ altKey: event.altKey,
240
+ })
241
+ ) {
242
+ event.preventDefault();
243
+ toggleOverlay("shortcuts-help");
244
+ return;
245
+ }
246
+
247
+ if (key === "o") {
248
+ if (!canOpenOverview) return;
249
+
250
+ event.preventDefault();
251
+ toggleOverlay("quick-overview");
252
+ return;
253
+ }
254
+
255
+ if (key === "n") {
256
+ if (!canControl) return;
257
+
258
+ event.preventDefault();
259
+ toggleOverlay("notes-overview");
260
+ return;
261
+ }
262
+
263
+ if (key === "escape") closeOverlay();
264
+ };
265
+
266
+ const onKeyUp = (event: KeyboardEvent) => {
267
+ if (isTypingElement(event.target)) return;
268
+
269
+ const result = registerShortcutHelpKeyUp(
270
+ shortcutHelpTriggerRef.current,
271
+ event.key,
272
+ Date.now(),
273
+ );
274
+ shortcutHelpTriggerRef.current = result.nextState;
275
+
276
+ if (!result.shouldToggle) return;
277
+
278
+ toggleOverlay("shortcuts-help");
279
+ };
280
+
281
+ const onBlur = () => {
282
+ shortcutHelpTriggerRef.current = createShortcutHelpTriggerState();
283
+ };
284
+
285
+ window.addEventListener("keydown", onKeyDown);
286
+ window.addEventListener("keyup", onKeyUp);
287
+ window.addEventListener("blur", onBlur);
288
+ return () => {
289
+ window.removeEventListener("keydown", onKeyDown);
290
+ window.removeEventListener("keyup", onKeyUp);
291
+ window.removeEventListener("blur", onBlur);
292
+ };
293
+ }, [canControl, canOpenOverview, closeOverlay, toggleOverlay]);
294
+
295
+ const handleStageScaleChange = useCallback((value: number) => {
296
+ setStageScale(parsePersistedPresenterStageScale(String(value)) ?? 1);
297
+ }, []);
298
+
299
+ const handleCursorModeChange = useCallback((value: PresenterCursorMode) => {
300
+ setCursorMode(parsePersistedPresenterCursorMode(value) ?? "always");
301
+ }, []);
302
+
303
+ const presenterLayoutStyle = useMemo(
304
+ () =>
305
+ isWidePresenterLayout
306
+ ? {
307
+ gridTemplateColumns: `minmax(0, 1fr) ${PRESENTER_DIVIDER_WIDTH}px ${sidebarWidth}px`,
308
+ }
309
+ : undefined,
310
+ [isWidePresenterLayout, sidebarWidth],
311
+ );
312
+
313
+ const shortcutHelpSections = useMemo(
314
+ () =>
315
+ buildShortcutHelpSections({
316
+ canControl,
317
+ canOpenOverview,
318
+ }),
319
+ [canControl, canOpenOverview],
320
+ );
321
+
322
+ const hideCursor = useIdleCursor({
323
+ enabled: isPresenterRole && canControl && cursorMode === "idle-hide",
324
+ });
325
+
326
+ return {
327
+ activeOverlay,
328
+ stageScale,
329
+ cursorMode,
330
+ hideCursor,
331
+ presenterLayoutRef,
332
+ presenterLayoutStyle,
333
+ isResizingSidebar,
334
+ overviewOpen: activeOverlay === "quick-overview",
335
+ notesOverviewOpen: activeOverlay === "notes-overview",
336
+ shortcutsHelpOpen: activeOverlay === "shortcuts-help",
337
+ timelinePreviewOpen: activeOverlay === "timeline-preview",
338
+ shortcutHelpSections,
339
+ handleStageScaleChange,
340
+ handleCursorModeChange,
341
+ handleSidebarResizeStart,
342
+ handleSidebarResizeKeyDown,
343
+ setActiveOverlay,
344
+ toggleOverview: () => {
345
+ if (!canOpenOverview) return;
346
+
347
+ toggleOverlay("quick-overview");
348
+ },
349
+ toggleNotes: () => {
350
+ if (!canControl) return;
351
+
352
+ toggleOverlay("notes-overview");
353
+ },
354
+ toggleShortcuts: () => toggleOverlay("shortcuts-help"),
355
+ toggleTimelinePreview: () => toggleOverlay("timeline-preview"),
356
+ closeOverlay,
357
+ };
358
+ }
@@ -0,0 +1,226 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import type { DrawStroke } from "../draw/DrawProvider"
3
+ import { usePresentationSync } from "../usePresentationSync"
4
+ import { usePresentationRecorder } from "../usePresentationRecorder"
5
+ import type {
6
+ PresentationCursorState,
7
+ PresentationSharedState,
8
+ PresentationSyncMode,
9
+ } from "../types"
10
+ import type { PresentationSession } from "../session"
11
+ import { buildPresentationSharedState, mapRemotePresentationPatch } from "./presentationSyncBridge"
12
+ import type { CompiledSlide } from "./types"
13
+ import type { PresentationFlowRuntime } from "./usePresentationFlowRuntime"
14
+
15
+ export interface PresenterSessionState {
16
+ followPresenter: boolean
17
+ localCursor: PresentationCursorState | null
18
+ remoteCursor: PresentationCursorState | null
19
+ remotePageIndex: number | null
20
+ localTimer: number
21
+ remoteTimer: number
22
+ drawings: Record<string, DrawStroke[]>
23
+ remoteDrawings: {
24
+ revision: number
25
+ strokesBySlideId: Record<string, DrawStroke[]>
26
+ } | null
27
+ sync: ReturnType<typeof usePresentationSync>
28
+ recorder: ReturnType<typeof usePresentationRecorder>
29
+ onStrokesChange: (nextStrokes: Record<string, DrawStroke[]>) => void
30
+ setLocalCursor: (cursor: PresentationCursorState | null) => void
31
+ setRemoteCursor: (cursor: PresentationCursorState | null) => void
32
+ detachFromPresenter: () => void
33
+ }
34
+
35
+ export function usePresenterSessionState({
36
+ slides,
37
+ session,
38
+ navigation,
39
+ flow,
40
+ canControl,
41
+ slidesExportFilename,
42
+ slidesTitle,
43
+ }: {
44
+ slides: CompiledSlide[]
45
+ session: PresentationSession
46
+ navigation: { currentIndex: number; total: number; goTo: (index: number) => void }
47
+ flow: PresentationFlowRuntime
48
+ canControl: boolean
49
+ slidesExportFilename?: string
50
+ slidesTitle?: string
51
+ }): PresenterSessionState {
52
+ const [followPresenter, setFollowPresenter] = useState(session.role === "viewer")
53
+ const [localCursor, setLocalCursor] = useState<PresentationCursorState | null>(null)
54
+ const [remoteCursor, setRemoteCursor] = useState<PresentationCursorState | null>(null)
55
+ const [remotePageIndex, setRemotePageIndex] = useState<number | null>(null)
56
+ const [localTimer, setLocalTimer] = useState(0)
57
+ const [remoteTimer, setRemoteTimer] = useState(0)
58
+ const [drawings, setDrawings] = useState<Record<string, DrawStroke[]>>({})
59
+ const [drawingsRevision, setDrawingsRevision] = useState(0)
60
+ const [remoteDrawings, setRemoteDrawings] = useState<{
61
+ revision: number
62
+ strokesBySlideId: Record<string, DrawStroke[]>
63
+ } | null>(null)
64
+ const drawingsSyncFrameRef = useRef<number | null>(null)
65
+ const currentIndexRef = useRef(navigation.currentIndex)
66
+
67
+ useEffect(() => {
68
+ currentIndexRef.current = navigation.currentIndex
69
+ }, [navigation.currentIndex])
70
+
71
+ useEffect(() => {
72
+ setFollowPresenter(session.role === "viewer")
73
+ }, [session.role, session.sessionId])
74
+
75
+ useEffect(() => {
76
+ setRemotePageIndex(navigation.currentIndex)
77
+ }, [session.role, session.sessionId])
78
+
79
+ const scheduleDrawingsSync = useCallback(() => {
80
+ if (drawingsSyncFrameRef.current !== null) return
81
+
82
+ drawingsSyncFrameRef.current = window.requestAnimationFrame(() => {
83
+ drawingsSyncFrameRef.current = null
84
+ setDrawingsRevision((revision) => revision + 1)
85
+ })
86
+ }, [])
87
+
88
+ const onStrokesChange = useCallback(
89
+ (nextStrokes: Record<string, DrawStroke[]>) => {
90
+ setDrawings(nextStrokes)
91
+
92
+ if (!canControl) return
93
+
94
+ scheduleDrawingsSync()
95
+ },
96
+ [canControl, scheduleDrawingsSync],
97
+ )
98
+
99
+ const localSharedState = useMemo<PresentationSharedState>(
100
+ () =>
101
+ buildPresentationSharedState({
102
+ page: navigation.currentIndex,
103
+ cue: flow.currentClicks,
104
+ cueTotal: flow.currentClicksTotal,
105
+ timer: localTimer,
106
+ cursor: localCursor,
107
+ drawings,
108
+ drawingsRevision,
109
+ }),
110
+ [
111
+ flow.currentClicks,
112
+ flow.currentClicksTotal,
113
+ drawings,
114
+ drawingsRevision,
115
+ localCursor,
116
+ localTimer,
117
+ navigation.currentIndex,
118
+ ],
119
+ )
120
+
121
+ const sync = usePresentationSync({
122
+ session,
123
+ currentIndex: navigation.currentIndex,
124
+ total: navigation.total,
125
+ goTo: navigation.goTo,
126
+ followRemotePage: followPresenter,
127
+ localState: localSharedState,
128
+ onRemoteState: (patch, remotePage) => {
129
+ setRemotePageIndex(remotePage)
130
+ const effects = mapRemotePresentationPatch({
131
+ patch,
132
+ remotePage,
133
+ currentPage: currentIndexRef.current,
134
+ resolveSlideId: (index) => slides[index]?.id ?? null,
135
+ })
136
+
137
+ if (typeof effects.remoteTimer === "number") setRemoteTimer(effects.remoteTimer)
138
+
139
+ if ("remoteCursor" in effects) setRemoteCursor(effects.remoteCursor ?? null)
140
+
141
+ if (effects.slideClicksTotal)
142
+ flow.setSlideClicksTotal(
143
+ effects.slideClicksTotal.slideId,
144
+ effects.slideClicksTotal.clicksTotal,
145
+ )
146
+
147
+ if (effects.slideClicks)
148
+ flow.setSlideClicks(effects.slideClicks.slideId, effects.slideClicks.clicks)
149
+
150
+ if (effects.remoteDrawings) setRemoteDrawings(effects.remoteDrawings)
151
+ },
152
+ })
153
+
154
+ const recorder = usePresentationRecorder({
155
+ enabled: canControl,
156
+ exportFilename: slidesExportFilename,
157
+ slidesTitle,
158
+ })
159
+
160
+ const detachFromPresenter = useCallback(() => {
161
+ if (session.role !== "viewer") return
162
+
163
+ setFollowPresenter(false)
164
+ }, [session.role])
165
+
166
+ // Timer effect
167
+ useEffect(() => {
168
+ if (!canControl) {
169
+ setLocalTimer(0)
170
+ return
171
+ }
172
+
173
+ const startedAt = Date.now()
174
+ setLocalTimer(0)
175
+ const intervalId = window.setInterval(() => {
176
+ setLocalTimer(Math.floor((Date.now() - startedAt) / 1000))
177
+ }, 1000)
178
+
179
+ return () => {
180
+ window.clearInterval(intervalId)
181
+ }
182
+ }, [canControl, session.sessionId])
183
+
184
+ // Reset state on role change
185
+ useEffect(() => {
186
+ if (canControl) {
187
+ setRemoteCursor(null)
188
+ setRemoteTimer(0)
189
+ setRemoteDrawings(null)
190
+ } else {
191
+ setLocalCursor(null)
192
+ }
193
+ }, [canControl])
194
+
195
+ // Clear remote cursor when page changes
196
+ useEffect(() => {
197
+ if (remotePageIndex === navigation.currentIndex) return
198
+
199
+ setRemoteCursor(null)
200
+ }, [navigation.currentIndex, remotePageIndex])
201
+
202
+ // Cleanup animations on unmount
203
+ useEffect(() => {
204
+ return () => {
205
+ if (drawingsSyncFrameRef.current !== null)
206
+ window.cancelAnimationFrame(drawingsSyncFrameRef.current)
207
+ }
208
+ }, [])
209
+
210
+ return {
211
+ followPresenter,
212
+ localCursor,
213
+ remoteCursor,
214
+ remotePageIndex,
215
+ localTimer,
216
+ remoteTimer,
217
+ drawings,
218
+ remoteDrawings,
219
+ sync,
220
+ recorder,
221
+ onStrokesChange,
222
+ setLocalCursor,
223
+ setRemoteCursor,
224
+ detachFromPresenter,
225
+ }
226
+ }
@@ -0,0 +1,110 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ function isWakeLockSupported() {
4
+ return (
5
+ typeof navigator !== "undefined" &&
6
+ "wakeLock" in navigator &&
7
+ typeof navigator.wakeLock?.request === "function"
8
+ );
9
+ }
10
+
11
+ export function useWakeLock() {
12
+ const sentinelRef = useRef<WakeLockSentinel | null>(null);
13
+ const [requested, setRequested] = useState(false);
14
+ const [active, setActive] = useState(false);
15
+ const [error, setError] = useState<string | null>(null);
16
+
17
+ const release = useCallback(async () => {
18
+ setRequested(false);
19
+ setError(null);
20
+
21
+ const sentinel = sentinelRef.current;
22
+ sentinelRef.current = null;
23
+
24
+ if (!sentinel) {
25
+ setActive(false);
26
+ return;
27
+ }
28
+
29
+ try {
30
+ await sentinel.release();
31
+ } catch (releaseError) {
32
+ setError(releaseError instanceof Error ? releaseError.message : String(releaseError));
33
+ } finally {
34
+ setActive(false);
35
+ }
36
+ }, []);
37
+
38
+ const request = useCallback(async () => {
39
+ setRequested(true);
40
+ setError(null);
41
+
42
+ if (!isWakeLockSupported()) {
43
+ setActive(false);
44
+ setError("Wake lock is not supported in this browser.");
45
+ return;
46
+ }
47
+
48
+ try {
49
+ const sentinel = await navigator.wakeLock!.request("screen");
50
+ sentinelRef.current = sentinel;
51
+ setActive(!sentinel.released);
52
+
53
+ sentinel.addEventListener(
54
+ "release",
55
+ () => {
56
+ setActive(false);
57
+ sentinelRef.current = null;
58
+ },
59
+ { once: true },
60
+ );
61
+ } catch (requestError) {
62
+ setActive(false);
63
+ setError(requestError instanceof Error ? requestError.message : String(requestError));
64
+ }
65
+ }, []);
66
+
67
+ const toggle = useCallback(async () => {
68
+ if (requested || active) {
69
+ await release();
70
+ return;
71
+ }
72
+
73
+ await request();
74
+ }, [active, release, request, requested]);
75
+
76
+ useEffect(() => {
77
+ if (!requested || typeof document === "undefined") return;
78
+
79
+ const onVisibilityChange = () => {
80
+ if (document.visibilityState !== "visible") return;
81
+ if (!requested || sentinelRef.current) return;
82
+ void request();
83
+ };
84
+
85
+ document.addEventListener("visibilitychange", onVisibilityChange);
86
+ return () => {
87
+ document.removeEventListener("visibilitychange", onVisibilityChange);
88
+ };
89
+ }, [request, requested]);
90
+
91
+ useEffect(() => {
92
+ return () => {
93
+ const sentinel = sentinelRef.current;
94
+ sentinelRef.current = null;
95
+ if (sentinel) void sentinel.release();
96
+ };
97
+ }, []);
98
+
99
+ return {
100
+ supported: isWakeLockSupported(),
101
+ requested,
102
+ active,
103
+ error,
104
+ request,
105
+ release,
106
+ toggle,
107
+ };
108
+ }
109
+
110
+ export type WakeLockRuntime = ReturnType<typeof useWakeLock>;