@honeydeck/honeydeck 0.4.0 → 0.5.0
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.
- package/DEVELOPMENT.md +4 -1
- package/Readme.md +2 -2
- package/SPEC.md +3 -3
- package/docs/components-browser-frame.md +34 -0
- package/docs/components-keyboard.md +31 -0
- package/docs/components-list-style.md +49 -0
- package/docs/components-notes.md +36 -0
- package/docs/components-reveal-group.md +58 -0
- package/docs/components-reveal-with.md +37 -0
- package/docs/components-reveal.md +33 -0
- package/docs/components-timeline-steps.md +48 -0
- package/docs/components.md +13 -54
- package/docs/configuration.md +11 -0
- package/docs/deeper-dive.md +30 -7
- package/docs/getting-started.md +2 -2
- package/docs/navigation.md +1 -1
- package/docs/pdf-export.md +4 -2
- package/docs/presenter-mode.md +6 -3
- package/docs/skills.md +3 -3
- package/docs/slidev-migration.md +3 -0
- package/docs/steps-and-reveals.md +143 -8
- package/package.json +4 -1
- package/skills/SPEC.md +2 -2
- package/skills/honeydeck/SKILL.md +2 -2
- package/skills/slidev-migration/SKILL.md +1 -0
- package/src/SPEC.md +8 -3
- package/src/cli/SPEC.md +3 -2
- package/src/cli/pdf.ts +11 -4
- package/src/remark/SPEC.md +102 -2
- package/src/remark/code-utils.ts +151 -0
- package/src/remark/shiki-code-blocks.ts +329 -136
- package/src/remark/step-numbering.ts +408 -103
- package/src/runtime/Deck.tsx +133 -116
- package/src/runtime/EffectiveColorModeContext.tsx +37 -0
- package/src/runtime/SPEC.md +21 -8
- package/src/runtime/SlideCanvas.tsx +19 -16
- package/src/runtime/SlideScaleContext.tsx +23 -0
- package/src/runtime/components/CodeBlock.tsx +19 -202
- package/src/runtime/components/CodeBlockCopyButton.tsx +64 -0
- package/src/runtime/components/CodeBlockShared.ts +17 -0
- package/src/runtime/components/Fade.tsx +51 -0
- package/src/runtime/components/FadeGroup.tsx +175 -0
- package/src/runtime/components/FadeWith.tsx +54 -0
- package/src/runtime/components/MagicCodeBlock.tsx +223 -0
- package/src/runtime/components/NavBar.tsx +1 -1
- package/src/runtime/components/NormalCodeBlock.tsx +128 -0
- package/src/runtime/components/Reveal.tsx +27 -27
- package/src/runtime/components/RevealGroup.tsx +143 -41
- package/src/runtime/components/RevealWith.tsx +63 -0
- package/src/runtime/components/SPEC.md +112 -7
- package/src/runtime/components/TimelineReveal.tsx +81 -0
- package/src/runtime/components/index.ts +13 -5
- package/src/runtime/components/timelineVisibility.ts +45 -0
- package/src/runtime/index.ts +9 -1
- package/src/runtime/navigation.ts +6 -4
- package/src/runtime/presentationApi.ts +449 -0
- package/src/runtime/views/PresenterCastButton.tsx +39 -0
- package/src/runtime/views/PresenterView.tsx +21 -4
- package/src/runtime/views/SPEC.md +7 -5
- package/src/theme/base.css +67 -2
- package/src/vite-plugin/SPEC.md +20 -2
- package/src/vite-plugin/index.ts +16 -2
- package/src/vite-plugin/splitter.ts +1 -0
- package/src/vite-plugin/virtual-modules.ts +16 -6
package/src/runtime/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This is what end-users import in their MDX slides:
|
|
5
5
|
* ```mdx
|
|
6
|
-
* import { Reveal, RevealGroup, Notes } from '@honeydeck/honeydeck'
|
|
6
|
+
* import { Reveal, RevealWith, RevealGroup, Notes } from '@honeydeck/honeydeck'
|
|
7
7
|
* ```
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -11,6 +11,12 @@ export type { ColorModeImageProps } from "../layouts/ColorModeImage.tsx";
|
|
|
11
11
|
export { ColorModeImage } from "../layouts/ColorModeImage.tsx";
|
|
12
12
|
export type { BrowserFrameProps } from "./components/BrowserFrame.tsx";
|
|
13
13
|
export { BrowserFrame } from "./components/BrowserFrame.tsx";
|
|
14
|
+
export type { FadeProps } from "./components/Fade.tsx";
|
|
15
|
+
export { Fade } from "./components/Fade.tsx";
|
|
16
|
+
export type { FadeGroupProps } from "./components/FadeGroup.tsx";
|
|
17
|
+
export { FadeGroup } from "./components/FadeGroup.tsx";
|
|
18
|
+
export type { FadeWithProps } from "./components/FadeWith.tsx";
|
|
19
|
+
export { FadeWith } from "./components/FadeWith.tsx";
|
|
14
20
|
export type { KeyboardKey, KeyboardProps } from "./components/Keyboard.tsx";
|
|
15
21
|
export { Keyboard } from "./components/Keyboard.tsx";
|
|
16
22
|
export type {
|
|
@@ -25,6 +31,8 @@ export type { RevealProps } from "./components/Reveal.tsx";
|
|
|
25
31
|
export { Reveal } from "./components/Reveal.tsx";
|
|
26
32
|
export type { RevealGroupProps } from "./components/RevealGroup.tsx";
|
|
27
33
|
export { RevealGroup } from "./components/RevealGroup.tsx";
|
|
34
|
+
export type { RevealWithProps } from "./components/RevealWith.tsx";
|
|
35
|
+
export { RevealWith } from "./components/RevealWith.tsx";
|
|
28
36
|
export type {
|
|
29
37
|
TimelineStepsPhase,
|
|
30
38
|
TimelineStepsProps,
|
|
@@ -203,9 +203,11 @@ export function openDocsWebsite(): void {
|
|
|
203
203
|
openUrlInNewTab(getDocsWebsiteUrl());
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
export function getPresenterRoute(route: Route): Route | null {
|
|
207
|
+
if (route.view === "kit") return null;
|
|
208
|
+
return { view: "presenter", slide: route.slide, step: route.step };
|
|
209
|
+
}
|
|
210
|
+
|
|
206
211
|
export function openPresenter(route: Route): void {
|
|
207
|
-
|
|
208
|
-
openUrlInNewTab(
|
|
209
|
-
getRouteUrl({ view: "presenter", slide: route.slide, step: route.step }),
|
|
210
|
-
);
|
|
212
|
+
navigateTo(getPresenterRoute(route));
|
|
211
213
|
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { getRouteUrl } from "./navigation.ts";
|
|
3
|
+
import { navigate, parseHash, type Route } from "./router.ts";
|
|
4
|
+
import {
|
|
5
|
+
createSyncResponseMessage,
|
|
6
|
+
resolveAudienceRouteFromSyncMessage,
|
|
7
|
+
type SyncMessage,
|
|
8
|
+
type SyncNavigateMessage,
|
|
9
|
+
type SyncRequestMessage,
|
|
10
|
+
type SyncResponseMessage,
|
|
11
|
+
} from "./sync.ts";
|
|
12
|
+
|
|
13
|
+
export type PresentationConnectionLike = {
|
|
14
|
+
send?: (message: unknown) => void;
|
|
15
|
+
close?: () => void;
|
|
16
|
+
terminate?: () => void;
|
|
17
|
+
addEventListener?: (
|
|
18
|
+
type: string,
|
|
19
|
+
listener: (event: MessageEvent<unknown>) => void,
|
|
20
|
+
) => void;
|
|
21
|
+
removeEventListener?: (
|
|
22
|
+
type: string,
|
|
23
|
+
listener: (event: MessageEvent<unknown>) => void,
|
|
24
|
+
) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type PresentationRequestLike = new (
|
|
28
|
+
presentationUrls: string[],
|
|
29
|
+
) => {
|
|
30
|
+
start: () => Promise<PresentationConnectionLike>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type PresentationReceiverLike = {
|
|
34
|
+
connectionList?:
|
|
35
|
+
| PresentationReceiverConnectionListLike
|
|
36
|
+
| Promise<PresentationReceiverConnectionListLike>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type PresentationReceiverConnectionListLike = {
|
|
40
|
+
connections?: PresentationConnectionLike[];
|
|
41
|
+
addEventListener?: (
|
|
42
|
+
type: string,
|
|
43
|
+
listener: (event: { connection?: PresentationConnectionLike }) => void,
|
|
44
|
+
) => void;
|
|
45
|
+
removeEventListener?: (
|
|
46
|
+
type: string,
|
|
47
|
+
listener: (event: { connection?: PresentationConnectionLike }) => void,
|
|
48
|
+
) => void;
|
|
49
|
+
onconnectionavailable?: (event: {
|
|
50
|
+
connection?: PresentationConnectionLike;
|
|
51
|
+
}) => void;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type PresentationNavigatorLike = {
|
|
55
|
+
receiver?: PresentationReceiverLike;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type PresenterRoute = {
|
|
59
|
+
slide: number;
|
|
60
|
+
step: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type PresentationWindowLike = Window & {
|
|
64
|
+
PresentationRequest?: PresentationRequestLike;
|
|
65
|
+
navigator: Navigator & {
|
|
66
|
+
presentation?: PresentationNavigatorLike;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function getPresentationWindow(): PresentationWindowLike | null {
|
|
71
|
+
if (typeof window === "undefined") return null;
|
|
72
|
+
return window as PresentationWindowLike;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getPresentationRequestConstructor(): PresentationRequestLike | null {
|
|
76
|
+
const request = getPresentationWindow()?.PresentationRequest;
|
|
77
|
+
return typeof request === "function" ? request : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isPresentationApiSupported(): boolean {
|
|
81
|
+
return getPresentationRequestConstructor() !== null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getPresentationAudienceUrl(route: Route): string | null {
|
|
85
|
+
if (route.view === "kit") return null;
|
|
86
|
+
return getRouteUrl({ view: "slide", slide: route.slide, step: route.step });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parsePresentationMessage(value: unknown): unknown {
|
|
90
|
+
if (typeof value !== "string") return value;
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(value) as unknown;
|
|
93
|
+
} catch {
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sendPresentationMessage(
|
|
99
|
+
connection: PresentationConnectionLike | null,
|
|
100
|
+
message: SyncMessage,
|
|
101
|
+
): void {
|
|
102
|
+
if (!connection?.send) return;
|
|
103
|
+
try {
|
|
104
|
+
connection.send(JSON.stringify(message));
|
|
105
|
+
} catch {
|
|
106
|
+
// Presentation API connections may throw if the receiver is gone.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sendPresentationRouteToConnection(
|
|
111
|
+
connection: PresentationConnectionLike | null,
|
|
112
|
+
slide: number,
|
|
113
|
+
step: number,
|
|
114
|
+
): void {
|
|
115
|
+
sendPresentationMessage(connection, { type: "navigate", slide, step });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function closeConnection(connection: PresentationConnectionLike | null): void {
|
|
119
|
+
if (!connection) return;
|
|
120
|
+
try {
|
|
121
|
+
if (connection.terminate) {
|
|
122
|
+
connection.terminate();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
connection.close?.();
|
|
126
|
+
} catch {
|
|
127
|
+
try {
|
|
128
|
+
connection.close?.();
|
|
129
|
+
} catch {
|
|
130
|
+
// Ignore connection shutdown errors; the UI is already stopping locally.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function sendPresentationSyncRequest(
|
|
136
|
+
connection: PresentationConnectionLike | null,
|
|
137
|
+
): void {
|
|
138
|
+
sendPresentationMessage(connection, { type: "sync-request" });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sendPresentationSyncResponse(
|
|
142
|
+
connection: PresentationConnectionLike | null,
|
|
143
|
+
slide: number,
|
|
144
|
+
step: number,
|
|
145
|
+
): void {
|
|
146
|
+
sendPresentationMessage(
|
|
147
|
+
connection,
|
|
148
|
+
createSyncResponseMessage({ slide, step }),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type PresentationRouteRef = {
|
|
153
|
+
current: PresenterRoute;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
type PresentationCastGenerationRef = {
|
|
157
|
+
current: number;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export async function startPresentationCast({
|
|
161
|
+
enabled = true,
|
|
162
|
+
supported,
|
|
163
|
+
audienceUrl,
|
|
164
|
+
currentSlide,
|
|
165
|
+
currentStep,
|
|
166
|
+
routeRef,
|
|
167
|
+
requestConstructor,
|
|
168
|
+
connectionRef,
|
|
169
|
+
startInFlightRef,
|
|
170
|
+
castGenerationRef,
|
|
171
|
+
setIsCasting,
|
|
172
|
+
}: {
|
|
173
|
+
enabled?: boolean;
|
|
174
|
+
supported: boolean;
|
|
175
|
+
audienceUrl: string | null;
|
|
176
|
+
currentSlide: number;
|
|
177
|
+
currentStep: number;
|
|
178
|
+
routeRef: PresentationRouteRef;
|
|
179
|
+
requestConstructor: PresentationRequestLike | null;
|
|
180
|
+
connectionRef: { current: PresentationConnectionLike | null };
|
|
181
|
+
startInFlightRef: { current: boolean };
|
|
182
|
+
castGenerationRef: PresentationCastGenerationRef;
|
|
183
|
+
setIsCasting: (isCasting: boolean) => void;
|
|
184
|
+
}): Promise<void> {
|
|
185
|
+
if (
|
|
186
|
+
!enabled ||
|
|
187
|
+
!supported ||
|
|
188
|
+
!audienceUrl ||
|
|
189
|
+
connectionRef.current ||
|
|
190
|
+
startInFlightRef.current
|
|
191
|
+
) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!requestConstructor) return;
|
|
196
|
+
|
|
197
|
+
startInFlightRef.current = true;
|
|
198
|
+
const castGeneration = ++castGenerationRef.current;
|
|
199
|
+
try {
|
|
200
|
+
const request = new requestConstructor([audienceUrl]);
|
|
201
|
+
const connection = await request.start();
|
|
202
|
+
if (castGeneration !== castGenerationRef.current) {
|
|
203
|
+
closeConnection(connection);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
connectionRef.current = connection;
|
|
207
|
+
setIsCasting(true);
|
|
208
|
+
|
|
209
|
+
const onMessage = (event: MessageEvent<unknown>) => {
|
|
210
|
+
if (!isSyncRequestMessage(parsePresentationMessage(event.data))) return;
|
|
211
|
+
sendPresentationSyncResponse(
|
|
212
|
+
connection,
|
|
213
|
+
routeRef.current.slide,
|
|
214
|
+
routeRef.current.step,
|
|
215
|
+
);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const onClose = () => {
|
|
219
|
+
connectionRef.current = null;
|
|
220
|
+
setIsCasting(false);
|
|
221
|
+
connection.removeEventListener?.("message", onMessage);
|
|
222
|
+
connection.removeEventListener?.("close", onClose);
|
|
223
|
+
connection.removeEventListener?.("terminate", onClose);
|
|
224
|
+
connection.removeEventListener?.("statechange", onStateChange);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const onStateChange = () => {
|
|
228
|
+
const state = (connection as { state?: unknown }).state;
|
|
229
|
+
if (state === "closed" || state === "terminated") onClose();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
connection.addEventListener?.("message", onMessage);
|
|
233
|
+
connection.addEventListener?.("close", onClose);
|
|
234
|
+
connection.addEventListener?.("terminate", onClose);
|
|
235
|
+
connection.addEventListener?.("statechange", onStateChange);
|
|
236
|
+
|
|
237
|
+
sendPresentationRouteToConnection(connection, currentSlide, currentStep);
|
|
238
|
+
} catch {
|
|
239
|
+
if (castGeneration !== castGenerationRef.current) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
connectionRef.current = null;
|
|
243
|
+
setIsCasting(false);
|
|
244
|
+
} finally {
|
|
245
|
+
startInFlightRef.current = false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function stopPresentationCast(
|
|
250
|
+
connectionRef: { current: PresentationConnectionLike | null },
|
|
251
|
+
setIsCasting: (isCasting: boolean) => void,
|
|
252
|
+
castGenerationRef?: PresentationCastGenerationRef,
|
|
253
|
+
): void {
|
|
254
|
+
if (castGenerationRef) castGenerationRef.current += 1;
|
|
255
|
+
closeConnection(connectionRef.current);
|
|
256
|
+
connectionRef.current = null;
|
|
257
|
+
setIsCasting(false);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function getConnectionFromEvent(event: {
|
|
261
|
+
connection?: PresentationConnectionLike;
|
|
262
|
+
}) {
|
|
263
|
+
return event.connection ?? null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function usePresentationReceiverSync({
|
|
267
|
+
enabled = true,
|
|
268
|
+
}: {
|
|
269
|
+
enabled?: boolean;
|
|
270
|
+
}): void {
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (!enabled) return;
|
|
273
|
+
|
|
274
|
+
const presentationNavigator =
|
|
275
|
+
getPresentationWindow()?.navigator.presentation;
|
|
276
|
+
const receiver = presentationNavigator?.receiver;
|
|
277
|
+
if (!receiver?.connectionList) return;
|
|
278
|
+
|
|
279
|
+
let cancelled = false;
|
|
280
|
+
const connections = new Set<PresentationConnectionLike>();
|
|
281
|
+
const cleanupTasks: Array<() => void> = [];
|
|
282
|
+
|
|
283
|
+
function handleMessage(event: MessageEvent<unknown>) {
|
|
284
|
+
const message = parsePresentationMessage(event.data);
|
|
285
|
+
if (cancelled || !isSyncMessage(message)) return;
|
|
286
|
+
const currentRoute = parseHash(location.hash);
|
|
287
|
+
const nextRoute = resolveAudienceRouteFromSyncMessage(
|
|
288
|
+
currentRoute,
|
|
289
|
+
message,
|
|
290
|
+
);
|
|
291
|
+
if (nextRoute) {
|
|
292
|
+
navigate(nextRoute);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function attachConnection(connection: PresentationConnectionLike | null) {
|
|
297
|
+
if (!connection || connections.has(connection)) return;
|
|
298
|
+
connections.add(connection);
|
|
299
|
+
connection.addEventListener?.("message", handleMessage);
|
|
300
|
+
sendPresentationSyncRequest(connection);
|
|
301
|
+
cleanupTasks.push(() => {
|
|
302
|
+
connection.removeEventListener?.("message", handleMessage);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function handleConnectionEvent(event: {
|
|
307
|
+
connection?: PresentationConnectionLike;
|
|
308
|
+
}) {
|
|
309
|
+
attachConnection(getConnectionFromEvent(event));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
Promise.resolve(receiver.connectionList)
|
|
313
|
+
.then((connectionList) => {
|
|
314
|
+
if (cancelled) return;
|
|
315
|
+
connectionList.connections?.forEach(attachConnection);
|
|
316
|
+
connectionList.addEventListener?.(
|
|
317
|
+
"connectionavailable",
|
|
318
|
+
handleConnectionEvent,
|
|
319
|
+
);
|
|
320
|
+
cleanupTasks.push(() => {
|
|
321
|
+
connectionList.removeEventListener?.(
|
|
322
|
+
"connectionavailable",
|
|
323
|
+
handleConnectionEvent,
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
const previousOnConnectionAvailable =
|
|
327
|
+
connectionList.onconnectionavailable;
|
|
328
|
+
connectionList.onconnectionavailable = handleConnectionEvent;
|
|
329
|
+
cleanupTasks.push(() => {
|
|
330
|
+
if (connectionList.onconnectionavailable === handleConnectionEvent) {
|
|
331
|
+
connectionList.onconnectionavailable =
|
|
332
|
+
previousOnConnectionAvailable;
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
})
|
|
336
|
+
.catch(() => {});
|
|
337
|
+
|
|
338
|
+
return () => {
|
|
339
|
+
cancelled = true;
|
|
340
|
+
cleanupTasks.splice(0).forEach((cleanup) => {
|
|
341
|
+
cleanup();
|
|
342
|
+
});
|
|
343
|
+
connections.clear();
|
|
344
|
+
};
|
|
345
|
+
}, [enabled]);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function usePresentationCast({
|
|
349
|
+
enabled = true,
|
|
350
|
+
audienceUrl,
|
|
351
|
+
currentSlide,
|
|
352
|
+
currentStep,
|
|
353
|
+
}: {
|
|
354
|
+
enabled?: boolean;
|
|
355
|
+
audienceUrl: string | null;
|
|
356
|
+
currentSlide: number;
|
|
357
|
+
currentStep: number;
|
|
358
|
+
}): {
|
|
359
|
+
supported: boolean;
|
|
360
|
+
isCasting: boolean;
|
|
361
|
+
startCasting: () => Promise<void>;
|
|
362
|
+
stopCasting: () => void;
|
|
363
|
+
} {
|
|
364
|
+
const [isCasting, setIsCasting] = useState(false);
|
|
365
|
+
const isMountedRef = useRef(true);
|
|
366
|
+
const connectionRef = useRef<PresentationConnectionLike | null>(null);
|
|
367
|
+
const startInFlightRef = useRef(false);
|
|
368
|
+
const castGenerationRef = useRef(0);
|
|
369
|
+
const routeRef = useRef<PresenterRoute>({
|
|
370
|
+
slide: currentSlide,
|
|
371
|
+
step: currentStep,
|
|
372
|
+
});
|
|
373
|
+
const supported = isPresentationApiSupported();
|
|
374
|
+
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
routeRef.current = {
|
|
377
|
+
slide: currentSlide,
|
|
378
|
+
step: currentStep,
|
|
379
|
+
};
|
|
380
|
+
}, [currentSlide, currentStep]);
|
|
381
|
+
|
|
382
|
+
const setCastingState = useCallback((next: boolean) => {
|
|
383
|
+
if (isMountedRef.current) setIsCasting(next);
|
|
384
|
+
}, []);
|
|
385
|
+
|
|
386
|
+
const stopCasting = useCallback(() => {
|
|
387
|
+
stopPresentationCast(connectionRef, setCastingState, castGenerationRef);
|
|
388
|
+
}, [setCastingState]);
|
|
389
|
+
|
|
390
|
+
const startCasting = useCallback(async () => {
|
|
391
|
+
await startPresentationCast({
|
|
392
|
+
enabled,
|
|
393
|
+
supported,
|
|
394
|
+
audienceUrl,
|
|
395
|
+
currentSlide,
|
|
396
|
+
currentStep,
|
|
397
|
+
routeRef,
|
|
398
|
+
requestConstructor: getPresentationRequestConstructor(),
|
|
399
|
+
connectionRef,
|
|
400
|
+
startInFlightRef,
|
|
401
|
+
castGenerationRef,
|
|
402
|
+
setIsCasting: setCastingState,
|
|
403
|
+
});
|
|
404
|
+
}, [
|
|
405
|
+
audienceUrl,
|
|
406
|
+
currentSlide,
|
|
407
|
+
currentStep,
|
|
408
|
+
enabled,
|
|
409
|
+
supported,
|
|
410
|
+
setCastingState,
|
|
411
|
+
]);
|
|
412
|
+
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
if (!isCasting) return;
|
|
415
|
+
sendPresentationRouteToConnection(
|
|
416
|
+
connectionRef.current,
|
|
417
|
+
currentSlide,
|
|
418
|
+
currentStep,
|
|
419
|
+
);
|
|
420
|
+
}, [currentSlide, currentStep, isCasting]);
|
|
421
|
+
|
|
422
|
+
useEffect(() => {
|
|
423
|
+
isMountedRef.current = true;
|
|
424
|
+
return () => {
|
|
425
|
+
isMountedRef.current = false;
|
|
426
|
+
stopCasting();
|
|
427
|
+
};
|
|
428
|
+
}, [stopCasting]);
|
|
429
|
+
|
|
430
|
+
return useMemo(
|
|
431
|
+
() => ({ supported, isCasting, startCasting, stopCasting }),
|
|
432
|
+
[supported, isCasting, startCasting, stopCasting],
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function isSyncRequestMessage(value: unknown): value is SyncRequestMessage {
|
|
437
|
+
if (typeof value !== "object" || value === null) return false;
|
|
438
|
+
if (!("type" in value)) return false;
|
|
439
|
+
return (value as SyncMessage).type === "sync-request";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function isSyncMessage(
|
|
443
|
+
value: unknown,
|
|
444
|
+
): value is SyncNavigateMessage | SyncResponseMessage {
|
|
445
|
+
if (typeof value !== "object" || value === null) return false;
|
|
446
|
+
if (!("type" in value)) return false;
|
|
447
|
+
const type = (value as SyncMessage).type;
|
|
448
|
+
return type === "navigate" || type === "sync-response";
|
|
449
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CastIcon, StopCircleIcon } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
export function PresenterCastButton({
|
|
4
|
+
supported,
|
|
5
|
+
isCasting,
|
|
6
|
+
onStartCasting,
|
|
7
|
+
onStopCasting,
|
|
8
|
+
}: {
|
|
9
|
+
supported: boolean;
|
|
10
|
+
isCasting: boolean;
|
|
11
|
+
onStartCasting: () => void | Promise<void>;
|
|
12
|
+
onStopCasting: () => void;
|
|
13
|
+
}) {
|
|
14
|
+
const label = isCasting ? "Stop casting" : "Cast audience view";
|
|
15
|
+
const unsupportedHint =
|
|
16
|
+
"Presentation casting is not supported in this browser";
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
onClick={isCasting ? onStopCasting : onStartCasting}
|
|
22
|
+
disabled={!supported}
|
|
23
|
+
title={supported ? label : unsupportedHint}
|
|
24
|
+
aria-label={supported ? label : unsupportedHint}
|
|
25
|
+
className={`px-3 py-1 rounded border border-white/20 text-white/80 text-sm font-[inherit] inline-flex items-center gap-1.5 ${
|
|
26
|
+
supported
|
|
27
|
+
? "bg-white/6"
|
|
28
|
+
: "bg-white/4 text-white/30 border-white/10 cursor-not-allowed"
|
|
29
|
+
}`}
|
|
30
|
+
>
|
|
31
|
+
{label}
|
|
32
|
+
{isCasting ? (
|
|
33
|
+
<StopCircleIcon aria-hidden="true" size={14} />
|
|
34
|
+
) : (
|
|
35
|
+
<CastIcon aria-hidden="true" size={14} />
|
|
36
|
+
)}
|
|
37
|
+
</button>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
* `<Notes>` component in that slide pushes its children into a state slot
|
|
20
20
|
* via `setNotes`, which is then displayed in the notes area.
|
|
21
21
|
*
|
|
22
|
-
* ###
|
|
22
|
+
* ### Presenter sync
|
|
23
23
|
* PresenterView is the controller: it broadcasts `navigate` messages whenever
|
|
24
|
-
* its route changes. Audience windows (`Deck`) listen and follow
|
|
24
|
+
* its route changes. Audience windows (`Deck`) listen and follow via
|
|
25
|
+
* BroadcastChannel fallback or Presentation API receiver messages.
|
|
25
26
|
*
|
|
26
27
|
* ### Keyboard navigation
|
|
27
28
|
* `useKeyboardNav` is wired so that arrow keys advance the presenter route
|
|
@@ -45,19 +46,23 @@ import {
|
|
|
45
46
|
} from "react";
|
|
46
47
|
import { NotesContext } from "../components/Notes.tsx";
|
|
47
48
|
import {
|
|
48
|
-
getRouteUrl,
|
|
49
49
|
nextSlide,
|
|
50
50
|
nextStep,
|
|
51
51
|
openUrlInNewTab,
|
|
52
52
|
previousSlide,
|
|
53
53
|
previousStep,
|
|
54
54
|
} from "../navigation.ts";
|
|
55
|
+
import {
|
|
56
|
+
getPresentationAudienceUrl,
|
|
57
|
+
usePresentationCast,
|
|
58
|
+
} from "../presentationApi.ts";
|
|
55
59
|
import { useRoute } from "../router.ts";
|
|
56
60
|
import { SlideCanvas } from "../SlideCanvas.tsx";
|
|
57
61
|
import { BASE_HEIGHT, BASE_WIDTH, slideData } from "../slideData.ts";
|
|
58
62
|
import { useSync } from "../sync.ts";
|
|
59
63
|
import { useKeyboardNav } from "../useKeyboardNav.ts";
|
|
60
64
|
import { useSwipeNav } from "../useSwipeNav.ts";
|
|
65
|
+
import { PresenterCastButton } from "./PresenterCastButton.tsx";
|
|
61
66
|
import { PresenterNotesPanel } from "./PresenterNotesPanel.tsx";
|
|
62
67
|
import { getPresenterNextPreview } from "./presenterPreview.ts";
|
|
63
68
|
|
|
@@ -177,6 +182,11 @@ export function PresenterView() {
|
|
|
177
182
|
stepCount: currentStepCount,
|
|
178
183
|
totalSlides,
|
|
179
184
|
});
|
|
185
|
+
const presentationCast = usePresentationCast({
|
|
186
|
+
audienceUrl: getPresentationAudienceUrl({ view: "slide", slide, step }),
|
|
187
|
+
currentSlide: slide,
|
|
188
|
+
currentStep: step,
|
|
189
|
+
});
|
|
180
190
|
|
|
181
191
|
// ── Wall clock ─────────────────────────────────────────────────────────
|
|
182
192
|
useEffect(() => {
|
|
@@ -203,7 +213,8 @@ export function PresenterView() {
|
|
|
203
213
|
|
|
204
214
|
// ── Open audience window ────────────────────────────────────────────────
|
|
205
215
|
function openAudienceView() {
|
|
206
|
-
|
|
216
|
+
const url = getPresentationAudienceUrl({ view: "slide", slide, step });
|
|
217
|
+
if (url) openUrlInNewTab(url);
|
|
207
218
|
}
|
|
208
219
|
|
|
209
220
|
// ── Navigate (presenter stays on presenter route) ───────────────────────
|
|
@@ -308,6 +319,12 @@ export function PresenterView() {
|
|
|
308
319
|
Open audience view
|
|
309
320
|
<ExternalLinkIcon aria-hidden="true" size={14} />
|
|
310
321
|
</button>
|
|
322
|
+
<PresenterCastButton
|
|
323
|
+
supported={presentationCast.supported}
|
|
324
|
+
isCasting={presentationCast.isCasting}
|
|
325
|
+
onStartCasting={presentationCast.startCasting}
|
|
326
|
+
onStopCasting={presentationCast.stopCasting}
|
|
327
|
+
/>
|
|
311
328
|
</div>
|
|
312
329
|
</div>
|
|
313
330
|
</div>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
### Activation
|
|
8
8
|
|
|
9
|
-
- Keyboard shortcut `p` (opens
|
|
9
|
+
- Keyboard shortcut `p` (opens presenter mode in the current tab)
|
|
10
10
|
- Navigation controls button
|
|
11
11
|
- Direct URL: `/#/presenter/1/0`
|
|
12
12
|
|
|
@@ -36,6 +36,7 @@ Includes:
|
|
|
36
36
|
- Slide/step counter
|
|
37
37
|
- Clock (wall clock)
|
|
38
38
|
- Button to open audience view in new tab, preserving the current slide/step and deck base path
|
|
39
|
+
- Button to cast the audience view to a secondary display when the Presentation API is supported; unsupported browsers show a disabled hint and active casting can be stopped from the same control
|
|
39
40
|
- Presenter navigation buttons provide previous/next timeline-step navigation and previous/next slide navigation. Timeline keyboard shortcuts (`→`/`←`/`↓`/`↑`, `d`/`a`/`s`/`w`) update the presenter route and keep the window in presenter mode.
|
|
40
41
|
- Presenter navigation uses the shared Honeydeck navigation command abstraction so button, keyboard, and touch inputs share the same semantics as audience view.
|
|
41
42
|
- Presenter notes are scroll-owned regions: wheel, trackpad, touch scroll, and swipe gestures that start in notes scroll notes and never navigate slides, even at scroll boundaries.
|
|
@@ -50,15 +51,16 @@ Presenter mode uses a two-column preview area (`Current` larger, `Next` smaller)
|
|
|
50
51
|
|
|
51
52
|
### Audience Sync
|
|
52
53
|
|
|
53
|
-
Presenter mode and audience view synchronize via `BroadcastChannel
|
|
54
|
+
Presenter mode and audience view synchronize via `BroadcastChannel` and the Presentation API:
|
|
54
55
|
|
|
55
|
-
- Same browser/profile
|
|
56
|
+
- Same browser/profile BroadcastChannel sync remains available when casting is unsupported or unavailable
|
|
56
57
|
- Presenter mode is the controller
|
|
57
58
|
- Audience view listens for navigation updates
|
|
58
|
-
- Late-opening audience tabs request the current presenter position via a `sync-request` / `sync-response` handshake so they sync immediately instead of waiting for the next presenter move
|
|
59
|
+
- Late-opening audience tabs request the current presenter position via a `sync-request` / `sync-response` handshake as soon as a receiver connection is available, so they sync immediately instead of waiting for the next presenter move
|
|
59
60
|
- Presence messages (`presenter-connected` / `presenter-disconnected`) are broadcast
|
|
61
|
+
- When the Presentation API is supported, presenter mode can cast the audience view to a secondary display; the receiver asks for the current route when a connection becomes available and presenter replies with a `sync-response` so the cast audience resyncs even if it missed the first `navigate` message
|
|
60
62
|
- Audience sync ignores navigation while the audience window is in the docs/reference view
|
|
61
|
-
- If sync unavailable, both still work independently
|
|
63
|
+
- If sync is unavailable, both still work independently
|
|
62
64
|
|
|
63
65
|
---
|
|
64
66
|
|