@honeydeck/honeydeck 0.6.0 → 0.8.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/AGENTS.md +1 -1
- package/docs/configuration.md +16 -4
- package/docs/deeper-dive.md +6 -3
- package/docs/index.json +11 -0
- package/docs/mobile.md +1 -1
- package/docs/presenter-mode.md +12 -6
- package/docs/transitions.md +126 -0
- package/package.json +1 -1
- package/src/cli/templates/SPEC.md +2 -1
- package/src/cli/templates/starter/deck.mdx +22 -0
- package/src/runtime/Deck.tsx +255 -36
- package/src/runtime/SPEC.md +10 -2
- package/src/runtime/presentationApi.ts +112 -6
- package/src/runtime/sync.ts +130 -12
- package/src/runtime/views/PresenterCastButton.tsx +17 -9
- package/src/runtime/views/PresenterView.tsx +247 -30
- package/src/runtime/views/SPEC.md +28 -5
- package/src/runtime/views/presenterTime.ts +15 -0
- package/src/theme/base.css +85 -0
- package/src/vite-plugin/SPEC.md +7 -2
- package/src/vite-plugin/splitter.ts +2 -0
package/src/runtime/Deck.tsx
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { config } from "virtual:honeydeck/config";
|
|
29
29
|
import {
|
|
30
|
+
type CSSProperties,
|
|
30
31
|
useCallback,
|
|
31
32
|
useEffect,
|
|
32
33
|
useLayoutEffect,
|
|
@@ -82,6 +83,80 @@ function calcScaleFromElement(el: HTMLElement | null): number | null {
|
|
|
82
83
|
return Math.min(el.clientWidth / BASE_WIDTH, el.clientHeight / BASE_HEIGHT);
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
type SlideTransitionState = {
|
|
87
|
+
from: number;
|
|
88
|
+
to: number;
|
|
89
|
+
name: string;
|
|
90
|
+
className: string;
|
|
91
|
+
duration: number;
|
|
92
|
+
easing: string;
|
|
93
|
+
direction: 1 | -1;
|
|
94
|
+
enterFromOpacity: number;
|
|
95
|
+
exitFromOpacity: number;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function normalizeTransitionName(value: unknown): string {
|
|
99
|
+
if (value === false) return "none";
|
|
100
|
+
if (value === true || value == null) return "fade";
|
|
101
|
+
if (typeof value !== "string") return "fade";
|
|
102
|
+
|
|
103
|
+
const name = value.trim();
|
|
104
|
+
if (!name) return "fade";
|
|
105
|
+
if (name === "true") return "fade";
|
|
106
|
+
if (name === "false") return "none";
|
|
107
|
+
return name;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeTransitionDuration(value: unknown): number | null {
|
|
111
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
112
|
+
return Math.max(0, Math.round(value));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeTransitionEasing(value: unknown): string | null {
|
|
116
|
+
if (typeof value !== "string") return null;
|
|
117
|
+
const easing = value.trim();
|
|
118
|
+
return easing ? easing : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function transitionClassName(name: string): string {
|
|
122
|
+
return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readLayerOpacity(
|
|
126
|
+
element: HTMLElement | null | undefined,
|
|
127
|
+
): number | null {
|
|
128
|
+
if (!element) return null;
|
|
129
|
+
const opacity = Number.parseFloat(window.getComputedStyle(element).opacity);
|
|
130
|
+
return Number.isFinite(opacity) ? opacity : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getTransitionOptions(
|
|
134
|
+
slideIndex: number,
|
|
135
|
+
): Omit<
|
|
136
|
+
SlideTransitionState,
|
|
137
|
+
"from" | "to" | "direction" | "enterFromOpacity" | "exitFromOpacity"
|
|
138
|
+
> {
|
|
139
|
+
const frontmatter = slideData[slideIndex]?.frontmatter ?? {};
|
|
140
|
+
const name = normalizeTransitionName(
|
|
141
|
+
frontmatter.transition ?? config.transition,
|
|
142
|
+
);
|
|
143
|
+
const duration =
|
|
144
|
+
normalizeTransitionDuration(frontmatter.transitionDuration) ??
|
|
145
|
+
normalizeTransitionDuration(config.transitionDuration) ??
|
|
146
|
+
200;
|
|
147
|
+
const easing =
|
|
148
|
+
normalizeTransitionEasing(frontmatter.transitionEasing) ??
|
|
149
|
+
normalizeTransitionEasing(config.transitionEasing) ??
|
|
150
|
+
"ease";
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
name,
|
|
154
|
+
className: transitionClassName(name),
|
|
155
|
+
duration,
|
|
156
|
+
easing,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
85
160
|
// ---------------------------------------------------------------------------
|
|
86
161
|
// Component
|
|
87
162
|
// ---------------------------------------------------------------------------
|
|
@@ -102,6 +177,7 @@ export function Deck() {
|
|
|
102
177
|
});
|
|
103
178
|
const [effectiveColorMode, setEffectiveColorMode] =
|
|
104
179
|
useState<EffectiveColorMode>("light");
|
|
180
|
+
const [reducedMotion, setReducedMotion] = useState(false);
|
|
105
181
|
|
|
106
182
|
const route = useRoute();
|
|
107
183
|
const pointerLayout = usePointerLayout();
|
|
@@ -126,6 +202,17 @@ export function Deck() {
|
|
|
126
202
|
return () => mq.removeEventListener("change", onChange);
|
|
127
203
|
}, [colorMode]);
|
|
128
204
|
|
|
205
|
+
// ── Reduced motion: disable slide transition animations ────────────────
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
208
|
+
setReducedMotion(mq.matches);
|
|
209
|
+
|
|
210
|
+
const onChange = (event: MediaQueryListEvent) =>
|
|
211
|
+
setReducedMotion(event.matches);
|
|
212
|
+
mq.addEventListener("change", onChange);
|
|
213
|
+
return () => mq.removeEventListener("change", onChange);
|
|
214
|
+
}, []);
|
|
215
|
+
|
|
129
216
|
// ── Observe stage size → recalculate scale ─────────────────────────────
|
|
130
217
|
useEffect(() => {
|
|
131
218
|
if (route.view !== "slide" && route.view !== "overview") return;
|
|
@@ -156,12 +243,22 @@ export function Deck() {
|
|
|
156
243
|
}, [route]);
|
|
157
244
|
|
|
158
245
|
// ── Audience sync: BroadcastChannel + Presentation API receiver ──────
|
|
246
|
+
const [blankScreen, setBlankScreen] = useState<"black" | null>(null);
|
|
247
|
+
const handleBlankScreen = useCallback(
|
|
248
|
+
(mode: "black" | "off") =>
|
|
249
|
+
setBlankScreen(mode === "black" ? "black" : null),
|
|
250
|
+
[],
|
|
251
|
+
);
|
|
159
252
|
useSync({
|
|
160
253
|
enabled: route.view === "slide" || route.view === "overview",
|
|
161
254
|
isPresenter: false,
|
|
255
|
+
onSetColorMode: setColorMode,
|
|
256
|
+
onBlankScreen: handleBlankScreen,
|
|
162
257
|
});
|
|
163
258
|
usePresentationReceiverSync({
|
|
164
259
|
enabled: route.view === "slide" || route.view === "overview",
|
|
260
|
+
onSetColorMode: setColorMode,
|
|
261
|
+
onBlankScreen: handleBlankScreen,
|
|
165
262
|
});
|
|
166
263
|
|
|
167
264
|
const resetZoom = useCallback(() => {
|
|
@@ -237,6 +334,73 @@ export function Deck() {
|
|
|
237
334
|
});
|
|
238
335
|
}, [scale, slideZoom]);
|
|
239
336
|
|
|
337
|
+
const currentSlide = Math.max(
|
|
338
|
+
1,
|
|
339
|
+
Math.min(route.slide, slideData.length || 1),
|
|
340
|
+
);
|
|
341
|
+
const previousSlideRef = useRef<number | null>(null);
|
|
342
|
+
const slideLayerRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
|
343
|
+
const [slideTransition, setSlideTransition] =
|
|
344
|
+
useState<SlideTransitionState | null>(null);
|
|
345
|
+
const slideTransitionRef = useRef<SlideTransitionState | null>(null);
|
|
346
|
+
slideTransitionRef.current = slideTransition;
|
|
347
|
+
|
|
348
|
+
useLayoutEffect(() => {
|
|
349
|
+
if (route.view !== "slide") {
|
|
350
|
+
previousSlideRef.current = currentSlide;
|
|
351
|
+
setSlideTransition(null);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (reducedMotion) {
|
|
356
|
+
previousSlideRef.current = currentSlide;
|
|
357
|
+
setSlideTransition(null);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const previousSlide = previousSlideRef.current;
|
|
362
|
+
if (previousSlide === null) {
|
|
363
|
+
previousSlideRef.current = currentSlide;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (previousSlide === currentSlide) return;
|
|
367
|
+
|
|
368
|
+
const direction: 1 | -1 = currentSlide > previousSlide ? 1 : -1;
|
|
369
|
+
const options = getTransitionOptions(currentSlide - 1);
|
|
370
|
+
previousSlideRef.current = currentSlide;
|
|
371
|
+
|
|
372
|
+
if (options.name === "none" || options.duration === 0) {
|
|
373
|
+
setSlideTransition(null);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const activeTransition = slideTransitionRef.current;
|
|
378
|
+
const isInterruptingFade = activeTransition?.name === "fade";
|
|
379
|
+
const nextTransition = {
|
|
380
|
+
...options,
|
|
381
|
+
from: previousSlide,
|
|
382
|
+
to: currentSlide,
|
|
383
|
+
direction,
|
|
384
|
+
enterFromOpacity: isInterruptingFade
|
|
385
|
+
? (readLayerOpacity(slideLayerRefs.current[currentSlide]) ?? 0)
|
|
386
|
+
: 0,
|
|
387
|
+
exitFromOpacity: isInterruptingFade
|
|
388
|
+
? (readLayerOpacity(slideLayerRefs.current[previousSlide]) ?? 1)
|
|
389
|
+
: 1,
|
|
390
|
+
};
|
|
391
|
+
setSlideTransition(nextTransition);
|
|
392
|
+
|
|
393
|
+
const timeout = window.setTimeout(() => {
|
|
394
|
+
setSlideTransition((active) =>
|
|
395
|
+
active?.from === nextTransition.from && active.to === nextTransition.to
|
|
396
|
+
? null
|
|
397
|
+
: active,
|
|
398
|
+
);
|
|
399
|
+
}, options.duration);
|
|
400
|
+
|
|
401
|
+
return () => window.clearTimeout(timeout);
|
|
402
|
+
}, [currentSlide, reducedMotion, route.view]);
|
|
403
|
+
|
|
240
404
|
// ── Reference mode: delegate to DocsView ─────────────────────────────
|
|
241
405
|
if (route.view === "kit") {
|
|
242
406
|
return (
|
|
@@ -250,12 +414,11 @@ export function Deck() {
|
|
|
250
414
|
|
|
251
415
|
// ── Presenter mode: delegate to PresenterView ──────────────────────────
|
|
252
416
|
if (route.view === "presenter") {
|
|
253
|
-
return
|
|
417
|
+
return (
|
|
418
|
+
<PresenterView colorMode={colorMode} onSetColorMode={setColorMode} />
|
|
419
|
+
);
|
|
254
420
|
}
|
|
255
421
|
|
|
256
|
-
// Whether slide transitions are enabled (can be disabled via deck frontmatter)
|
|
257
|
-
const enableTransition = config.transition !== false;
|
|
258
|
-
|
|
259
422
|
// ─────────────────────────────────────────────────────────────────────────
|
|
260
423
|
// Guard: no slides
|
|
261
424
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -268,14 +431,15 @@ export function Deck() {
|
|
|
268
431
|
);
|
|
269
432
|
}
|
|
270
433
|
|
|
271
|
-
const currentSlide = Math.max(1, Math.min(route.slide, slideData.length));
|
|
272
434
|
const currentStep = Math.max(0, route.step);
|
|
273
435
|
const controlRoute =
|
|
274
436
|
route.view === "slide" || route.view === "overview"
|
|
275
437
|
? { ...route, slide: currentSlide, step: currentStep }
|
|
276
438
|
: route;
|
|
277
439
|
const activeSlideScale = scale * slideZoom;
|
|
440
|
+
const viewportScale = scale || 1;
|
|
278
441
|
const slideTransform = `translate(${slidePan.x}px, ${slidePan.y}px) scale(${activeSlideScale})`;
|
|
442
|
+
const zoomedSlideTransform = `translate(${slidePan.x / viewportScale}px, ${slidePan.y / viewportScale}px) scale(${slideZoom})`;
|
|
279
443
|
const showSlideNumbers = config.showSlideNumbers === true;
|
|
280
444
|
const disableSlideTextSelection =
|
|
281
445
|
route.view === "slide" &&
|
|
@@ -314,6 +478,31 @@ export function Deck() {
|
|
|
314
478
|
{slideData.map((data, i) => {
|
|
315
479
|
const slideNumber = i + 1;
|
|
316
480
|
const isCurrent = slideNumber === currentSlide;
|
|
481
|
+
const activeTransition = slideTransition;
|
|
482
|
+
const transitionRole =
|
|
483
|
+
activeTransition?.to === slideNumber
|
|
484
|
+
? "enter"
|
|
485
|
+
: activeTransition?.from === slideNumber
|
|
486
|
+
? "exit"
|
|
487
|
+
: null;
|
|
488
|
+
const isVisible = isCurrent || transitionRole !== null;
|
|
489
|
+
const transitionLayerClass =
|
|
490
|
+
transitionRole && activeTransition
|
|
491
|
+
? `honeydeck-transition-${activeTransition.className} honeydeck-transition-${transitionRole}`
|
|
492
|
+
: "";
|
|
493
|
+
const layerStyle =
|
|
494
|
+
transitionRole && activeTransition
|
|
495
|
+
? ({
|
|
496
|
+
"--honeydeck-transition-duration": `${activeTransition.duration}ms`,
|
|
497
|
+
"--honeydeck-transition-easing": activeTransition.easing,
|
|
498
|
+
"--honeydeck-transition-direction":
|
|
499
|
+
activeTransition.direction,
|
|
500
|
+
"--honeydeck-transition-enter-from-opacity":
|
|
501
|
+
activeTransition.enterFromOpacity,
|
|
502
|
+
"--honeydeck-transition-exit-from-opacity":
|
|
503
|
+
activeTransition.exitFromOpacity,
|
|
504
|
+
} as CSSProperties)
|
|
505
|
+
: undefined;
|
|
317
506
|
const { Component, stepCount, title, frontmatter, layoutName } =
|
|
318
507
|
data;
|
|
319
508
|
const LayoutComponent = resolveLayout(layoutName);
|
|
@@ -323,42 +512,67 @@ export function Deck() {
|
|
|
323
512
|
key={data.id}
|
|
324
513
|
aria-hidden={!isCurrent}
|
|
325
514
|
className={`absolute inset-0 flex items-center justify-center ${
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
515
|
+
isVisible ? "visible" : "invisible"
|
|
516
|
+
} ${isCurrent ? "pointer-events-auto" : "pointer-events-none"} ${
|
|
517
|
+
transitionRole === "enter"
|
|
518
|
+
? "z-2"
|
|
519
|
+
: transitionRole === "exit"
|
|
520
|
+
? "z-1"
|
|
521
|
+
: isCurrent
|
|
522
|
+
? "z-1"
|
|
523
|
+
: "z-0"
|
|
329
524
|
}`}
|
|
330
|
-
style={{
|
|
331
|
-
transition: enableTransition
|
|
332
|
-
? `opacity 200ms ease, visibility 0s ${isCurrent ? "0s" : "200ms"}`
|
|
333
|
-
: "none",
|
|
334
|
-
}}
|
|
335
525
|
>
|
|
336
|
-
<
|
|
337
|
-
|
|
338
|
-
|
|
526
|
+
<div
|
|
527
|
+
className="shrink-0 overflow-hidden"
|
|
528
|
+
style={{
|
|
529
|
+
width: BASE_WIDTH,
|
|
530
|
+
height: BASE_HEIGHT,
|
|
531
|
+
transform: `scale(${scale})`,
|
|
532
|
+
transformOrigin: "center center",
|
|
533
|
+
}}
|
|
339
534
|
>
|
|
340
|
-
<
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
535
|
+
<div
|
|
536
|
+
ref={(element) => {
|
|
537
|
+
slideLayerRefs.current[slideNumber] = element;
|
|
538
|
+
}}
|
|
539
|
+
className={`honeydeck-slide-layer relative ${
|
|
540
|
+
isCurrent ? "opacity-100" : "opacity-0"
|
|
541
|
+
} ${transitionLayerClass}`}
|
|
542
|
+
style={{
|
|
543
|
+
width: BASE_WIDTH,
|
|
544
|
+
height: BASE_HEIGHT,
|
|
545
|
+
...layerStyle,
|
|
546
|
+
}}
|
|
547
|
+
>
|
|
548
|
+
<TimelineProvider
|
|
549
|
+
stepIndex={isCurrent ? currentStep : 0}
|
|
550
|
+
stepCount={stepCount}
|
|
349
551
|
>
|
|
350
|
-
<
|
|
351
|
-
<
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
552
|
+
<SlideScaleProvider scale={activeSlideScale}>
|
|
553
|
+
<div
|
|
554
|
+
className="honeydeck-slide-canvas shrink-0 relative overflow-hidden box-border"
|
|
555
|
+
style={{
|
|
556
|
+
width: BASE_WIDTH,
|
|
557
|
+
height: BASE_HEIGHT,
|
|
558
|
+
transform: zoomedSlideTransform,
|
|
559
|
+
transformOrigin: "center center",
|
|
560
|
+
}}
|
|
355
561
|
>
|
|
356
|
-
<
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
562
|
+
<ErrorBoundary slideNumber={slideNumber}>
|
|
563
|
+
<LayoutComponent
|
|
564
|
+
title={title || null}
|
|
565
|
+
frontmatter={frontmatter}
|
|
566
|
+
rawChildren={<Component />}
|
|
567
|
+
>
|
|
568
|
+
<Component />
|
|
569
|
+
</LayoutComponent>
|
|
570
|
+
</ErrorBoundary>
|
|
571
|
+
</div>
|
|
572
|
+
</SlideScaleProvider>
|
|
573
|
+
</TimelineProvider>
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
362
576
|
</div>
|
|
363
577
|
);
|
|
364
578
|
})}
|
|
@@ -394,6 +608,11 @@ export function Deck() {
|
|
|
394
608
|
/>
|
|
395
609
|
)}
|
|
396
610
|
|
|
611
|
+
{/* ── Black screen overlay (controlled by presenter) ────────── */}
|
|
612
|
+
{blankScreen === "black" && (
|
|
613
|
+
<div className="fixed inset-0 bg-black z-[100]" aria-hidden="true" />
|
|
614
|
+
)}
|
|
615
|
+
|
|
397
616
|
{/* ── Navigation bar ────────────────────────────────────────────── */}
|
|
398
617
|
{/* GAP-06: showSlideNumbers wired from config */}
|
|
399
618
|
{!isOverview && (
|
package/src/runtime/SPEC.md
CHANGED
|
@@ -223,12 +223,20 @@ Build output is a single-page application. The app preserves client-side slide t
|
|
|
223
223
|
|
|
224
224
|
### Slide Transitions
|
|
225
225
|
|
|
226
|
-
Honeydeck
|
|
226
|
+
Honeydeck uses a named slide transition system. Deck-level frontmatter sets defaults, and slide-level frontmatter overrides the transition **into that slide**:
|
|
227
227
|
|
|
228
228
|
```yaml
|
|
229
|
-
transition:
|
|
229
|
+
transition: fade
|
|
230
|
+
transitionDuration: 200
|
|
231
|
+
transitionEasing: ease
|
|
230
232
|
```
|
|
231
233
|
|
|
234
|
+
Built-in transition names are `fade`, `none`, and `slide-left`. Any other string is exposed as a custom CSS hook on the participating slide layers. Legacy `transition: true` maps to `fade`, and `transition: false` maps to `none`.
|
|
235
|
+
|
|
236
|
+
During slide navigation, Honeydeck keeps the outgoing and incoming slide layers mounted inside a scaled slide-sized clipping viewport, applies `honeydeck-slide-layer`, `honeydeck-transition-{name}`, and either `honeydeck-transition-enter` or `honeydeck-transition-exit` only to those two layers, then clears transition state after the configured duration. Transition visuals are clipped to the slide canvas area and must not animate into letterbox or pillarbox bars around the slide. If the next transition is `none` or navigation is interrupted, stale transition state is cleared/replaced so old slides do not remain visible. Outgoing layers are visible during the transition but have pointer events disabled. The built-in `fade` transition uses keyframes and, when a fade is interrupted, starts the next fade from the participating layers' current computed opacity so quick back-and-forth navigation stays close to the old opacity-transition behavior.
|
|
237
|
+
|
|
238
|
+
Participating slide layers receive CSS variables: `--honeydeck-transition-duration`, `--honeydeck-transition-easing`, and `--honeydeck-transition-direction` (`1` forward, `-1` backward). Built-in `slide-left` uses the direction variable so backward navigation reverses direction. Custom transition CSS can use the same variable for opt-in reverse awareness. Reduced-motion preferences disable slide transition animations.
|
|
239
|
+
|
|
232
240
|
### Aspect Ratio
|
|
233
241
|
|
|
234
242
|
Slides render at a fixed 1920px logical width. Height is derived from deck-level `aspectRatio` when it is a string ratio matching `N:N` (default `16:9` → `1080`, `4:3` → `1440`, etc.). Invalid or missing ratios fall back to `16:9`.
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { ColorMode } from "./components/ColorModeCycleButton.tsx";
|
|
2
3
|
import { getRouteUrl } from "./navigation.ts";
|
|
3
4
|
import { navigate, parseHash, type Route } from "./router.ts";
|
|
4
5
|
import {
|
|
6
|
+
createSyncColorModeMessage,
|
|
5
7
|
createSyncResponseMessage,
|
|
6
8
|
resolveAudienceRouteFromSyncMessage,
|
|
9
|
+
type SyncBlankScreenMessage,
|
|
10
|
+
type SyncColorModeMessage,
|
|
7
11
|
type SyncMessage,
|
|
8
12
|
type SyncNavigateMessage,
|
|
9
13
|
type SyncRequestMessage,
|
|
@@ -60,6 +64,10 @@ type PresenterRoute = {
|
|
|
60
64
|
step: number;
|
|
61
65
|
};
|
|
62
66
|
|
|
67
|
+
type PresentationColorModeRef = {
|
|
68
|
+
current: ColorMode;
|
|
69
|
+
};
|
|
70
|
+
|
|
63
71
|
type PresentationWindowLike = Window & {
|
|
64
72
|
PresentationRequest?: PresentationRequestLike;
|
|
65
73
|
navigator: Navigator & {
|
|
@@ -142,13 +150,21 @@ function sendPresentationSyncResponse(
|
|
|
142
150
|
connection: PresentationConnectionLike | null,
|
|
143
151
|
slide: number,
|
|
144
152
|
step: number,
|
|
153
|
+
colorMode?: ColorMode,
|
|
145
154
|
): void {
|
|
146
155
|
sendPresentationMessage(
|
|
147
156
|
connection,
|
|
148
|
-
createSyncResponseMessage({ slide, step }),
|
|
157
|
+
createSyncResponseMessage({ slide, step }, colorMode),
|
|
149
158
|
);
|
|
150
159
|
}
|
|
151
160
|
|
|
161
|
+
function sendPresentationColorModeToConnection(
|
|
162
|
+
connection: PresentationConnectionLike | null,
|
|
163
|
+
colorMode: ColorMode,
|
|
164
|
+
): void {
|
|
165
|
+
sendPresentationMessage(connection, createSyncColorModeMessage(colorMode));
|
|
166
|
+
}
|
|
167
|
+
|
|
152
168
|
type PresentationRouteRef = {
|
|
153
169
|
current: PresenterRoute;
|
|
154
170
|
};
|
|
@@ -163,7 +179,9 @@ export async function startPresentationCast({
|
|
|
163
179
|
audienceUrl,
|
|
164
180
|
currentSlide,
|
|
165
181
|
currentStep,
|
|
182
|
+
currentColorMode,
|
|
166
183
|
routeRef,
|
|
184
|
+
colorModeRef,
|
|
167
185
|
requestConstructor,
|
|
168
186
|
connectionRef,
|
|
169
187
|
startInFlightRef,
|
|
@@ -175,7 +193,9 @@ export async function startPresentationCast({
|
|
|
175
193
|
audienceUrl: string | null;
|
|
176
194
|
currentSlide: number;
|
|
177
195
|
currentStep: number;
|
|
196
|
+
currentColorMode: ColorMode;
|
|
178
197
|
routeRef: PresentationRouteRef;
|
|
198
|
+
colorModeRef: PresentationColorModeRef;
|
|
179
199
|
requestConstructor: PresentationRequestLike | null;
|
|
180
200
|
connectionRef: { current: PresentationConnectionLike | null };
|
|
181
201
|
startInFlightRef: { current: boolean };
|
|
@@ -212,6 +232,7 @@ export async function startPresentationCast({
|
|
|
212
232
|
connection,
|
|
213
233
|
routeRef.current.slide,
|
|
214
234
|
routeRef.current.step,
|
|
235
|
+
colorModeRef.current,
|
|
215
236
|
);
|
|
216
237
|
};
|
|
217
238
|
|
|
@@ -235,6 +256,7 @@ export async function startPresentationCast({
|
|
|
235
256
|
connection.addEventListener?.("statechange", onStateChange);
|
|
236
257
|
|
|
237
258
|
sendPresentationRouteToConnection(connection, currentSlide, currentStep);
|
|
259
|
+
sendPresentationColorModeToConnection(connection, currentColorMode);
|
|
238
260
|
} catch {
|
|
239
261
|
if (castGeneration !== castGenerationRef.current) {
|
|
240
262
|
return;
|
|
@@ -265,8 +287,12 @@ function getConnectionFromEvent(event: {
|
|
|
265
287
|
|
|
266
288
|
export function usePresentationReceiverSync({
|
|
267
289
|
enabled = true,
|
|
290
|
+
onSetColorMode,
|
|
291
|
+
onBlankScreen,
|
|
268
292
|
}: {
|
|
269
293
|
enabled?: boolean;
|
|
294
|
+
onSetColorMode?: (mode: ColorMode) => void;
|
|
295
|
+
onBlankScreen?: (mode: "black" | "off") => void;
|
|
270
296
|
}): void {
|
|
271
297
|
useEffect(() => {
|
|
272
298
|
if (!enabled) return;
|
|
@@ -283,6 +309,20 @@ export function usePresentationReceiverSync({
|
|
|
283
309
|
function handleMessage(event: MessageEvent<unknown>) {
|
|
284
310
|
const message = parsePresentationMessage(event.data);
|
|
285
311
|
if (cancelled || !isSyncMessage(message)) return;
|
|
312
|
+
|
|
313
|
+
if (isColorModeMessage(message)) {
|
|
314
|
+
onSetColorMode?.(message.colorMode);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (isBlankScreenMessage(message)) {
|
|
319
|
+
onBlankScreen?.(message.mode);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (message.type === "sync-response" && message.colorMode) {
|
|
324
|
+
onSetColorMode?.(message.colorMode);
|
|
325
|
+
}
|
|
286
326
|
const currentRoute = parseHash(location.hash);
|
|
287
327
|
const nextRoute = resolveAudienceRouteFromSyncMessage(
|
|
288
328
|
currentRoute,
|
|
@@ -342,7 +382,7 @@ export function usePresentationReceiverSync({
|
|
|
342
382
|
});
|
|
343
383
|
connections.clear();
|
|
344
384
|
};
|
|
345
|
-
}, [enabled]);
|
|
385
|
+
}, [enabled, onSetColorMode, onBlankScreen]);
|
|
346
386
|
}
|
|
347
387
|
|
|
348
388
|
export function usePresentationCast({
|
|
@@ -350,16 +390,19 @@ export function usePresentationCast({
|
|
|
350
390
|
audienceUrl,
|
|
351
391
|
currentSlide,
|
|
352
392
|
currentStep,
|
|
393
|
+
currentColorMode,
|
|
353
394
|
}: {
|
|
354
395
|
enabled?: boolean;
|
|
355
396
|
audienceUrl: string | null;
|
|
356
397
|
currentSlide: number;
|
|
357
398
|
currentStep: number;
|
|
399
|
+
currentColorMode: ColorMode;
|
|
358
400
|
}): {
|
|
359
401
|
supported: boolean;
|
|
360
402
|
isCasting: boolean;
|
|
361
403
|
startCasting: () => Promise<void>;
|
|
362
404
|
stopCasting: () => void;
|
|
405
|
+
sendMessage: (message: SyncMessage) => void;
|
|
363
406
|
} {
|
|
364
407
|
const [isCasting, setIsCasting] = useState(false);
|
|
365
408
|
const isMountedRef = useRef(true);
|
|
@@ -370,6 +413,7 @@ export function usePresentationCast({
|
|
|
370
413
|
slide: currentSlide,
|
|
371
414
|
step: currentStep,
|
|
372
415
|
});
|
|
416
|
+
const colorModeRef = useRef<ColorMode>(currentColorMode);
|
|
373
417
|
const supported = isPresentationApiSupported();
|
|
374
418
|
|
|
375
419
|
useEffect(() => {
|
|
@@ -379,6 +423,10 @@ export function usePresentationCast({
|
|
|
379
423
|
};
|
|
380
424
|
}, [currentSlide, currentStep]);
|
|
381
425
|
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
colorModeRef.current = currentColorMode;
|
|
428
|
+
}, [currentColorMode]);
|
|
429
|
+
|
|
382
430
|
const setCastingState = useCallback((next: boolean) => {
|
|
383
431
|
if (isMountedRef.current) setIsCasting(next);
|
|
384
432
|
}, []);
|
|
@@ -394,7 +442,9 @@ export function usePresentationCast({
|
|
|
394
442
|
audienceUrl,
|
|
395
443
|
currentSlide,
|
|
396
444
|
currentStep,
|
|
445
|
+
currentColorMode,
|
|
397
446
|
routeRef,
|
|
447
|
+
colorModeRef,
|
|
398
448
|
requestConstructor: getPresentationRequestConstructor(),
|
|
399
449
|
connectionRef,
|
|
400
450
|
startInFlightRef,
|
|
@@ -405,6 +455,7 @@ export function usePresentationCast({
|
|
|
405
455
|
audienceUrl,
|
|
406
456
|
currentSlide,
|
|
407
457
|
currentStep,
|
|
458
|
+
currentColorMode,
|
|
408
459
|
enabled,
|
|
409
460
|
supported,
|
|
410
461
|
setCastingState,
|
|
@@ -419,6 +470,14 @@ export function usePresentationCast({
|
|
|
419
470
|
);
|
|
420
471
|
}, [currentSlide, currentStep, isCasting]);
|
|
421
472
|
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
if (!isCasting) return;
|
|
475
|
+
sendPresentationColorModeToConnection(
|
|
476
|
+
connectionRef.current,
|
|
477
|
+
currentColorMode,
|
|
478
|
+
);
|
|
479
|
+
}, [currentColorMode, isCasting]);
|
|
480
|
+
|
|
422
481
|
useEffect(() => {
|
|
423
482
|
isMountedRef.current = true;
|
|
424
483
|
return () => {
|
|
@@ -427,9 +486,13 @@ export function usePresentationCast({
|
|
|
427
486
|
};
|
|
428
487
|
}, [stopCasting]);
|
|
429
488
|
|
|
489
|
+
const sendMessage = useCallback((message: SyncMessage) => {
|
|
490
|
+
sendPresentationMessage(connectionRef.current, message);
|
|
491
|
+
}, []);
|
|
492
|
+
|
|
430
493
|
return useMemo(
|
|
431
|
-
() => ({ supported, isCasting, startCasting, stopCasting }),
|
|
432
|
-
[supported, isCasting, startCasting, stopCasting],
|
|
494
|
+
() => ({ supported, isCasting, startCasting, stopCasting, sendMessage }),
|
|
495
|
+
[supported, isCasting, startCasting, stopCasting, sendMessage],
|
|
433
496
|
);
|
|
434
497
|
}
|
|
435
498
|
|
|
@@ -441,9 +504,52 @@ function isSyncRequestMessage(value: unknown): value is SyncRequestMessage {
|
|
|
441
504
|
|
|
442
505
|
function isSyncMessage(
|
|
443
506
|
value: unknown,
|
|
444
|
-
): value is
|
|
507
|
+
): value is
|
|
508
|
+
| SyncNavigateMessage
|
|
509
|
+
| SyncResponseMessage
|
|
510
|
+
| SyncColorModeMessage
|
|
511
|
+
| SyncBlankScreenMessage {
|
|
445
512
|
if (typeof value !== "object" || value === null) return false;
|
|
446
513
|
if (!("type" in value)) return false;
|
|
447
514
|
const type = (value as SyncMessage).type;
|
|
448
|
-
|
|
515
|
+
if (type === "color-mode") return isColorModeMessage(value);
|
|
516
|
+
if (type === "blank-screen") return isBlankScreenMessage(value);
|
|
517
|
+
if (type !== "navigate" && type !== "sync-response") return false;
|
|
518
|
+
|
|
519
|
+
const slide = (value as { slide?: unknown }).slide;
|
|
520
|
+
const step = (value as { step?: unknown }).step;
|
|
521
|
+
if (
|
|
522
|
+
typeof slide !== "number" ||
|
|
523
|
+
!Number.isFinite(slide) ||
|
|
524
|
+
typeof step !== "number" ||
|
|
525
|
+
!Number.isFinite(step)
|
|
526
|
+
) {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const colorMode = (value as { colorMode?: unknown }).colorMode;
|
|
531
|
+
return colorMode === undefined || isColorMode(colorMode);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function isColorModeMessage(value: unknown): value is SyncColorModeMessage {
|
|
535
|
+
if (typeof value !== "object" || value === null) return false;
|
|
536
|
+
if (!("type" in value)) return false;
|
|
537
|
+
return (
|
|
538
|
+
(value as SyncMessage).type === "color-mode" &&
|
|
539
|
+
isColorMode((value as { colorMode?: unknown }).colorMode)
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function isBlankScreenMessage(value: unknown): value is SyncBlankScreenMessage {
|
|
544
|
+
if (typeof value !== "object" || value === null) return false;
|
|
545
|
+
if (!("type" in value)) return false;
|
|
546
|
+
const mode = (value as { mode?: unknown }).mode;
|
|
547
|
+
return (
|
|
548
|
+
(value as SyncMessage).type === "blank-screen" &&
|
|
549
|
+
(mode === "black" || mode === "off")
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function isColorMode(value: unknown): value is ColorMode {
|
|
554
|
+
return value === "system" || value === "light" || value === "dark";
|
|
449
555
|
}
|