@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.
Files changed (64) hide show
  1. package/DEVELOPMENT.md +4 -1
  2. package/Readme.md +2 -2
  3. package/SPEC.md +3 -3
  4. package/docs/components-browser-frame.md +34 -0
  5. package/docs/components-keyboard.md +31 -0
  6. package/docs/components-list-style.md +49 -0
  7. package/docs/components-notes.md +36 -0
  8. package/docs/components-reveal-group.md +58 -0
  9. package/docs/components-reveal-with.md +37 -0
  10. package/docs/components-reveal.md +33 -0
  11. package/docs/components-timeline-steps.md +48 -0
  12. package/docs/components.md +13 -54
  13. package/docs/configuration.md +11 -0
  14. package/docs/deeper-dive.md +30 -7
  15. package/docs/getting-started.md +2 -2
  16. package/docs/navigation.md +1 -1
  17. package/docs/pdf-export.md +4 -2
  18. package/docs/presenter-mode.md +6 -3
  19. package/docs/skills.md +3 -3
  20. package/docs/slidev-migration.md +3 -0
  21. package/docs/steps-and-reveals.md +143 -8
  22. package/package.json +4 -1
  23. package/skills/SPEC.md +2 -2
  24. package/skills/honeydeck/SKILL.md +2 -2
  25. package/skills/slidev-migration/SKILL.md +1 -0
  26. package/src/SPEC.md +8 -3
  27. package/src/cli/SPEC.md +3 -2
  28. package/src/cli/pdf.ts +11 -4
  29. package/src/remark/SPEC.md +102 -2
  30. package/src/remark/code-utils.ts +151 -0
  31. package/src/remark/shiki-code-blocks.ts +329 -136
  32. package/src/remark/step-numbering.ts +408 -103
  33. package/src/runtime/Deck.tsx +133 -116
  34. package/src/runtime/EffectiveColorModeContext.tsx +37 -0
  35. package/src/runtime/SPEC.md +21 -8
  36. package/src/runtime/SlideCanvas.tsx +19 -16
  37. package/src/runtime/SlideScaleContext.tsx +23 -0
  38. package/src/runtime/components/CodeBlock.tsx +19 -202
  39. package/src/runtime/components/CodeBlockCopyButton.tsx +64 -0
  40. package/src/runtime/components/CodeBlockShared.ts +17 -0
  41. package/src/runtime/components/Fade.tsx +51 -0
  42. package/src/runtime/components/FadeGroup.tsx +175 -0
  43. package/src/runtime/components/FadeWith.tsx +54 -0
  44. package/src/runtime/components/MagicCodeBlock.tsx +223 -0
  45. package/src/runtime/components/NavBar.tsx +1 -1
  46. package/src/runtime/components/NormalCodeBlock.tsx +128 -0
  47. package/src/runtime/components/Reveal.tsx +27 -27
  48. package/src/runtime/components/RevealGroup.tsx +143 -41
  49. package/src/runtime/components/RevealWith.tsx +63 -0
  50. package/src/runtime/components/SPEC.md +112 -7
  51. package/src/runtime/components/TimelineReveal.tsx +81 -0
  52. package/src/runtime/components/index.ts +13 -5
  53. package/src/runtime/components/timelineVisibility.ts +45 -0
  54. package/src/runtime/index.ts +9 -1
  55. package/src/runtime/navigation.ts +6 -4
  56. package/src/runtime/presentationApi.ts +449 -0
  57. package/src/runtime/views/PresenterCastButton.tsx +39 -0
  58. package/src/runtime/views/PresenterView.tsx +21 -4
  59. package/src/runtime/views/SPEC.md +7 -5
  60. package/src/theme/base.css +67 -2
  61. package/src/vite-plugin/SPEC.md +20 -2
  62. package/src/vite-plugin/index.ts +16 -2
  63. package/src/vite-plugin/splitter.ts +1 -0
  64. package/src/vite-plugin/virtual-modules.ts +16 -6
@@ -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
- if (route.view === "kit") return;
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
- * ### BroadcastChannel sync
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
- openUrlInNewTab(getRouteUrl({ view: "slide", slide, step }));
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 in new window/tab using the current deck base path)
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 only (no server needed)
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