@slidev-react/client 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +21 -0
  3. package/README.md +16 -0
  4. package/package.json +44 -0
  5. package/src/addons/AddonProvider.tsx +25 -0
  6. package/src/addons/g2/G2Chart.tsx +370 -0
  7. package/src/addons/g2/chartPresets.ts +43 -0
  8. package/src/addons/g2/chartThemeTokens.ts +124 -0
  9. package/src/addons/g2/index.ts +36 -0
  10. package/src/addons/g2/style.css +31 -0
  11. package/src/addons/insight/Insight.tsx +10 -0
  12. package/src/addons/insight/InsightAddonProvider.tsx +20 -0
  13. package/src/addons/insight/SpotlightLayout.tsx +11 -0
  14. package/src/addons/insight/index.ts +17 -0
  15. package/src/addons/insight/style.css +34 -0
  16. package/src/addons/mermaid/MermaidDiagram.tsx +379 -0
  17. package/src/addons/mermaid/index.ts +10 -0
  18. package/src/addons/registry.test.ts +28 -0
  19. package/src/addons/registry.ts +61 -0
  20. package/src/addons/types.ts +6 -0
  21. package/src/app/App.tsx +125 -0
  22. package/src/app/README.md +18 -0
  23. package/src/app/providers/SlidesNavigationProvider.tsx +82 -0
  24. package/src/app/usePresentationBootstrap.ts +85 -0
  25. package/src/features/presentation/PresentationStatus.tsx +514 -0
  26. package/src/features/presentation/PrintSlidesView.tsx +350 -0
  27. package/src/features/presentation/browser.ts +5 -0
  28. package/src/features/presentation/draw/DrawOverlay.tsx +170 -0
  29. package/src/features/presentation/draw/DrawProvider.tsx +394 -0
  30. package/src/features/presentation/draw/persistence.test.ts +80 -0
  31. package/src/features/presentation/draw/persistence.ts +54 -0
  32. package/src/features/presentation/exportArtifacts.test.ts +48 -0
  33. package/src/features/presentation/exportArtifacts.ts +6 -0
  34. package/src/features/presentation/location.test.ts +73 -0
  35. package/src/features/presentation/location.ts +113 -0
  36. package/src/features/presentation/navigation/KeyboardController.tsx +73 -0
  37. package/src/features/presentation/navigation/PresentationNavbar.tsx +162 -0
  38. package/src/features/presentation/navigation/ShortcutsHelpOverlay.test.tsx +24 -0
  39. package/src/features/presentation/navigation/ShortcutsHelpOverlay.tsx +111 -0
  40. package/src/features/presentation/navigation/keyboardShortcuts.test.ts +74 -0
  41. package/src/features/presentation/navigation/keyboardShortcuts.ts +221 -0
  42. package/src/features/presentation/navigation/useSlidesNavigation.ts +15 -0
  43. package/src/features/presentation/overview/NotesOverview.tsx +200 -0
  44. package/src/features/presentation/overview/QuickOverview.tsx +126 -0
  45. package/src/features/presentation/path.ts +137 -0
  46. package/src/features/presentation/presenter/FlowTimelinePreview.test.tsx +54 -0
  47. package/src/features/presentation/presenter/FlowTimelinePreview.tsx +274 -0
  48. package/src/features/presentation/presenter/PresenterModeView.tsx +93 -0
  49. package/src/features/presentation/presenter/PresenterShell.tsx +286 -0
  50. package/src/features/presentation/presenter/PresenterSidePreview.tsx +68 -0
  51. package/src/features/presentation/presenter/PresenterTopProgress.tsx +28 -0
  52. package/src/features/presentation/presenter/SpeakerNotesPanel.tsx +51 -0
  53. package/src/features/presentation/presenter/StandaloneModeView.tsx +36 -0
  54. package/src/features/presentation/presenter/persistence.test.ts +26 -0
  55. package/src/features/presentation/presenter/persistence.ts +31 -0
  56. package/src/features/presentation/presenter/presentationSyncBridge.test.ts +87 -0
  57. package/src/features/presentation/presenter/presentationSyncBridge.ts +82 -0
  58. package/src/features/presentation/presenter/stage.ts +15 -0
  59. package/src/features/presentation/presenter/types.ts +30 -0
  60. package/src/features/presentation/presenter/useFullscreen.ts +58 -0
  61. package/src/features/presentation/presenter/useIdleCursor.ts +37 -0
  62. package/src/features/presentation/presenter/usePresentationFlowRuntime.ts +238 -0
  63. package/src/features/presentation/presenter/usePresenterChromeRuntime.ts +358 -0
  64. package/src/features/presentation/presenter/usePresenterSessionState.ts +226 -0
  65. package/src/features/presentation/presenter/useWakeLock.ts +110 -0
  66. package/src/features/presentation/recordingFilename.test.ts +46 -0
  67. package/src/features/presentation/recordingFilename.ts +56 -0
  68. package/src/features/presentation/reveal/Reveal.tsx +119 -0
  69. package/src/features/presentation/reveal/RevealContext.tsx +29 -0
  70. package/src/features/presentation/reveal/useRevealStep.ts +35 -0
  71. package/src/features/presentation/session.test.ts +122 -0
  72. package/src/features/presentation/session.ts +124 -0
  73. package/src/features/presentation/stage/SlidePreviewSurface.tsx +92 -0
  74. package/src/features/presentation/stage/SlideStage.tsx +159 -0
  75. package/src/features/presentation/stage/slideSurface.ts +71 -0
  76. package/src/features/presentation/stage/slideViewport.tsx +47 -0
  77. package/src/features/presentation/sync/adapters/broadcastChannelTransport.ts +40 -0
  78. package/src/features/presentation/sync/adapters/websocketTransport.ts +128 -0
  79. package/src/features/presentation/sync/model/presence.test.ts +42 -0
  80. package/src/features/presentation/sync/model/presence.ts +33 -0
  81. package/src/features/presentation/sync/model/replication.test.ts +72 -0
  82. package/src/features/presentation/sync/model/replication.ts +113 -0
  83. package/src/features/presentation/sync/model/status.test.ts +52 -0
  84. package/src/features/presentation/sync/model/status.ts +33 -0
  85. package/src/features/presentation/types.ts +1 -0
  86. package/src/features/presentation/usePresentationRecorder.ts +194 -0
  87. package/src/features/presentation/usePresentationSync.ts +423 -0
  88. package/src/index.ts +7 -0
  89. package/src/main.tsx +12 -0
  90. package/src/theme/ThemeProvider.test.ts +36 -0
  91. package/src/theme/ThemeProvider.tsx +79 -0
  92. package/src/theme/__mocks__/active-theme.ts +3 -0
  93. package/src/theme/base.css +14 -0
  94. package/src/theme/components.css +231 -0
  95. package/src/theme/index.css +11 -0
  96. package/src/theme/layouts/center.tsx +9 -0
  97. package/src/theme/layouts/cover.tsx +9 -0
  98. package/src/theme/layouts/default.tsx +5 -0
  99. package/src/theme/layouts/defaultLayouts.ts +20 -0
  100. package/src/theme/layouts/helpers.tsx +12 -0
  101. package/src/theme/layouts/image-right.tsx +21 -0
  102. package/src/theme/layouts/immersive.tsx +9 -0
  103. package/src/theme/layouts/resolveLayout.ts +9 -0
  104. package/src/theme/layouts/section.tsx +9 -0
  105. package/src/theme/layouts/statement.tsx +9 -0
  106. package/src/theme/layouts/two-cols.tsx +21 -0
  107. package/src/theme/layouts/types.ts +1 -0
  108. package/src/theme/layouts.css +133 -0
  109. package/src/theme/mark.css +379 -0
  110. package/src/theme/print.css +106 -0
  111. package/src/theme/prose.css +263 -0
  112. package/src/theme/registry.test.ts +21 -0
  113. package/src/theme/registry.ts +40 -0
  114. package/src/theme/tokens.css +148 -0
  115. package/src/theme/transitions.css +141 -0
  116. package/src/theme/types.ts +9 -0
  117. package/src/theme/useResolvedLayout.ts +24 -0
  118. package/src/types/generated-slides.d.ts +7 -0
  119. package/src/types/mdx-components.ts +7 -0
  120. package/src/types/plantuml-encoder.d.ts +7 -0
  121. package/src/ui/diagrams/PlantUmlDiagram.tsx +33 -0
  122. package/src/ui/mdx/MagicMoveDemo.tsx +114 -0
  123. package/src/ui/mdx/index.ts +21 -0
  124. package/src/ui/primitives/Annotate.test.tsx +64 -0
  125. package/src/ui/primitives/Annotate.tsx +82 -0
  126. package/src/ui/primitives/Badge.tsx +5 -0
  127. package/src/ui/primitives/Callout.tsx +24 -0
  128. package/src/ui/primitives/ChromeIconButton.tsx +58 -0
  129. package/src/ui/primitives/ChromePanel.tsx +79 -0
  130. package/src/ui/primitives/ChromeTag.tsx +70 -0
  131. package/src/ui/primitives/FormSelect.tsx +51 -0
