@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,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolvePresentationSyncStatus } from "./status";
3
+
4
+ describe("resolvePresentationSyncStatus", () => {
5
+ it("returns disabled when the session is disabled", () => {
6
+ expect(
7
+ resolvePresentationSyncStatus({
8
+ sessionEnabled: false,
9
+ syncMode: "both",
10
+ sessionWsUrl: "ws://localhost:4860/ws",
11
+ transportState: "connected",
12
+ broadcastConnected: true,
13
+ }),
14
+ ).toBe("disabled");
15
+ });
16
+
17
+ it("treats websocket connections as connected when the socket is open", () => {
18
+ expect(
19
+ resolvePresentationSyncStatus({
20
+ sessionEnabled: true,
21
+ syncMode: "send",
22
+ sessionWsUrl: "ws://localhost:4860/ws",
23
+ transportState: "connected",
24
+ broadcastConnected: false,
25
+ }),
26
+ ).toBe("connected");
27
+ });
28
+
29
+ it("falls back to degraded when websocket is unavailable but broadcast still works", () => {
30
+ expect(
31
+ resolvePresentationSyncStatus({
32
+ sessionEnabled: true,
33
+ syncMode: "receive",
34
+ sessionWsUrl: "ws://localhost:4860/ws",
35
+ transportState: "reconnecting",
36
+ broadcastConnected: true,
37
+ }),
38
+ ).toBe("degraded");
39
+ });
40
+
41
+ it("treats broadcast-only sessions as connected when broadcast is available", () => {
42
+ expect(
43
+ resolvePresentationSyncStatus({
44
+ sessionEnabled: true,
45
+ syncMode: "both",
46
+ sessionWsUrl: null,
47
+ transportState: "disabled",
48
+ broadcastConnected: true,
49
+ }),
50
+ ).toBe("connected");
51
+ });
52
+ });
@@ -0,0 +1,33 @@
1
+ export type PresentationTransportState = "disabled" | "connecting" | "connected" | "reconnecting";
2
+
3
+ export type PresentationSyncStatus = "disabled" | "connecting" | "connected" | "degraded";
4
+
5
+ export function resolvePresentationSyncStatus({
6
+ sessionEnabled,
7
+ syncMode,
8
+ sessionWsUrl,
9
+ transportState,
10
+ broadcastConnected,
11
+ }: {
12
+ sessionEnabled: boolean;
13
+ syncMode: "send" | "receive" | "both" | "off";
14
+ sessionWsUrl: string | null;
15
+ transportState: PresentationTransportState;
16
+ broadcastConnected: boolean;
17
+ }): PresentationSyncStatus {
18
+ if (!sessionEnabled) return "disabled";
19
+
20
+ if (syncMode === "off") return "disabled";
21
+
22
+ if (sessionWsUrl) {
23
+ if (transportState === "connected") return "connected";
24
+
25
+ if (broadcastConnected) return "degraded";
26
+
27
+ return "connecting";
28
+ }
29
+
30
+ if (broadcastConnected) return "connected";
31
+
32
+ return "degraded";
33
+ }
@@ -0,0 +1 @@
1
+ export * from "@slidev-react/core/presentation/session/protocol";
@@ -0,0 +1,194 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { createRecordingDownloadName } from "./recordingFilename";
3
+
4
+ const ELAPSED_UPDATE_INTERVAL_MS = 250;
5
+
6
+ function chooseMimeType(): string | null {
7
+ if (typeof MediaRecorder === "undefined" || typeof MediaRecorder.isTypeSupported !== "function")
8
+ return null;
9
+
10
+ const preferred = ["video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm"];
11
+
12
+ for (const mimeType of preferred) {
13
+ if (MediaRecorder.isTypeSupported(mimeType)) return mimeType;
14
+ }
15
+
16
+ return null;
17
+ }
18
+
19
+ function downloadBlob({
20
+ blob,
21
+ exportFilename,
22
+ slidesTitle,
23
+ }: {
24
+ blob: Blob;
25
+ exportFilename?: string;
26
+ slidesTitle?: string;
27
+ }) {
28
+ const url = URL.createObjectURL(blob);
29
+ const anchor = document.createElement("a");
30
+ anchor.href = url;
31
+ anchor.download = createRecordingDownloadName({
32
+ exportFilename,
33
+ slidesTitle,
34
+ });
35
+ anchor.click();
36
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
37
+ }
38
+
39
+ export function usePresentationRecorder({
40
+ enabled,
41
+ exportFilename,
42
+ slidesTitle,
43
+ }: {
44
+ enabled: boolean;
45
+ exportFilename?: string;
46
+ slidesTitle?: string;
47
+ }) {
48
+ const [isRecording, setIsRecording] = useState(false);
49
+ const [elapsedMs, setElapsedMs] = useState(0);
50
+ const [error, setError] = useState<string | null>(null);
51
+
52
+ const recorderRef = useRef<MediaRecorder | null>(null);
53
+ const streamRef = useRef<MediaStream | null>(null);
54
+ const chunksRef = useRef<Blob[]>([]);
55
+ const startedAtRef = useRef<number>(0);
56
+ const elapsedTimerRef = useRef<number | null>(null);
57
+
58
+ const supported = useMemo(() => {
59
+ if (typeof navigator === "undefined") return false;
60
+
61
+ if (!navigator.mediaDevices || typeof navigator.mediaDevices.getDisplayMedia !== "function")
62
+ return false;
63
+
64
+ return typeof MediaRecorder !== "undefined";
65
+ }, []);
66
+
67
+ const stopStreamTracks = () => {
68
+ const stream = streamRef.current;
69
+ if (!stream) return;
70
+
71
+ for (const track of stream.getTracks()) track.stop();
72
+
73
+ streamRef.current = null;
74
+ };
75
+
76
+ const clearElapsedTimer = () => {
77
+ if (elapsedTimerRef.current === null) return;
78
+
79
+ window.clearInterval(elapsedTimerRef.current);
80
+ elapsedTimerRef.current = null;
81
+ };
82
+
83
+ const stop = async () => {
84
+ const recorder = recorderRef.current;
85
+ if (!recorder) return;
86
+
87
+ if (recorder.state === "inactive") {
88
+ stopStreamTracks();
89
+ setIsRecording(false);
90
+ clearElapsedTimer();
91
+ return;
92
+ }
93
+
94
+ await new Promise<void>((resolve) => {
95
+ recorder.addEventListener("stop", () => resolve(), { once: true });
96
+ recorder.stop();
97
+ });
98
+ };
99
+
100
+ const start = async () => {
101
+ if (!enabled || !supported || isRecording) return;
102
+
103
+ const mimeType = chooseMimeType();
104
+ if (!mimeType) {
105
+ setError("Your browser does not support a compatible recording format.");
106
+ return;
107
+ }
108
+
109
+ try {
110
+ setError(null);
111
+ chunksRef.current = [];
112
+
113
+ const stream = await navigator.mediaDevices.getDisplayMedia({
114
+ video: true,
115
+ audio: true,
116
+ });
117
+
118
+ streamRef.current = stream;
119
+ const recorder = new MediaRecorder(stream, { mimeType });
120
+ recorderRef.current = recorder;
121
+
122
+ recorder.addEventListener("dataavailable", (event) => {
123
+ if (event.data.size > 0) chunksRef.current.push(event.data);
124
+ });
125
+
126
+ recorder.addEventListener("stop", () => {
127
+ const blob = new Blob(chunksRef.current, { type: mimeType });
128
+ chunksRef.current = [];
129
+ stopStreamTracks();
130
+ recorderRef.current = null;
131
+ setIsRecording(false);
132
+ clearElapsedTimer();
133
+
134
+ if (blob.size > 0)
135
+ downloadBlob({
136
+ blob,
137
+ exportFilename,
138
+ slidesTitle,
139
+ });
140
+ });
141
+
142
+ const [videoTrack] = stream.getVideoTracks();
143
+ if (videoTrack) {
144
+ videoTrack.addEventListener(
145
+ "ended",
146
+ () => {
147
+ void stop();
148
+ },
149
+ { once: true },
150
+ );
151
+ }
152
+
153
+ recorder.start(1000);
154
+ startedAtRef.current = Date.now();
155
+ setElapsedMs(0);
156
+ setIsRecording(true);
157
+ clearElapsedTimer();
158
+ elapsedTimerRef.current = window.setInterval(() => {
159
+ setElapsedMs(Date.now() - startedAtRef.current);
160
+ }, ELAPSED_UPDATE_INTERVAL_MS);
161
+ } catch (startError) {
162
+ stopStreamTracks();
163
+ recorderRef.current = null;
164
+ setIsRecording(false);
165
+ clearElapsedTimer();
166
+ setError(startError instanceof Error ? startError.message : String(startError));
167
+ }
168
+ };
169
+
170
+ useEffect(() => {
171
+ if (enabled) return;
172
+
173
+ if (isRecording) void stop();
174
+ else setError(null);
175
+ }, [enabled, isRecording]);
176
+
177
+ useEffect(() => {
178
+ return () => {
179
+ clearElapsedTimer();
180
+ stopStreamTracks();
181
+ };
182
+ }, [slidesTitle, exportFilename]);
183
+
184
+ return {
185
+ supported,
186
+ isRecording,
187
+ elapsedMs,
188
+ error,
189
+ start,
190
+ stop,
191
+ };
192
+ }
193
+
194
+ export type PresentationRecorderRuntime = ReturnType<typeof usePresentationRecorder>;
@@ -0,0 +1,423 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import type { PresentationSession } from "./session";
3
+ import {
4
+ parsePresentationEnvelope,
5
+ type PresentationSharedState,
6
+ type SyncedPresentationRole,
7
+ } from "./types";
8
+ import {
9
+ canAuthorState,
10
+ canReceive,
11
+ canSend,
12
+ createEnvelope,
13
+ createSnapshotState,
14
+ isCursorEqual,
15
+ type EnvelopeInput,
16
+ } from "./sync/model/replication";
17
+ import {
18
+ countPeers,
19
+ markPeerSeen,
20
+ removePeer,
21
+ resolveRemoteActive,
22
+ sweepStalePeers,
23
+ } from "./sync/model/presence";
24
+ import {
25
+ resolvePresentationSyncStatus,
26
+ type PresentationSyncStatus,
27
+ type PresentationTransportState,
28
+ } from "./sync/model/status";
29
+ import { createBroadcastChannelTransport } from "./sync/adapters/broadcastChannelTransport";
30
+ import { createWebSocketTransport } from "./sync/adapters/websocketTransport";
31
+
32
+ const BROADCAST_CHANNEL_PREFIX = "slide-react:presentation:session:";
33
+ const HEARTBEAT_INTERVAL_MS = 5000;
34
+ const CURSOR_PATCH_INTERVAL_MS = 80;
35
+ const PEER_STALE_AFTER_MS = HEARTBEAT_INTERVAL_MS * 3;
36
+ const PEER_SWEEP_INTERVAL_MS = 2000;
37
+ const REMOTE_ACTIVE_WINDOW_MS = HEARTBEAT_INTERVAL_MS * 2;
38
+ export type { PresentationSyncStatus } from "./sync/model/status";
39
+
40
+ export interface UsePresentationSyncResult {
41
+ status: PresentationSyncStatus;
42
+ broadcastConnected: boolean;
43
+ wsConnected: boolean;
44
+ lastSyncedAt: number | null;
45
+ peerCount: number;
46
+ remoteActive: boolean;
47
+ }
48
+
49
+ function clamp(value: number, min: number, max: number) {
50
+ return Math.min(Math.max(value, min), max);
51
+ }
52
+
53
+ export function usePresentationSync({
54
+ session,
55
+ currentIndex,
56
+ total,
57
+ goTo,
58
+ followRemotePage,
59
+ localState,
60
+ onRemoteState,
61
+ }: {
62
+ session: PresentationSession;
63
+ currentIndex: number;
64
+ total: number;
65
+ goTo: (index: number) => void;
66
+ followRemotePage: boolean;
67
+ localState: PresentationSharedState;
68
+ onRemoteState: (patch: Partial<PresentationSharedState>, remotePage: number) => void;
69
+ }): UsePresentationSyncResult {
70
+ const [broadcastConnected, setBroadcastConnected] = useState(false);
71
+ const [wsState, setWsState] = useState<PresentationTransportState>("disabled");
72
+ const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
73
+ const [peerCount, setPeerCount] = useState(0);
74
+ const [remoteActive, setRemoteActive] = useState(false);
75
+
76
+ const seqRef = useRef(0);
77
+ const currentIndexRef = useRef(currentIndex);
78
+ const goToRef = useRef(goTo);
79
+ const totalRef = useRef(total);
80
+ const followRemotePageRef = useRef(followRemotePage);
81
+ const localStateRef = useRef(localState);
82
+ const onRemoteStateRef = useRef(onRemoteState);
83
+ const lastRemoteUpdateRef = useRef(0);
84
+ const remotePageRef = useRef(currentIndex);
85
+ const sendEnvelopeRef = useRef<((message: EnvelopeInput) => void) | null>(null);
86
+ const sendSnapshotRef = useRef<(() => void) | null>(null);
87
+ const lastSentStateRef = useRef<PresentationSharedState | null>(null);
88
+ const pendingCursorRef = useRef<PresentationSharedState["cursor"] | null>(null);
89
+ const cursorFlushTimerRef = useRef<number | null>(null);
90
+ const peerLastSeenRef = useRef<Map<string, number>>(new Map());
91
+ const lastRemoteActivityRef = useRef(0);
92
+
93
+ currentIndexRef.current = currentIndex;
94
+ goToRef.current = goTo;
95
+ totalRef.current = total;
96
+ followRemotePageRef.current = followRemotePage;
97
+ localStateRef.current = localState;
98
+ onRemoteStateRef.current = onRemoteState;
99
+
100
+ useEffect(() => {
101
+ lastSentStateRef.current = null;
102
+ pendingCursorRef.current = null;
103
+ peerLastSeenRef.current.clear();
104
+ lastRemoteActivityRef.current = 0;
105
+ remotePageRef.current = currentIndexRef.current;
106
+ setPeerCount(0);
107
+ setRemoteActive(!canReceive(session.syncMode));
108
+ if (cursorFlushTimerRef.current !== null) {
109
+ window.clearTimeout(cursorFlushTimerRef.current);
110
+ cursorFlushTimerRef.current = null;
111
+ }
112
+ }, [session.enabled, session.role, session.sessionId, session.syncMode]);
113
+
114
+ useEffect(() => {
115
+ if (
116
+ !session.enabled ||
117
+ !session.sessionId ||
118
+ (session.role !== "presenter" && session.role !== "viewer")
119
+ )
120
+ return;
121
+
122
+ if (!canSend(session.syncMode) && !canReceive(session.syncMode)) return;
123
+
124
+ const sessionId = session.sessionId;
125
+ const senderId = session.senderId;
126
+ const syncMode = session.syncMode;
127
+ const syncRole = session.role as SyncedPresentationRole;
128
+ const sessionWsUrl = session.wsUrl;
129
+ const channelName = `${BROADCAST_CHANNEL_PREFIX}${sessionId}`;
130
+ let disposed = false;
131
+ let heartbeatIntervalId: number | null = null;
132
+ let peerSweepIntervalId: number | null = null;
133
+
134
+ const refreshPeerCount = () => {
135
+ const size = countPeers(peerLastSeenRef.current);
136
+ setPeerCount((previous) => (previous === size ? previous : size));
137
+ };
138
+
139
+ const markPeerActivity = (peerId: string, activityAt: number) => {
140
+ markPeerSeen(peerLastSeenRef.current, peerId, activityAt);
141
+ refreshPeerCount();
142
+ };
143
+
144
+ const removePeerActivity = (peerId: string) => {
145
+ if (!removePeer(peerLastSeenRef.current, peerId)) return;
146
+
147
+ refreshPeerCount();
148
+ };
149
+
150
+ let websocketTransport: ReturnType<typeof createWebSocketTransport> | null = null;
151
+ const broadcastTransport = createBroadcastChannelTransport({
152
+ channelName,
153
+ onMessage: (incoming) => {
154
+ onIncomingEnvelope(incoming);
155
+ },
156
+ onConnectedChange: setBroadcastConnected,
157
+ });
158
+
159
+ const sendEnvelope = (message: EnvelopeInput) => {
160
+ if (disposed) return;
161
+
162
+ seqRef.current += 1;
163
+ const envelope = createEnvelope({
164
+ sessionId,
165
+ senderId,
166
+ seq: seqRef.current,
167
+ timestamp: Date.now(),
168
+ message,
169
+ });
170
+
171
+ broadcastTransport?.send(envelope);
172
+ websocketTransport?.send(JSON.stringify(envelope));
173
+ };
174
+
175
+ const sendSnapshot = () => {
176
+ if (!canSend(syncMode) || !canAuthorState(session.role)) return;
177
+
178
+ const state = createSnapshotState(localStateRef.current);
179
+ sendEnvelope({
180
+ type: "state/snapshot",
181
+ payload: {
182
+ state,
183
+ },
184
+ });
185
+ };
186
+
187
+ const onIncomingEnvelope = (incoming: unknown) => {
188
+ const envelope = parsePresentationEnvelope(incoming);
189
+ if (!envelope) return;
190
+
191
+ if (envelope.sessionId !== sessionId || envelope.senderId === senderId) return;
192
+
193
+ const observedAt = Date.now();
194
+ markPeerActivity(envelope.senderId, observedAt);
195
+ lastRemoteActivityRef.current = observedAt;
196
+ if (canReceive(syncMode)) setRemoteActive(true);
197
+
198
+ if (envelope.type === "session/leave") {
199
+ removePeerActivity(envelope.senderId);
200
+ return;
201
+ }
202
+
203
+ if (envelope.type === "heartbeat") return;
204
+
205
+ if (envelope.type === "session/join" && canSend(syncMode) && canAuthorState(syncRole)) {
206
+ sendSnapshot();
207
+ return;
208
+ }
209
+
210
+ if (envelope.type === "state/snapshot" || envelope.type === "state/patch") {
211
+ if (!canReceive(syncMode)) return;
212
+
213
+ const patchState = envelope.payload.state;
214
+ const updateAt = patchState.lastUpdate ?? envelope.timestamp;
215
+ if (updateAt <= lastRemoteUpdateRef.current) return;
216
+
217
+ lastRemoteUpdateRef.current = updateAt;
218
+ setLastSyncedAt(updateAt);
219
+
220
+ let remotePage = remotePageRef.current;
221
+ if (typeof patchState.page === "number") {
222
+ const maxIndex = Math.max(totalRef.current - 1, 0);
223
+ const nextIndex = clamp(Math.round(patchState.page), 0, maxIndex);
224
+ remotePage = nextIndex;
225
+ remotePageRef.current = nextIndex;
226
+ if (followRemotePageRef.current && nextIndex !== currentIndexRef.current)
227
+ goToRef.current(nextIndex);
228
+ }
229
+
230
+ onRemoteStateRef.current(patchState, remotePage);
231
+ }
232
+ };
233
+
234
+ if (sessionWsUrl) {
235
+ websocketTransport = createWebSocketTransport({
236
+ sessionWsUrl,
237
+ sessionId,
238
+ senderId,
239
+ onMessage: onIncomingEnvelope,
240
+ onStateChange: setWsState,
241
+ onOpen: () => {
242
+ sendEnvelope({
243
+ type: "session/join",
244
+ payload: {
245
+ role: syncRole,
246
+ },
247
+ });
248
+
249
+ if (canAuthorState(syncRole)) sendSnapshot();
250
+ },
251
+ });
252
+ } else setWsState("disabled");
253
+
254
+ sendEnvelope({
255
+ type: "session/join",
256
+ payload: {
257
+ role: syncRole,
258
+ },
259
+ });
260
+
261
+ if (canAuthorState(syncRole)) sendSnapshot();
262
+
263
+ heartbeatIntervalId = window.setInterval(() => {
264
+ sendEnvelope({
265
+ type: "heartbeat",
266
+ payload: {
267
+ role: syncRole,
268
+ },
269
+ });
270
+ }, HEARTBEAT_INTERVAL_MS);
271
+
272
+ peerSweepIntervalId = window.setInterval(() => {
273
+ const now = Date.now();
274
+ sweepStalePeers(peerLastSeenRef.current, now, PEER_STALE_AFTER_MS);
275
+ refreshPeerCount();
276
+ if (canReceive(syncMode))
277
+ setRemoteActive(
278
+ resolveRemoteActive(lastRemoteActivityRef.current, now, REMOTE_ACTIVE_WINDOW_MS),
279
+ );
280
+ }, PEER_SWEEP_INTERVAL_MS);
281
+
282
+ sendEnvelopeRef.current = sendEnvelope;
283
+ sendSnapshotRef.current = sendSnapshot;
284
+
285
+ return () => {
286
+ sendEnvelope({
287
+ type: "session/leave",
288
+ payload: {
289
+ role: syncRole,
290
+ },
291
+ });
292
+ disposed = true;
293
+
294
+ if (heartbeatIntervalId !== null) window.clearInterval(heartbeatIntervalId);
295
+
296
+ if (peerSweepIntervalId !== null) window.clearInterval(peerSweepIntervalId);
297
+
298
+ broadcastTransport?.dispose();
299
+ websocketTransport?.dispose();
300
+ sendEnvelopeRef.current = null;
301
+ sendSnapshotRef.current = null;
302
+ setWsState(sessionWsUrl ? "connecting" : "disabled");
303
+ setPeerCount(0);
304
+ setRemoteActive(!canReceive(syncMode));
305
+ };
306
+ }, [
307
+ session.enabled,
308
+ session.role,
309
+ session.senderId,
310
+ session.sessionId,
311
+ session.syncMode,
312
+ session.wsUrl,
313
+ ]);
314
+
315
+ useEffect(() => {
316
+ if (!session.enabled || !session.sessionId) return;
317
+
318
+ if (!canSend(session.syncMode) || !canAuthorState(session.role)) return;
319
+
320
+ const sendEnvelope = sendEnvelopeRef.current;
321
+ if (!sendEnvelope) return;
322
+
323
+ const previous = lastSentStateRef.current;
324
+ const current = localState;
325
+
326
+ if (!previous) {
327
+ sendSnapshotRef.current?.();
328
+ lastSentStateRef.current = current;
329
+ return;
330
+ }
331
+
332
+ const hasPageChange = previous.page !== current.page;
333
+ const hasCueChange = previous.cue !== current.cue;
334
+ const hasCueTotalChange = previous.cueTotal !== current.cueTotal;
335
+ const hasTimerChange = previous.timer !== current.timer;
336
+ const hasDrawingsChange = previous.drawingsRevision !== current.drawingsRevision;
337
+ const hasCursorChange = !isCursorEqual(previous.cursor, current.cursor);
338
+
339
+ if (
340
+ !hasPageChange &&
341
+ !hasCueChange &&
342
+ !hasCueTotalChange &&
343
+ !hasTimerChange &&
344
+ !hasDrawingsChange &&
345
+ !hasCursorChange
346
+ )
347
+ return;
348
+
349
+ if (
350
+ hasPageChange ||
351
+ hasCueChange ||
352
+ hasCueTotalChange ||
353
+ hasTimerChange ||
354
+ hasDrawingsChange
355
+ ) {
356
+ sendEnvelope({
357
+ type: "state/patch",
358
+ payload: {
359
+ state: {
360
+ ...(hasPageChange ? { page: current.page } : {}),
361
+ ...(hasCueChange ? { cue: current.cue } : {}),
362
+ ...(hasCueTotalChange ? { cueTotal: current.cueTotal } : {}),
363
+ ...(hasTimerChange ? { timer: current.timer } : {}),
364
+ ...(hasDrawingsChange
365
+ ? {
366
+ drawings: current.drawings,
367
+ drawingsRevision: current.drawingsRevision,
368
+ }
369
+ : {}),
370
+ lastUpdate: Date.now(),
371
+ },
372
+ },
373
+ });
374
+ }
375
+
376
+ if (hasCursorChange) {
377
+ pendingCursorRef.current = current.cursor;
378
+ if (cursorFlushTimerRef.current === null) {
379
+ cursorFlushTimerRef.current = window.setTimeout(() => {
380
+ cursorFlushTimerRef.current = null;
381
+ sendEnvelope({
382
+ type: "state/patch",
383
+ payload: {
384
+ state: {
385
+ cursor: pendingCursorRef.current,
386
+ lastUpdate: Date.now(),
387
+ },
388
+ },
389
+ });
390
+ }, CURSOR_PATCH_INTERVAL_MS);
391
+ }
392
+ }
393
+
394
+ lastSentStateRef.current = current;
395
+ }, [localState, session.enabled, session.role, session.sessionId, session.syncMode]);
396
+
397
+ useEffect(() => {
398
+ return () => {
399
+ if (cursorFlushTimerRef.current !== null) window.clearTimeout(cursorFlushTimerRef.current);
400
+ };
401
+ }, []);
402
+
403
+ const status = useMemo(
404
+ () =>
405
+ resolvePresentationSyncStatus({
406
+ sessionEnabled: session.enabled,
407
+ syncMode: session.syncMode,
408
+ sessionWsUrl: session.wsUrl,
409
+ transportState: wsState,
410
+ broadcastConnected,
411
+ }),
412
+ [broadcastConnected, session.enabled, session.syncMode, session.wsUrl, wsState],
413
+ );
414
+
415
+ return {
416
+ status,
417
+ broadcastConnected,
418
+ wsConnected: wsState === "connected",
419
+ lastSyncedAt,
420
+ peerCount,
421
+ remoteActive,
422
+ };
423
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { default } from "./app/App";
2
+ export { default as App } from "./app/App";
3
+ export { SlidesNavigationProvider, useSlidesState } from "./app/providers/SlidesNavigationProvider";
4
+ export { AddonProvider, useSlideAddons } from "./addons/AddonProvider";
5
+ export { ThemeProvider, useSlideTheme } from "./theme/ThemeProvider";
6
+ export { PrintSlidesView } from "./features/presentation/PrintSlidesView";
7
+ export { PresenterShell } from "./features/presentation/presenter/PresenterShell";