@honeydeck/honeydeck 0.5.0 → 0.7.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 +4 -4
- package/DEVELOPMENT.md +2 -3
- package/Readme.md +15 -15
- package/SPEC.md +2 -1
- package/docs/{components-browser-frame.md → browser-frame.md} +4 -0
- package/docs/components.md +12 -12
- package/docs/configuration.md +2 -0
- package/docs/customization.md +2 -0
- package/docs/deeper-dive.md +2 -0
- package/docs/getting-started.md +2 -0
- package/docs/index.json +258 -0
- package/docs/{components-keyboard.md → keyboard.md} +4 -0
- package/docs/{components-list-style.md → list-style.md} +4 -0
- package/docs/local-development.md +3 -1
- package/docs/mermaid.md +2 -0
- package/docs/mobile.md +3 -1
- package/docs/navigation.md +2 -0
- package/docs/{components-notes.md → notes.md} +4 -0
- package/docs/pdf-export.md +2 -0
- package/docs/presenter-mode.md +14 -6
- package/docs/{components-reveal-group.md → reveal-group.md} +2 -0
- package/docs/{components-reveal-with.md → reveal-with.md} +2 -0
- package/docs/{components-reveal.md → reveal.md} +5 -3
- package/docs/skills.md +3 -1
- package/docs/slides.md +2 -0
- package/docs/slidev-migration.md +2 -0
- package/docs/steps-and-reveals.md +2 -0
- package/docs/{components-timeline-steps.md → timeline-steps.md} +2 -0
- package/package.json +3 -2
- package/skills/SPEC.md +4 -4
- package/skills/honeydeck/SKILL.md +7 -7
- package/skills/slidev-migration/SKILL.md +6 -6
- package/src/runtime/Deck.tsx +18 -1
- package/src/runtime/components/SPEC.md +3 -3
- 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/runtime/sync.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* ### Message types
|
|
11
11
|
* - `navigate` — presenter changed slide/step
|
|
12
|
+
* - `color-mode` — presenter changed configured color mode
|
|
13
|
+
* - `blank-screen` — presenter toggled audience blank screen
|
|
12
14
|
* - `sync-request` — audience asks for the current presenter route
|
|
13
15
|
* - `sync-response` — presenter replies with the current route
|
|
14
16
|
* - `presenter-connected` — a presenter window opened
|
|
@@ -28,7 +30,8 @@
|
|
|
28
30
|
* ```
|
|
29
31
|
*/
|
|
30
32
|
|
|
31
|
-
import { useEffect, useRef, useState } from "react";
|
|
33
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
34
|
+
import type { ColorMode } from "./components/ColorModeCycleButton.tsx";
|
|
32
35
|
import { navigate, parseHash, type Route } from "./router.ts";
|
|
33
36
|
|
|
34
37
|
// ---------------------------------------------------------------------------
|
|
@@ -49,6 +52,17 @@ export type SyncResponseMessage = {
|
|
|
49
52
|
type: "sync-response";
|
|
50
53
|
slide: number;
|
|
51
54
|
step: number;
|
|
55
|
+
colorMode?: ColorMode;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type SyncColorModeMessage = {
|
|
59
|
+
type: "color-mode";
|
|
60
|
+
colorMode: ColorMode;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type SyncBlankScreenMessage = {
|
|
64
|
+
type: "blank-screen";
|
|
65
|
+
mode: "black" | "off";
|
|
52
66
|
};
|
|
53
67
|
|
|
54
68
|
export type SyncPresenceMessage =
|
|
@@ -59,6 +73,8 @@ export type SyncMessage =
|
|
|
59
73
|
| SyncNavigateMessage
|
|
60
74
|
| SyncRequestMessage
|
|
61
75
|
| SyncResponseMessage
|
|
76
|
+
| SyncColorModeMessage
|
|
77
|
+
| SyncBlankScreenMessage
|
|
62
78
|
| SyncPresenceMessage;
|
|
63
79
|
|
|
64
80
|
export type UseSyncOptions = {
|
|
@@ -70,6 +86,12 @@ export type UseSyncOptions = {
|
|
|
70
86
|
currentSlide?: number;
|
|
71
87
|
/** Current 0-based step index (required when `isPresenter: true`). */
|
|
72
88
|
currentStep?: number;
|
|
89
|
+
/** Current configured color mode, broadcast by presenter when supplied. */
|
|
90
|
+
currentColorMode?: ColorMode;
|
|
91
|
+
/** Called by audience windows when presenter color mode changes. */
|
|
92
|
+
onSetColorMode?: (mode: ColorMode) => void;
|
|
93
|
+
/** Called by audience windows when presenter toggles blank screen. */
|
|
94
|
+
onBlankScreen?: (mode: "black" | "off") => void;
|
|
73
95
|
};
|
|
74
96
|
|
|
75
97
|
type PresenterRoute = {
|
|
@@ -89,14 +111,28 @@ export function createSyncRequestMessage(): SyncRequestMessage {
|
|
|
89
111
|
|
|
90
112
|
export function createSyncResponseMessage(
|
|
91
113
|
route: PresenterRoute,
|
|
114
|
+
colorMode?: ColorMode,
|
|
92
115
|
): SyncResponseMessage {
|
|
93
116
|
return {
|
|
94
117
|
type: "sync-response",
|
|
95
118
|
slide: route.slide,
|
|
96
119
|
step: route.step,
|
|
120
|
+
...(colorMode ? { colorMode } : {}),
|
|
97
121
|
};
|
|
98
122
|
}
|
|
99
123
|
|
|
124
|
+
export function createSyncColorModeMessage(
|
|
125
|
+
colorMode: ColorMode,
|
|
126
|
+
): SyncColorModeMessage {
|
|
127
|
+
return { type: "color-mode", colorMode };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createSyncBlankScreenMessage(
|
|
131
|
+
mode: "black" | "off",
|
|
132
|
+
): SyncBlankScreenMessage {
|
|
133
|
+
return { type: "blank-screen", mode };
|
|
134
|
+
}
|
|
135
|
+
|
|
100
136
|
export function resolveAudienceRouteFromSyncMessage(
|
|
101
137
|
currentRoute: Route,
|
|
102
138
|
message: SyncNavigateMessage | SyncResponseMessage,
|
|
@@ -110,6 +146,14 @@ export function resolveAudienceRouteFromSyncMessage(
|
|
|
110
146
|
};
|
|
111
147
|
}
|
|
112
148
|
|
|
149
|
+
function isColorModeValue(value: unknown): value is { colorMode: ColorMode } {
|
|
150
|
+
if (typeof value !== "object" || value === null) return false;
|
|
151
|
+
const colorMode = (value as { colorMode?: unknown }).colorMode;
|
|
152
|
+
return (
|
|
153
|
+
colorMode === "system" || colorMode === "light" || colorMode === "dark"
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
113
157
|
function isSyncMessage(value: unknown): value is SyncMessage {
|
|
114
158
|
if (typeof value !== "object" || value === null) return false;
|
|
115
159
|
if (!("type" in value)) return false;
|
|
@@ -118,17 +162,32 @@ function isSyncMessage(value: unknown): value is SyncMessage {
|
|
|
118
162
|
if (type === "sync-request") return true;
|
|
119
163
|
if (type === "presenter-connected") return true;
|
|
120
164
|
if (type === "presenter-disconnected") return true;
|
|
165
|
+
if (type === "color-mode") return isColorModeValue(value);
|
|
166
|
+
if (type === "blank-screen") return isBlankScreenValue(value);
|
|
121
167
|
|
|
122
168
|
if (type !== "navigate" && type !== "sync-response") return false;
|
|
123
169
|
|
|
124
170
|
const slide = (value as { slide?: unknown }).slide;
|
|
125
171
|
const step = (value as { step?: unknown }).step;
|
|
126
|
-
|
|
127
|
-
typeof slide
|
|
128
|
-
Number.isFinite(slide)
|
|
129
|
-
typeof step
|
|
130
|
-
Number.isFinite(step)
|
|
131
|
-
)
|
|
172
|
+
if (
|
|
173
|
+
typeof slide !== "number" ||
|
|
174
|
+
!Number.isFinite(slide) ||
|
|
175
|
+
typeof step !== "number" ||
|
|
176
|
+
!Number.isFinite(step)
|
|
177
|
+
) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const colorMode = (value as { colorMode?: unknown }).colorMode;
|
|
182
|
+
return colorMode === undefined || isColorModeValue({ colorMode });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isBlankScreenValue(
|
|
186
|
+
value: unknown,
|
|
187
|
+
): value is { mode: "black" | "off" } {
|
|
188
|
+
if (typeof value !== "object" || value === null) return false;
|
|
189
|
+
const mode = (value as { mode?: unknown }).mode;
|
|
190
|
+
return mode === "black" || mode === "off";
|
|
132
191
|
}
|
|
133
192
|
|
|
134
193
|
function isRouteSyncMessage(
|
|
@@ -137,6 +196,18 @@ function isRouteSyncMessage(
|
|
|
137
196
|
return message.type === "navigate" || message.type === "sync-response";
|
|
138
197
|
}
|
|
139
198
|
|
|
199
|
+
function isColorModeSyncMessage(
|
|
200
|
+
message: SyncMessage,
|
|
201
|
+
): message is SyncColorModeMessage {
|
|
202
|
+
return message.type === "color-mode";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isBlankScreenSyncMessage(
|
|
206
|
+
message: SyncMessage,
|
|
207
|
+
): message is SyncBlankScreenMessage {
|
|
208
|
+
return message.type === "blank-screen";
|
|
209
|
+
}
|
|
210
|
+
|
|
140
211
|
// ---------------------------------------------------------------------------
|
|
141
212
|
// Hook
|
|
142
213
|
// ---------------------------------------------------------------------------
|
|
@@ -144,21 +215,31 @@ function isRouteSyncMessage(
|
|
|
144
215
|
/**
|
|
145
216
|
* Bidirectional sync hook.
|
|
146
217
|
*
|
|
147
|
-
* @returns `{ presenterConnected }` —
|
|
148
|
-
* detected that a presenter window is open on
|
|
218
|
+
* @returns `{ presenterConnected, broadcastBlankScreen }` — presenterConnected is
|
|
219
|
+
* true when an audience window has detected that a presenter window is open on
|
|
220
|
+
* the same channel. broadcastBlankScreen sends a blank-screen command.
|
|
149
221
|
*/
|
|
150
222
|
export function useSync({
|
|
151
223
|
enabled = true,
|
|
152
224
|
isPresenter,
|
|
153
225
|
currentSlide,
|
|
154
226
|
currentStep,
|
|
155
|
-
|
|
227
|
+
currentColorMode,
|
|
228
|
+
onSetColorMode,
|
|
229
|
+
onBlankScreen,
|
|
230
|
+
}: UseSyncOptions): {
|
|
231
|
+
presenterConnected: boolean;
|
|
232
|
+
broadcastBlankScreen: (mode: "black" | "off") => void;
|
|
233
|
+
} {
|
|
156
234
|
const [presenterConnected, setPresenterConnected] = useState(false);
|
|
157
235
|
const channelRef = useRef<BroadcastChannel | null>(null);
|
|
158
236
|
const presenterRouteRef = useRef<PresenterRoute>({
|
|
159
237
|
slide: currentSlide ?? 1,
|
|
160
238
|
step: currentStep ?? 0,
|
|
161
239
|
});
|
|
240
|
+
const presenterColorModeRef = useRef<ColorMode | undefined>(currentColorMode);
|
|
241
|
+
const onBlankScreenRef = useRef(onBlankScreen);
|
|
242
|
+
onBlankScreenRef.current = onBlankScreen;
|
|
162
243
|
|
|
163
244
|
useEffect(() => {
|
|
164
245
|
if (!isPresenter) return;
|
|
@@ -170,6 +251,11 @@ export function useSync({
|
|
|
170
251
|
};
|
|
171
252
|
}, [currentSlide, currentStep, isPresenter]);
|
|
172
253
|
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (!isPresenter) return;
|
|
256
|
+
presenterColorModeRef.current = currentColorMode;
|
|
257
|
+
}, [currentColorMode, isPresenter]);
|
|
258
|
+
|
|
173
259
|
// ── Channel lifecycle ───────────────────────────────────────────────────
|
|
174
260
|
|
|
175
261
|
useEffect(() => {
|
|
@@ -199,6 +285,7 @@ export function useSync({
|
|
|
199
285
|
channel.postMessage(
|
|
200
286
|
createSyncResponseMessage(
|
|
201
287
|
presenterRouteRef.current,
|
|
288
|
+
presenterColorModeRef.current,
|
|
202
289
|
) satisfies SyncMessage,
|
|
203
290
|
);
|
|
204
291
|
}
|
|
@@ -207,6 +294,7 @@ export function useSync({
|
|
|
207
294
|
|
|
208
295
|
if (msg.type === "presenter-disconnected") {
|
|
209
296
|
setPresenterConnected(false);
|
|
297
|
+
onBlankScreenRef.current?.("off");
|
|
210
298
|
return;
|
|
211
299
|
}
|
|
212
300
|
|
|
@@ -215,8 +303,23 @@ export function useSync({
|
|
|
215
303
|
return;
|
|
216
304
|
}
|
|
217
305
|
|
|
306
|
+
if (isColorModeSyncMessage(msg)) {
|
|
307
|
+
setPresenterConnected(true);
|
|
308
|
+
onSetColorMode?.(msg.colorMode);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (isBlankScreenSyncMessage(msg)) {
|
|
313
|
+
setPresenterConnected(true);
|
|
314
|
+
onBlankScreenRef.current?.(msg.mode);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
218
318
|
if (isRouteSyncMessage(msg)) {
|
|
219
319
|
setPresenterConnected(true);
|
|
320
|
+
if (msg.type === "sync-response" && msg.colorMode) {
|
|
321
|
+
onSetColorMode?.(msg.colorMode);
|
|
322
|
+
}
|
|
220
323
|
const currentRoute = parseHash(location.hash);
|
|
221
324
|
const nextRoute = resolveAudienceRouteFromSyncMessage(
|
|
222
325
|
currentRoute,
|
|
@@ -241,7 +344,7 @@ export function useSync({
|
|
|
241
344
|
channel.close();
|
|
242
345
|
channelRef.current = null;
|
|
243
346
|
};
|
|
244
|
-
}, [enabled, isPresenter]);
|
|
347
|
+
}, [enabled, isPresenter, onSetColorMode]);
|
|
245
348
|
|
|
246
349
|
// ── Presenter: broadcast navigation changes ─────────────────────────────
|
|
247
350
|
|
|
@@ -263,5 +366,20 @@ export function useSync({
|
|
|
263
366
|
} satisfies SyncMessage);
|
|
264
367
|
}, [enabled, isPresenter, currentSlide, currentStep]);
|
|
265
368
|
|
|
266
|
-
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
if (!enabled || !isPresenter || !currentColorMode) return;
|
|
371
|
+
|
|
372
|
+
presenterColorModeRef.current = currentColorMode;
|
|
373
|
+
channelRef.current?.postMessage(
|
|
374
|
+
createSyncColorModeMessage(currentColorMode) satisfies SyncMessage,
|
|
375
|
+
);
|
|
376
|
+
}, [enabled, isPresenter, currentColorMode]);
|
|
377
|
+
|
|
378
|
+
const broadcastBlankScreen = useCallback((mode: "black" | "off") => {
|
|
379
|
+
channelRef.current?.postMessage(
|
|
380
|
+
createSyncBlankScreenMessage(mode) satisfies SyncMessage,
|
|
381
|
+
);
|
|
382
|
+
}, []);
|
|
383
|
+
|
|
384
|
+
return { presenterConnected, broadcastBlankScreen };
|
|
267
385
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { CastIcon, StopCircleIcon } from "lucide-react";
|
|
2
2
|
|
|
3
|
+
const UNSUPPORTED_HINT =
|
|
4
|
+
"Presentation casting is not supported in this browser";
|
|
5
|
+
|
|
3
6
|
export function PresenterCastButton({
|
|
4
7
|
supported,
|
|
5
8
|
isCasting,
|
|
@@ -12,20 +15,25 @@ export function PresenterCastButton({
|
|
|
12
15
|
onStopCasting: () => void;
|
|
13
16
|
}) {
|
|
14
17
|
const label = isCasting ? "Stop casting" : "Cast audience view";
|
|
15
|
-
const
|
|
16
|
-
|
|
18
|
+
const title = supported ? label : UNSUPPORTED_HINT;
|
|
19
|
+
|
|
20
|
+
function handleClick() {
|
|
21
|
+
if (!supported) return;
|
|
22
|
+
if (isCasting) onStopCasting();
|
|
23
|
+
else void onStartCasting();
|
|
24
|
+
}
|
|
17
25
|
|
|
18
26
|
return (
|
|
19
27
|
<button
|
|
20
28
|
type="button"
|
|
21
|
-
onClick={
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
aria-
|
|
25
|
-
className={`px-3 py-1 rounded border
|
|
29
|
+
onClick={handleClick}
|
|
30
|
+
title={title}
|
|
31
|
+
aria-label={title}
|
|
32
|
+
aria-disabled={!supported}
|
|
33
|
+
className={`px-3 py-1 rounded border text-sm font-[inherit] inline-flex items-center gap-1.5 transition-[background,color,border-color,opacity] ${
|
|
26
34
|
supported
|
|
27
|
-
? "bg-white/6"
|
|
28
|
-
: "
|
|
35
|
+
? "border-white/20 bg-white/6 text-white/80 hover:bg-white/10"
|
|
36
|
+
: "border-white/10 bg-white/3 text-white/25 opacity-60 grayscale cursor-not-allowed"
|
|
29
37
|
}`}
|
|
30
38
|
>
|
|
31
39
|
{label}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* │ │ (large) │ ├──────────┤ │
|
|
12
12
|
* │ │ │ │ Notes │ │
|
|
13
13
|
* │ └──────────────────────┘ └──────────┘ │
|
|
14
|
-
* │ Slide 3/12 · Step 2/4
|
|
14
|
+
* │ Slide 3/12 · Step 2/4 12:34 Timer 1:23 [Open] │
|
|
15
15
|
* └──────────────────────────────────────────┘
|
|
16
16
|
*
|
|
17
17
|
* ### Notes collection
|
|
@@ -35,6 +35,11 @@ import {
|
|
|
35
35
|
ChevronRightIcon,
|
|
36
36
|
ChevronUpIcon,
|
|
37
37
|
ExternalLinkIcon,
|
|
38
|
+
MonitorOffIcon,
|
|
39
|
+
PauseIcon,
|
|
40
|
+
PlayIcon,
|
|
41
|
+
RotateCcwIcon,
|
|
42
|
+
XIcon,
|
|
38
43
|
} from "lucide-react";
|
|
39
44
|
import {
|
|
40
45
|
type ReactNode,
|
|
@@ -44,8 +49,14 @@ import {
|
|
|
44
49
|
useRef,
|
|
45
50
|
useState,
|
|
46
51
|
} from "react";
|
|
52
|
+
import {
|
|
53
|
+
type ColorMode,
|
|
54
|
+
ColorModeCycleButton,
|
|
55
|
+
} from "../components/ColorModeCycleButton.tsx";
|
|
47
56
|
import { NotesContext } from "../components/Notes.tsx";
|
|
48
57
|
import {
|
|
58
|
+
getSlideRouteFromRoute,
|
|
59
|
+
navigateTo,
|
|
49
60
|
nextSlide,
|
|
50
61
|
nextStep,
|
|
51
62
|
openUrlInNewTab,
|
|
@@ -65,6 +76,7 @@ import { useSwipeNav } from "../useSwipeNav.ts";
|
|
|
65
76
|
import { PresenterCastButton } from "./PresenterCastButton.tsx";
|
|
66
77
|
import { PresenterNotesPanel } from "./PresenterNotesPanel.tsx";
|
|
67
78
|
import { getPresenterNextPreview } from "./presenterPreview.ts";
|
|
79
|
+
import { formatPresenterElapsedTime } from "./presenterTime.ts";
|
|
68
80
|
|
|
69
81
|
// ---------------------------------------------------------------------------
|
|
70
82
|
// Helpers
|
|
@@ -163,14 +175,27 @@ function usePresenterMobile(): boolean {
|
|
|
163
175
|
// Component
|
|
164
176
|
// ---------------------------------------------------------------------------
|
|
165
177
|
|
|
166
|
-
|
|
178
|
+
type PresenterViewProps = {
|
|
179
|
+
colorMode: ColorMode;
|
|
180
|
+
onSetColorMode: (mode: ColorMode) => void;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export function PresenterView({
|
|
184
|
+
colorMode,
|
|
185
|
+
onSetColorMode,
|
|
186
|
+
}: PresenterViewProps) {
|
|
167
187
|
const route = useRoute();
|
|
168
188
|
const totalSlides = slideData.length;
|
|
169
189
|
const slide = Math.max(1, Math.min(route.slide, totalSlides || 1));
|
|
170
190
|
const step = Math.max(0, route.step);
|
|
171
191
|
|
|
172
192
|
const [clock, setClock] = useState(() => new Date().toLocaleTimeString());
|
|
193
|
+
const [timerState, setTimerState] = useState<"idle" | "running" | "paused">(
|
|
194
|
+
"idle",
|
|
195
|
+
);
|
|
196
|
+
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
173
197
|
const [notes, setNotes] = useState<ReactNode>(null);
|
|
198
|
+
const [isBlankScreen, setIsBlankScreen] = useState(false);
|
|
174
199
|
const notesContextValue = useMemo(() => ({ setNotes }), []);
|
|
175
200
|
const isMobile = usePresenterMobile();
|
|
176
201
|
|
|
@@ -186,18 +211,27 @@ export function PresenterView() {
|
|
|
186
211
|
audienceUrl: getPresentationAudienceUrl({ view: "slide", slide, step }),
|
|
187
212
|
currentSlide: slide,
|
|
188
213
|
currentStep: step,
|
|
214
|
+
currentColorMode: colorMode,
|
|
189
215
|
});
|
|
216
|
+
const sendCastMessage = presentationCast.sendMessage;
|
|
217
|
+
const elapsedTime = formatPresenterElapsedTime(elapsedSeconds * 1000);
|
|
190
218
|
|
|
191
|
-
// ── Wall clock
|
|
219
|
+
// ── Wall clock + elapsed timer ─────────────────────────────────────────
|
|
192
220
|
useEffect(() => {
|
|
193
221
|
const timer = setInterval(() => {
|
|
194
222
|
setClock(new Date().toLocaleTimeString());
|
|
223
|
+
if (timerState === "running") setElapsedSeconds((value) => value + 1);
|
|
195
224
|
}, 1000);
|
|
196
225
|
return () => clearInterval(timer);
|
|
197
|
-
}, []);
|
|
226
|
+
}, [timerState]);
|
|
198
227
|
|
|
199
228
|
// ── BroadcastChannel: this window IS the presenter (controller) ─────────
|
|
200
|
-
|
|
229
|
+
const { broadcastBlankScreen } = useSync({
|
|
230
|
+
isPresenter: true,
|
|
231
|
+
currentSlide: slide,
|
|
232
|
+
currentStep: step,
|
|
233
|
+
currentColorMode: colorMode,
|
|
234
|
+
});
|
|
201
235
|
|
|
202
236
|
// ── Keyboard navigation ─────────────────────────────────────────────────
|
|
203
237
|
const getStepCount = useCallback(
|
|
@@ -211,6 +245,60 @@ export function PresenterView() {
|
|
|
211
245
|
});
|
|
212
246
|
useSwipeNav({ enabled: isMobile });
|
|
213
247
|
|
|
248
|
+
// ── Presenter exit + blank screen shortcuts ─────────────────────────────
|
|
249
|
+
const closePresenter = useCallback(() => {
|
|
250
|
+
navigateTo(getSlideRouteFromRoute({ view: "presenter", slide, step }));
|
|
251
|
+
}, [slide, step]);
|
|
252
|
+
|
|
253
|
+
const toggleBlankScreen = useCallback(() => {
|
|
254
|
+
const next = !isBlankScreen;
|
|
255
|
+
setIsBlankScreen(next);
|
|
256
|
+
const mode = next ? "black" : "off";
|
|
257
|
+
broadcastBlankScreen(mode);
|
|
258
|
+
sendCastMessage({ type: "blank-screen", mode });
|
|
259
|
+
}, [isBlankScreen, broadcastBlankScreen, sendCastMessage]);
|
|
260
|
+
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
function handlePresenterKeys(event: KeyboardEvent) {
|
|
263
|
+
if (event.key === "p" || event.key === "Escape") {
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
closePresenter();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (event.key === "b" || event.key === "B") {
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
toggleBlankScreen();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
window.addEventListener("keydown", handlePresenterKeys);
|
|
276
|
+
return () => window.removeEventListener("keydown", handlePresenterKeys);
|
|
277
|
+
}, [closePresenter, toggleBlankScreen]);
|
|
278
|
+
|
|
279
|
+
// ── Timer controls ──────────────────────────────────────────────────────
|
|
280
|
+
function startTimer() {
|
|
281
|
+
setTimerState("running");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function pauseTimer() {
|
|
285
|
+
setTimerState("paused");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function continueTimer() {
|
|
289
|
+
setTimerState("running");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function restartTimer() {
|
|
293
|
+
setElapsedSeconds(0);
|
|
294
|
+
setTimerState("running");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function closeTimer() {
|
|
298
|
+
setElapsedSeconds(0);
|
|
299
|
+
setTimerState("idle");
|
|
300
|
+
}
|
|
301
|
+
|
|
214
302
|
// ── Open audience window ────────────────────────────────────────────────
|
|
215
303
|
function openAudienceView() {
|
|
216
304
|
const url = getPresentationAudienceUrl({ view: "slide", slide, step });
|
|
@@ -246,6 +334,13 @@ export function PresenterView() {
|
|
|
246
334
|
|
|
247
335
|
return (
|
|
248
336
|
<div className="fixed inset-0 bg-black text-white font-sans grid grid-rows-[1fr_auto_auto] grid-cols-1 overflow-hidden select-none">
|
|
337
|
+
{/* ── Blank screen indicator overlay ────────────────────────────── */}
|
|
338
|
+
{isBlankScreen && (
|
|
339
|
+
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 px-4 py-2 rounded-md bg-red-600/90 text-white text-sm font-semibold tracking-wide uppercase">
|
|
340
|
+
Screen blanked (b)
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
|
|
249
344
|
{/* ── Top section: current slide plus desktop next/notes column ─── */}
|
|
250
345
|
<div
|
|
251
346
|
className={
|
|
@@ -284,33 +379,152 @@ export function PresenterView() {
|
|
|
284
379
|
/>
|
|
285
380
|
)}
|
|
286
381
|
|
|
287
|
-
{/* ── Bottom bar: counter ·
|
|
288
|
-
<div
|
|
289
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
382
|
+
{/* ── Bottom bar: counter · mobile nav buttons · timer/actions ──── */}
|
|
383
|
+
<div
|
|
384
|
+
className={
|
|
385
|
+
isMobile
|
|
386
|
+
? "grid grid-cols-[minmax(0,1fr)_auto] items-center px-3 py-2.5 border-t border-white/8 bg-black/30 gap-x-3 gap-y-2"
|
|
387
|
+
: "grid grid-cols-[minmax(0,1fr)_auto] items-center px-5 py-2.5 border-t border-white/8 bg-black/30 gap-x-4 gap-y-2"
|
|
388
|
+
}
|
|
389
|
+
>
|
|
390
|
+
{/* Counter + timer */}
|
|
391
|
+
<div className="min-w-0 flex flex-wrap items-center gap-3 text-md text-white/60 tabular-nums">
|
|
392
|
+
<span className="truncate">
|
|
393
|
+
Slide {slide}/{totalSlides}
|
|
394
|
+
{currentStepCount > 0 && ` · Step ${step}/${currentStepCount}`}
|
|
395
|
+
</span>
|
|
396
|
+
{timerState === "idle" && (
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
onClick={startTimer}
|
|
400
|
+
title="Start timer"
|
|
401
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/6 text-white/50 hover:text-white/80 text-sm"
|
|
402
|
+
>
|
|
403
|
+
<PlayIcon aria-hidden="true" size={14} />
|
|
404
|
+
Start timer
|
|
405
|
+
</button>
|
|
406
|
+
)}
|
|
407
|
+
{timerState === "running" && (
|
|
408
|
+
<>
|
|
409
|
+
<button
|
|
410
|
+
type="button"
|
|
411
|
+
onClick={pauseTimer}
|
|
412
|
+
title="Pause timer"
|
|
413
|
+
className="text-lg font-semibold text-white tabular-nums bg-white/10 px-2.5 py-0.5 rounded"
|
|
414
|
+
>
|
|
415
|
+
⏱ {elapsedTime}
|
|
416
|
+
</button>
|
|
417
|
+
<button
|
|
418
|
+
type="button"
|
|
419
|
+
onClick={pauseTimer}
|
|
420
|
+
title="Pause timer"
|
|
421
|
+
aria-label="Pause timer"
|
|
422
|
+
className="text-white/50 hover:text-white/80 inline-flex"
|
|
423
|
+
>
|
|
424
|
+
<PauseIcon aria-hidden="true" size={16} />
|
|
425
|
+
</button>
|
|
426
|
+
</>
|
|
427
|
+
)}
|
|
428
|
+
{timerState === "paused" && (
|
|
429
|
+
<>
|
|
430
|
+
<span className="text-lg font-semibold text-white/60 tabular-nums bg-white/6 px-2.5 py-0.5 rounded">
|
|
431
|
+
⏱ {elapsedTime}
|
|
432
|
+
</span>
|
|
433
|
+
<button
|
|
434
|
+
type="button"
|
|
435
|
+
onClick={continueTimer}
|
|
436
|
+
title="Continue timer"
|
|
437
|
+
aria-label="Continue timer"
|
|
438
|
+
className="text-white/50 hover:text-white/80 inline-flex"
|
|
439
|
+
>
|
|
440
|
+
<PlayIcon aria-hidden="true" size={16} />
|
|
441
|
+
</button>
|
|
442
|
+
<button
|
|
443
|
+
type="button"
|
|
444
|
+
onClick={restartTimer}
|
|
445
|
+
title="Restart timer from zero"
|
|
446
|
+
aria-label="Restart timer from zero"
|
|
447
|
+
className="text-white/30 hover:text-white/60 inline-flex"
|
|
448
|
+
>
|
|
449
|
+
<RotateCcwIcon aria-hidden="true" size={15} />
|
|
450
|
+
</button>
|
|
451
|
+
<button
|
|
452
|
+
type="button"
|
|
453
|
+
onClick={closeTimer}
|
|
454
|
+
title="Close timer"
|
|
455
|
+
aria-label="Close timer"
|
|
456
|
+
className="text-white/30 hover:text-white/60 inline-flex"
|
|
457
|
+
>
|
|
458
|
+
<XIcon aria-hidden="true" size={16} />
|
|
459
|
+
</button>
|
|
460
|
+
</>
|
|
461
|
+
)}
|
|
293
462
|
</div>
|
|
294
463
|
|
|
295
|
-
{/* Nav buttons */}
|
|
296
|
-
|
|
297
|
-
<
|
|
298
|
-
<
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
464
|
+
{/* Nav buttons are mobile-only; desktop uses keyboard shortcuts. */}
|
|
465
|
+
{isMobile && (
|
|
466
|
+
<div className="order-last col-span-2 flex w-full gap-2 items-center justify-center">
|
|
467
|
+
<NavButton
|
|
468
|
+
onClick={goPrev}
|
|
469
|
+
title="Previous step (←)"
|
|
470
|
+
className="h-12 w-16"
|
|
471
|
+
>
|
|
472
|
+
<ChevronLeftIcon aria-hidden="true" className="h-6 w-6" />
|
|
473
|
+
</NavButton>
|
|
474
|
+
<NavButton
|
|
475
|
+
onClick={goPrevSlide}
|
|
476
|
+
title="Previous slide (↑)"
|
|
477
|
+
className="h-12 w-16"
|
|
478
|
+
>
|
|
479
|
+
<ChevronUpIcon aria-hidden="true" className="h-6 w-6" />
|
|
480
|
+
</NavButton>
|
|
481
|
+
<NavButton
|
|
482
|
+
onClick={goNextSlide}
|
|
483
|
+
title="Next slide (↓)"
|
|
484
|
+
className="h-12 w-16"
|
|
485
|
+
>
|
|
486
|
+
<ChevronDownIcon aria-hidden="true" className="h-6 w-6" />
|
|
487
|
+
</NavButton>
|
|
488
|
+
<NavButton
|
|
489
|
+
onClick={goNext}
|
|
490
|
+
title="Next step (→)"
|
|
491
|
+
className="h-12 w-16"
|
|
492
|
+
>
|
|
493
|
+
<ChevronRightIcon aria-hidden="true" className="h-6 w-6" />
|
|
494
|
+
</NavButton>
|
|
495
|
+
</div>
|
|
496
|
+
)}
|
|
310
497
|
|
|
311
|
-
{/* Clock + open
|
|
312
|
-
<div
|
|
313
|
-
|
|
498
|
+
{/* Clock + mode/open/cast buttons */}
|
|
499
|
+
<div
|
|
500
|
+
className={
|
|
501
|
+
isMobile
|
|
502
|
+
? "col-span-2 flex flex-wrap gap-3 items-center justify-self-start"
|
|
503
|
+
: "flex flex-wrap gap-3 items-center justify-self-end"
|
|
504
|
+
}
|
|
505
|
+
>
|
|
506
|
+
<span
|
|
507
|
+
className="text-md tabular-nums text-white/60"
|
|
508
|
+
title="Wall clock"
|
|
509
|
+
>
|
|
510
|
+
{clock}
|
|
511
|
+
</span>
|
|
512
|
+
<ColorModeCycleButton
|
|
513
|
+
colorMode={colorMode}
|
|
514
|
+
onSetColorMode={onSetColorMode}
|
|
515
|
+
iconSize={14}
|
|
516
|
+
className="w-8 h-8 rounded border border-white/20 bg-white/6 text-white/80 inline-flex items-center justify-center"
|
|
517
|
+
/>
|
|
518
|
+
<NavButton
|
|
519
|
+
onClick={toggleBlankScreen}
|
|
520
|
+
title={isBlankScreen ? "Unblank screen (b)" : "Blank screen (b)"}
|
|
521
|
+
>
|
|
522
|
+
<MonitorOffIcon
|
|
523
|
+
aria-hidden="true"
|
|
524
|
+
size={16}
|
|
525
|
+
className={isBlankScreen ? "text-red-400" : ""}
|
|
526
|
+
/>
|
|
527
|
+
</NavButton>
|
|
314
528
|
<button
|
|
315
529
|
type="button"
|
|
316
530
|
onClick={openAudienceView}
|
|
@@ -338,10 +552,12 @@ export function PresenterView() {
|
|
|
338
552
|
function NavButton({
|
|
339
553
|
onClick,
|
|
340
554
|
title,
|
|
555
|
+
className,
|
|
341
556
|
children,
|
|
342
557
|
}: {
|
|
343
558
|
onClick: () => void;
|
|
344
559
|
title?: string;
|
|
560
|
+
className?: string;
|
|
345
561
|
children: ReactNode;
|
|
346
562
|
}) {
|
|
347
563
|
return (
|
|
@@ -349,7 +565,8 @@ function NavButton({
|
|
|
349
565
|
type="button"
|
|
350
566
|
onClick={onClick}
|
|
351
567
|
title={title}
|
|
352
|
-
|
|
568
|
+
aria-label={title}
|
|
569
|
+
className={`w-9 h-9 rounded border border-white/15 bg-white/6 text-white/75 text-md flex items-center justify-center font-[inherit] ${className ?? ""}`}
|
|
353
570
|
>
|
|
354
571
|
{children}
|
|
355
572
|
</button>
|