@@ -0,0 +1,514 @@
1
+ import {
2
+ Circle,
3
+ CircleDot,
4
+ Copy,
5
+ Eraser,
6
+ Expand,
7
+ Keyboard,
8
+ LayoutGrid,
9
+ List,
10
+ Link2,
11
+ NotebookText,
12
+ PenLine,
13
+ Printer,
14
+ Radio,
15
+ RectangleHorizontal,
16
+ RotateCcw,
17
+ Square,
18
+ SunMedium,
19
+ Trash2,
20
+ Wifi,
21
+ WifiOff,
22
+ } from "lucide-react";
23
+ import { useMemo, useState } from "react";
24
+ import { useDraw } from "./draw/DrawProvider";
25
+ import type { PresentationSession } from "./session";
26
+ import type { PresentationSyncMode } from "./types";
27
+ import type { PresentationSyncStatus, UsePresentationSyncResult } from "./usePresentationSync";
28
+ import type { PresentationRecorderRuntime } from "./usePresentationRecorder";
29
+ import type { WakeLockRuntime } from "./presenter/useWakeLock";
30
+ import type { FullscreenRuntime } from "./presenter/useFullscreen";
31
+ import { ChromeIconButton } from "../../ui/primitives/ChromeIconButton";
32
+ import { ChromeTag } from "../../ui/primitives/ChromeTag";
33
+ import { FormSelect } from "../../ui/primitives/FormSelect";
34
+
35
+ const DRAW_COLORS = ["#ef4444", "#3b82f6", "#22c55e", "#f59e0b", "#111827"];
36
+ const DRAW_WIDTHS = [3, 5, 8];
37
+
38
+ interface PresenterChromeProps {
39
+ stageScale: number;
40
+ cursorMode: "always" | "idle-hide";
41
+ timelinePreviewOpen: boolean;
42
+ overviewOpen: boolean;
43
+ notesOpen: boolean;
44
+ shortcutsOpen: boolean;
45
+ canOpenOverview: boolean;
46
+ onToggleTimelinePreview: () => void;
47
+ onToggleOverview: () => void;
48
+ onToggleNotes: () => void;
49
+ onToggleShortcuts: () => void;
50
+ onStageScaleChange: (value: number) => void;
51
+ onCursorModeChange: (value: "always" | "idle-hide") => void;
52
+ }
53
+
54
+ export interface PresentationStatusProps {
55
+ slideId: string;
56
+ session: PresentationSession;
57
+ sync: UsePresentationSyncResult;
58
+ recorder: PresentationRecorderRuntime;
59
+ wakeLock: WakeLockRuntime;
60
+ fullscreen: FullscreenRuntime;
61
+ chrome: PresenterChromeProps;
62
+ sessionTimerSeconds: number;
63
+ canRecord: boolean;
64
+ onOpenMirrorStage?: () => void;
65
+ onOpenPrintExport?: () => void;
66
+ onSyncModeChange?: (mode: PresentationSyncMode) => void;
67
+ }
68
+
69
+ function badgeClassName(status: PresentationSyncStatus) {
70
+ switch (status) {
71
+ case "connected":
72
+ return "border-emerald-200 bg-emerald-50 text-emerald-700";
73
+ case "degraded":
74
+ return "border-amber-200 bg-amber-50 text-amber-700";
75
+ case "connecting":
76
+ return "border-green-200 bg-green-50 text-green-700";
77
+ default:
78
+ return "border-slate-200 bg-slate-50 text-slate-700";
79
+ }
80
+ }
81
+
82
+ function statusDotClassName(status: PresentationSyncStatus) {
83
+ switch (status) {
84
+ case "connected":
85
+ return "bg-emerald-400";
86
+ case "degraded":
87
+ return "bg-amber-400";
88
+ case "connecting":
89
+ return "bg-green-400";
90
+ default:
91
+ return "bg-slate-400";
92
+ }
93
+ }
94
+
95
+ function formatTimer(seconds: number) {
96
+ const minutes = String(Math.floor(seconds / 60)).padStart(2, "0");
97
+ const restSeconds = String(seconds % 60).padStart(2, "0");
98
+ return `${minutes}:${restSeconds}`;
99
+ }
100
+
101
+ export function PresentationStatus({
102
+ slideId,
103
+ session,
104
+ sync,
105
+ recorder,
106
+ wakeLock,
107
+ fullscreen,
108
+ chrome,
109
+ sessionTimerSeconds,
110
+ canRecord,
111
+ onOpenMirrorStage,
112
+ onOpenPrintExport,
113
+ onSyncModeChange,
114
+ }: PresentationStatusProps) {
115
+ const draw = useDraw();
116
+ const [copiedTarget, setCopiedTarget] = useState<"viewer" | null>(null);
117
+ const [detailsOpen, setDetailsOpen] = useState(false);
118
+ const strokeCount = draw.strokesBySlideId[slideId]?.length ?? 0;
119
+ const hasStrokes = strokeCount > 0;
120
+
121
+ const statusLabel = useMemo(() => {
122
+ switch (sync.status) {
123
+ case "connected":
124
+ return "Connected";
125
+ case "connecting":
126
+ return "Connecting";
127
+ case "degraded":
128
+ return "Degraded";
129
+ default:
130
+ return "Disabled";
131
+ }
132
+ }, [sync.status]);
133
+
134
+ if (!session.enabled || !session.sessionId) return null;
135
+
136
+ const canCopyViewerLink = session.role === "presenter" && !!session.viewerUrl;
137
+
138
+ return (
139
+ <aside className="pointer-events-none absolute inset-x-0 bottom-0 z-40">
140
+ <div className="relative">
141
+ {detailsOpen && (
142
+ <div className="pointer-events-auto absolute inset-x-0 bottom-full mb-2 border-t border-slate-200/80 bg-slate-50/72 px-3 py-3 text-slate-800 ring-1 ring-white/45 backdrop-blur-xl">
143
+ <div className="mb-3 flex flex-wrap items-center gap-2">
144
+ <ChromeTag size="md" weight="semibold" className="uppercase tracking-[0.18em]">
145
+ <span className={`size-2.5 rounded-full ${statusDotClassName(sync.status)}`} />
146
+ Live
147
+ <span
148
+ className={`rounded-[4px] border px-2 py-0.5 text-[10px] font-medium normal-case tracking-normal ${badgeClassName(sync.status)}`}
149
+ >
150
+ {statusLabel}
151
+ </span>
152
+ </ChromeTag>
153
+ <FormSelect
154
+ label="sync"
155
+ size="sm"
156
+ value={session.syncMode}
157
+ onChange={(event) => {
158
+ onSyncModeChange?.(event.target.value as PresentationSyncMode);
159
+ }}
160
+ >
161
+ <option value="send">send</option>
162
+ <option value="receive">receive</option>
163
+ <option value="both">both</option>
164
+ <option value="off">off</option>
165
+ </FormSelect>
166
+ {canCopyViewerLink && (
167
+ <button
168
+ type="button"
169
+ onClick={async () => {
170
+ if (!session.viewerUrl) return;
171
+
172
+ try {
173
+ await navigator.clipboard.writeText(session.viewerUrl);
174
+ setCopiedTarget("viewer");
175
+ window.setTimeout(
176
+ () => setCopiedTarget((value) => (value === "viewer" ? null : value)),
177
+ 1200,
178
+ );
179
+ } catch {
180
+ setCopiedTarget(null);
181
+ }
182
+ }}
183
+ className="inline-flex items-center justify-center gap-1.5 rounded-md border border-slate-200 bg-white/88 px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-white"
184
+ >
185
+ {copiedTarget === "viewer" ? <Copy size={12} /> : <Link2 size={12} />}
186
+ {copiedTarget === "viewer" ? "Viewer copied" : "Copy viewer link"}
187
+ </button>
188
+ )}
189
+ {onOpenMirrorStage && (
190
+ <button
191
+ type="button"
192
+ onClick={onOpenMirrorStage}
193
+ className="inline-flex items-center justify-center gap-1.5 rounded-md border border-slate-200 bg-white/88 px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-white"
194
+ >
195
+ <Link2 size={12} />
196
+ Open mirror stage
197
+ </button>
198
+ )}
199
+ </div>
200
+ <div className="mb-3 grid gap-2 sm:grid-cols-2">
201
+ <FormSelect
202
+ label="stage scale"
203
+ size="sm"
204
+ value={String(chrome.stageScale)}
205
+ onChange={(event) => {
206
+ chrome.onStageScaleChange(Number(event.target.value));
207
+ }}
208
+ >
209
+ <option value="0.9">90%</option>
210
+ <option value="1">100%</option>
211
+ <option value="1.08">108%</option>
212
+ </FormSelect>
213
+ <FormSelect
214
+ label="cursor"
215
+ size="sm"
216
+ value={chrome.cursorMode}
217
+ onChange={(event) => {
218
+ chrome.onCursorModeChange(event.target.value as "always" | "idle-hide");
219
+ }}
220
+ >
221
+ <option value="always">always visible</option>
222
+ <option value="idle-hide">hide when idle</option>
223
+ </FormSelect>
224
+ <span
225
+ className={`inline-flex items-center gap-2 rounded-md border px-3 py-2 text-xs ${
226
+ fullscreen.supported
227
+ ? fullscreen.active
228
+ ? "border-emerald-200 bg-emerald-50 text-emerald-700"
229
+ : "border-slate-200 bg-white/82 text-slate-600"
230
+ : "border-amber-200 bg-amber-50 text-amber-700"
231
+ }`}
232
+ >
233
+ fullscreen:{" "}
234
+ {fullscreen.supported ? (fullscreen.active ? "active" : "off") : "unsupported"}
235
+ </span>
236
+ <span
237
+ className={`inline-flex items-center gap-2 rounded-md border px-3 py-2 text-xs ${
238
+ wakeLock.supported
239
+ ? wakeLock.active
240
+ ? "border-emerald-200 bg-emerald-50 text-emerald-700"
241
+ : "border-slate-200 bg-white/82 text-slate-600"
242
+ : "border-amber-200 bg-amber-50 text-amber-700"
243
+ }`}
244
+ >
245
+ wake lock:{" "}
246
+ {wakeLock.supported
247
+ ? wakeLock.requested || wakeLock.active
248
+ ? wakeLock.active
249
+ ? "active"
250
+ : "requesting"
251
+ : "off"
252
+ : "unsupported"}
253
+ </span>
254
+ </div>
255
+ <div className="grid gap-2 sm:grid-cols-2">
256
+ <ChromeTag tone="muted" size="md" className="py-2 text-xs">
257
+ {sync.broadcastConnected ? <Wifi size={12} /> : <WifiOff size={12} />}
258
+ {sync.broadcastConnected ? "Broadcast connected" : "Broadcast unavailable"}
259
+ </ChromeTag>
260
+ <ChromeTag tone="muted" size="md" className="py-2 text-xs">
261
+ ws: {sync.wsConnected ? "connected" : "idle"}
262
+ </ChromeTag>
263
+ <ChromeTag tone="muted" size="md" className="py-2 text-xs tabular-nums">
264
+ peers: {sync.peerCount}
265
+ </ChromeTag>
266
+ <ChromeTag
267
+ tone={sync.remoteActive ? "success" : "warning"}
268
+ size="md"
269
+ className="py-2 text-xs"
270
+ >
271
+ remote: {sync.remoteActive ? "active" : "stale"}
272
+ </ChromeTag>
273
+ <ChromeTag tone="muted" size="md" className="py-2 text-xs">
274
+ role: {session.role}
275
+ </ChromeTag>
276
+ <ChromeTag tone="muted" size="md" className="py-2 font-mono text-[11px]">
277
+ {session.sessionId}
278
+ </ChromeTag>
279
+ </div>
280
+ {sync.lastSyncedAt && (
281
+ <p className="mt-3 text-right text-[11px] text-slate-500">
282
+ last sync {new Date(sync.lastSyncedAt).toLocaleTimeString()}
283
+ </p>
284
+ )}
285
+ </div>
286
+ )}
287
+ <div className="pointer-events-auto w-full overflow-hidden rounded-t-[6px] border border-b-0 border-slate-200/80 bg-white/82 text-slate-800 ring-1 ring-white/45 backdrop-blur-xl">
288
+ <div className="flex flex-wrap items-center justify-between gap-3 px-3 py-3">
289
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
290
+ {canRecord && (
291
+ <>
292
+ <ChromeIconButton
293
+ onClick={draw.toggleEnabled}
294
+ title="Toggle draw (D)"
295
+ aria-label="Toggle draw mode"
296
+ tone={draw.enabled ? "active" : "default"}
297
+ >
298
+ <PenLine size={16} />
299
+ </ChromeIconButton>
300
+ {draw.enabled && (
301
+ <>
302
+ <ChromeIconButton
303
+ onClick={() => draw.setTool("pen")}
304
+ title="Pen (P)"
305
+ aria-label="Use pen tool"
306
+ tone={draw.tool === "pen" ? "active" : "default"}
307
+ >
308
+ <PenLine size={15} />
309
+ </ChromeIconButton>
310
+ <ChromeIconButton
311
+ onClick={() => draw.setTool("circle")}
312
+ title="Circle (B)"
313
+ aria-label="Use circle tool"
314
+ tone={draw.tool === "circle" ? "active" : "default"}
315
+ >
316
+ <Circle size={15} />
317
+ </ChromeIconButton>
318
+ <ChromeIconButton
319
+ onClick={() => draw.setTool("rectangle")}
320
+ title="Rectangle (R)"
321
+ aria-label="Use rectangle tool"
322
+ tone={draw.tool === "rectangle" ? "active" : "default"}
323
+ >
324
+ <RectangleHorizontal size={15} />
325
+ </ChromeIconButton>
326
+ <ChromeIconButton
327
+ onClick={() => draw.setTool("eraser")}
328
+ title="Eraser (E)"
329
+ aria-label="Use eraser tool"
330
+ tone={draw.tool === "eraser" ? "active" : "default"}
331
+ >
332
+ <Eraser size={15} />
333
+ </ChromeIconButton>
334
+ <div className="mx-1 h-6 w-px bg-slate-200" aria-hidden />
335
+ {DRAW_COLORS.map((color) => (
336
+ <button
337
+ key={color}
338
+ type="button"
339
+ onClick={() => {
340
+ draw.setColor(color);
341
+ draw.setTool("pen");
342
+ }}
343
+ title={`Set draw color ${color}`}
344
+ aria-label={`Set draw color ${color}`}
345
+ className={`inline-flex size-5 items-center justify-center rounded-full border shadow-sm transition ${draw.color === color ? "border-slate-700 ring-2 ring-emerald-300" : "border-slate-300 opacity-90 hover:opacity-100"}`}
346
+ style={{ backgroundColor: color }}
347
+ />
348
+ ))}
349
+ <div className="mx-1 h-6 w-px bg-slate-200" aria-hidden />
350
+ {DRAW_WIDTHS.map((value) => (
351
+ <ChromeIconButton
352
+ key={value}
353
+ onClick={() => {
354
+ draw.setWidth(value);
355
+ draw.setTool("pen");
356
+ }}
357
+ title={`Set brush size ${value}`}
358
+ aria-label={`Set brush size ${value}`}
359
+ tone={draw.width === value ? "active" : "default"}
360
+ >
361
+ <span
362
+ className="rounded-full bg-current"
363
+ style={{
364
+ width: `${Math.max(value + 2, 6)}px`,
365
+ height: `${Math.max(value + 2, 6)}px`,
366
+ }}
367
+ />
368
+ </ChromeIconButton>
369
+ ))}
370
+ <div className="mx-1 h-6 w-px bg-slate-200" aria-hidden />
371
+ <ChromeIconButton
372
+ onClick={() => draw.undo(slideId)}
373
+ disabled={!hasStrokes}
374
+ title="Undo last stroke (Cmd/Ctrl+Z)"
375
+ aria-label="Undo last stroke"
376
+ >
377
+ <RotateCcw size={15} />
378
+ </ChromeIconButton>
379
+ <ChromeIconButton
380
+ onClick={() => draw.clear(slideId)}
381
+ disabled={!hasStrokes}
382
+ title="Clear page strokes (C)"
383
+ aria-label="Clear page strokes"
384
+ >
385
+ <Trash2 size={15} />
386
+ </ChromeIconButton>
387
+ </>
388
+ )}
389
+ </>
390
+ )}
391
+ </div>
392
+ <div className="flex flex-wrap items-center justify-end gap-2">
393
+ <ChromeTag
394
+ tone="defaultStrong"
395
+ size="md"
396
+ weight="semibold"
397
+ className="h-9 px-3.5 text-sm tabular-nums"
398
+ >
399
+ <Radio size={13} />
400
+ {formatTimer(sessionTimerSeconds)}
401
+ </ChromeTag>
402
+ {canRecord && recorder.supported && (
403
+ <ChromeIconButton
404
+ onClick={() => {
405
+ if (recorder.isRecording) void recorder.stop();
406
+ else void recorder.start();
407
+ }}
408
+ title={recorder.isRecording ? "Stop recording" : "Start recording"}
409
+ aria-label={recorder.isRecording ? "Stop recording" : "Start recording"}
410
+ tone={recorder.isRecording ? "danger" : "default"}
411
+ >
412
+ {recorder.isRecording ? <Square size={12} /> : <CircleDot size={12} />}
413
+ </ChromeIconButton>
414
+ )}
415
+ {canRecord && onOpenPrintExport && (
416
+ <ChromeIconButton
417
+ onClick={onOpenPrintExport}
418
+ title="Print / PDF"
419
+ aria-label="Print / PDF"
420
+ >
421
+ <Printer size={12} />
422
+ </ChromeIconButton>
423
+ )}
424
+ <ChromeIconButton
425
+ onClick={chrome.onToggleNotes}
426
+ title="Notes Workspace (N)"
427
+ aria-label="Toggle notes workspace"
428
+ tone={chrome.notesOpen ? "active" : "default"}
429
+ >
430
+ <NotebookText size={13} />
431
+ </ChromeIconButton>
432
+ <ChromeIconButton
433
+ onClick={chrome.onToggleOverview}
434
+ disabled={!chrome.canOpenOverview}
435
+ title="Quick Overview (O)"
436
+ aria-label="Toggle quick overview"
437
+ tone={chrome.overviewOpen ? "active" : "default"}
438
+ >
439
+ <LayoutGrid size={13} />
440
+ </ChromeIconButton>
441
+ <ChromeIconButton
442
+ onClick={chrome.onToggleShortcuts}
443
+ title="Keyboard shortcuts (?)"
444
+ aria-label="Toggle keyboard shortcuts"
445
+ tone={chrome.shortcutsOpen ? "active" : "default"}
446
+ >
447
+ <Keyboard size={13} />
448
+ </ChromeIconButton>
449
+ <ChromeIconButton
450
+ onClick={chrome.onToggleTimelinePreview}
451
+ title={chrome.timelinePreviewOpen ? "Hide timeline preview" : "Show timeline preview"}
452
+ aria-label={chrome.timelinePreviewOpen ? "Hide timeline preview" : "Show timeline preview"}
453
+ tone={chrome.timelinePreviewOpen ? "violet" : "default"}
454
+ >
455
+ <List size={13} />
456
+ </ChromeIconButton>
457
+ <ChromeIconButton
458
+ onClick={() => {
459
+ void wakeLock.toggle();
460
+ }}
461
+ disabled={!wakeLock.supported}
462
+ title={
463
+ wakeLock.supported
464
+ ? wakeLock.active
465
+ ? "Wake lock on"
466
+ : "Turn on wake lock"
467
+ : "Wake lock unsupported"
468
+ }
469
+ aria-label={
470
+ wakeLock.supported
471
+ ? wakeLock.active
472
+ ? "Wake lock on"
473
+ : "Turn on wake lock"
474
+ : "Wake lock unsupported"
475
+ }
476
+ tone={wakeLock.active ? "success" : "default"}
477
+ >
478
+ <SunMedium size={13} />
479
+ </ChromeIconButton>
480
+ <ChromeIconButton
481
+ onClick={() => {
482
+ void fullscreen.toggle();
483
+ }}
484
+ disabled={!fullscreen.supported}
485
+ title={fullscreen.active ? "Fullscreen on" : "Fullscreen"}
486
+ aria-label={fullscreen.active ? "Fullscreen on" : "Fullscreen"}
487
+ tone={fullscreen.active ? "info" : "default"}
488
+ >
489
+ <Expand size={12} />
490
+ </ChromeIconButton>
491
+ <ChromeIconButton
492
+ onClick={() => setDetailsOpen((value) => !value)}
493
+ title={detailsOpen ? "Hide live details" : "Show live details"}
494
+ aria-label={detailsOpen ? "Hide live details" : "Show live details"}
495
+ >
496
+ <span className="relative inline-flex items-center justify-center">
497
+ <Radio size={13} />
498
+ <span
499
+ className={`absolute right-0 bottom-0 size-2 rounded-full ring-2 ring-white ${statusDotClassName(sync.status)}`}
500
+ />
501
+ </span>
502
+ </ChromeIconButton>
503
+ </div>
504
+ {canRecord && !recorder.supported && (
505
+ <span className="text-xs text-amber-700">Recording unsupported in this browser.</span>
506
+ )}
507
+ {recorder.error && <span className="text-xs text-rose-700">{recorder.error}</span>}
508
+ {wakeLock.error && <span className="text-xs text-amber-700">{wakeLock.error}</span>}
509
+ </div>
510
+ </div>
511
+ </div>
512
+ </aside>
513
+ );
514
+ }