@open-slide/core 0.0.11 → 0.0.13

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 (89) hide show
  1. package/dist/{build-DHiRlpjn.js → build-DC3FTpWO.js} +2 -1
  2. package/dist/cli/bin.js +43 -4
  3. package/dist/{config-LZM903FE.js → config-Cuw0mC5h.js} +592 -63
  4. package/dist/design-BUML7uvZ.js +35 -0
  5. package/dist/{dev-B3JzCYn7.js → dev-BuWsdYvn.js} +2 -1
  6. package/dist/index.d.ts +55 -4
  7. package/dist/index.js +110 -1
  8. package/dist/{preview-UikovHEt.js → preview-CIcG-lP3.js} +2 -1
  9. package/dist/sync-3oqN1WyK.js +139 -0
  10. package/dist/sync-B4eLo2H6.js +3 -0
  11. package/dist/vite/index.d.ts +1 -1
  12. package/dist/vite/index.js +2 -1
  13. package/package.json +2 -1
  14. package/skills/apply-comments/SKILL.md +83 -0
  15. package/skills/create-slide/SKILL.md +81 -0
  16. package/skills/create-theme/SKILL.md +194 -0
  17. package/skills/slide-authoring/SKILL.md +288 -0
  18. package/src/app/{App.tsx → app.tsx} +8 -6
  19. package/src/app/components/{AssetView.tsx → asset-view.tsx} +41 -33
  20. package/src/app/components/{ClickNavZones.tsx → click-nav-zones.tsx} +1 -1
  21. package/src/app/components/history-provider.tsx +120 -0
  22. package/src/app/components/image-placeholder.tsx +121 -0
  23. package/src/app/components/inspector/{CommentWidget.tsx → comment-widget.tsx} +1 -1
  24. package/src/app/components/inspector/{InspectOverlay.tsx → inspect-overlay.tsx} +1 -1
  25. package/src/app/components/inspector/{InspectorPanel.tsx → inspector-panel.tsx} +164 -212
  26. package/src/app/components/inspector/{InspectorProvider.tsx → inspector-provider.tsx} +186 -18
  27. package/src/app/components/inspector/save-bar.tsx +47 -0
  28. package/src/app/components/panel/panel-fields.tsx +60 -0
  29. package/src/app/components/panel/panel-shell.tsx +78 -0
  30. package/src/app/components/panel/save-card.tsx +139 -0
  31. package/src/app/components/pdf-progress-toast.tsx +25 -0
  32. package/src/app/components/player.tsx +341 -0
  33. package/src/app/components/present/blackout-overlay.tsx +18 -0
  34. package/src/app/components/present/control-bar.tsx +204 -0
  35. package/src/app/components/present/help-overlay.tsx +56 -0
  36. package/src/app/components/present/jump-input.tsx +74 -0
  37. package/src/app/components/present/laser-pointer.tsx +40 -0
  38. package/src/app/components/present/overview-grid.tsx +184 -0
  39. package/src/app/components/present/progress-bar.tsx +26 -0
  40. package/src/app/components/present/use-idle.ts +44 -0
  41. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  42. package/src/app/components/present/use-presenter-channel.ts +71 -0
  43. package/src/app/components/present/use-touch-swipe.ts +63 -0
  44. package/src/app/components/sidebar/{FolderItem.tsx → folder-item.tsx} +62 -27
  45. package/src/app/components/sidebar/{IconPicker.tsx → icon-picker.tsx} +13 -10
  46. package/src/app/components/sidebar/{Sidebar.tsx → sidebar.tsx} +40 -34
  47. package/src/app/components/{SlideCanvas.tsx → slide-canvas.tsx} +35 -10
  48. package/src/app/components/style-panel/design-provider.tsx +139 -0
  49. package/src/app/components/style-panel/style-panel.tsx +326 -0
  50. package/src/app/components/style-panel/use-design.ts +112 -0
  51. package/src/app/components/theme-toggle.tsx +57 -0
  52. package/src/app/components/thumbnail-rail.tsx +151 -0
  53. package/src/app/components/ui/button.tsx +51 -19
  54. package/src/app/components/ui/card.tsx +1 -1
  55. package/src/app/components/ui/dialog.tsx +25 -9
  56. package/src/app/components/ui/dropdown-menu.tsx +29 -12
  57. package/src/app/components/ui/input.tsx +13 -9
  58. package/src/app/components/ui/popover.tsx +5 -2
  59. package/src/app/components/ui/progress.tsx +2 -2
  60. package/src/app/components/ui/select.tsx +11 -5
  61. package/src/app/components/ui/separator.tsx +1 -1
  62. package/src/app/components/ui/slider.tsx +4 -4
  63. package/src/app/components/ui/sonner.tsx +11 -1
  64. package/src/app/components/ui/tabs.tsx +6 -6
  65. package/src/app/components/ui/textarea.tsx +11 -7
  66. package/src/app/components/ui/toggle-group.tsx +2 -2
  67. package/src/app/components/ui/toggle.tsx +6 -6
  68. package/src/app/components/ui/tooltip.tsx +5 -2
  69. package/src/app/lib/design.ts +64 -0
  70. package/src/app/lib/export-html.ts +10 -1
  71. package/src/app/lib/export-pdf.ts +7 -0
  72. package/src/app/lib/folders.ts +1 -1
  73. package/src/app/lib/inspector/{useEditor.ts → use-editor.ts} +2 -1
  74. package/src/app/lib/sdk.ts +5 -0
  75. package/src/app/lib/slides.ts +1 -1
  76. package/src/app/lib/utils.ts +1 -1
  77. package/src/app/main.tsx +5 -2
  78. package/src/app/routes/{Home.tsx → home.tsx} +266 -97
  79. package/src/app/routes/presenter.tsx +400 -0
  80. package/src/app/routes/slide.tsx +519 -0
  81. package/src/app/styles.css +338 -67
  82. package/src/app/components/PdfProgressToast.tsx +0 -23
  83. package/src/app/components/Player.tsx +0 -100
  84. package/src/app/components/ThumbnailRail.tsx +0 -68
  85. package/src/app/components/inspector/SaveBar.tsx +0 -77
  86. package/src/app/routes/Slide.tsx +0 -478
  87. /package/dist/{config-SXL5qIl6.d.ts → config-DweCbRkQ.d.ts} +0 -0
  88. /package/src/app/lib/inspector/{useComments.ts → use-comments.ts} +0 -0
  89. /package/src/app/lib/{useWheelPageNavigation.ts → use-wheel-page-navigation.ts} +0 -0
@@ -0,0 +1,341 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
3
+ import { cn } from '@/lib/utils';
4
+ import type { DesignSystem } from '../lib/design';
5
+ import type { Page } from '../lib/sdk';
6
+ import { PresentBlackoutOverlay } from './present/blackout-overlay';
7
+ import { PresentControlBar } from './present/control-bar';
8
+ import { PresentHelpOverlay } from './present/help-overlay';
9
+ import { PresentJumpInput } from './present/jump-input';
10
+ import { PresentLaserPointer } from './present/laser-pointer';
11
+ import { PresentOverviewGrid } from './present/overview-grid';
12
+ import { PresentProgressBar } from './present/progress-bar';
13
+ import { useIdle } from './present/use-idle';
14
+ import { usePointerNearBottom } from './present/use-pointer-near-bottom';
15
+ import {
16
+ type PresenterCommand,
17
+ type PresenterState,
18
+ usePresenterChannel,
19
+ } from './present/use-presenter-channel';
20
+ import { useTouchSwipe } from './present/use-touch-swipe';
21
+ import { SlideCanvas } from './slide-canvas';
22
+
23
+ const IDLE_HIDE_MS = 2000;
24
+ // Bottom band of the viewport that reveals the control bar + progress bar.
25
+ // Generous enough to feel forgiving with a trackpad, tight enough not to
26
+ // flash on incidental cursor moves.
27
+ const BAR_HOTZONE_PX = 160;
28
+
29
+ type Props = {
30
+ pages: Page[];
31
+ design?: DesignSystem;
32
+ index: number;
33
+ onIndexChange: (index: number) => void;
34
+ onExit: () => void;
35
+ allowExit?: boolean;
36
+ /**
37
+ * When true, render the full presenter chrome (control bar, progress bar,
38
+ * overview, blackout, laser pointer, jump-to-slide, help overlay, and
39
+ * the BroadcastChannel sync that powers Presenter View). Defaults to
40
+ * false so the static HTML export and any other minimal embeddings stay
41
+ * untouched.
42
+ */
43
+ controls?: boolean;
44
+ /** Optional id used to namespace the BroadcastChannel for Presenter View. */
45
+ slideId?: string;
46
+ };
47
+
48
+ export function Player({
49
+ pages,
50
+ design,
51
+ index,
52
+ onIndexChange,
53
+ onExit,
54
+ allowExit = true,
55
+ controls = false,
56
+ slideId,
57
+ }: Props) {
58
+ const rootRef = useRef<HTMLDivElement>(null);
59
+ // Mirrored as state so children that need to portal *into* the player
60
+ // (tooltips, popovers — the body is outside the fullscreen subtree and
61
+ // therefore invisible) can subscribe and re-render once the node mounts.
62
+ const [rootEl, setRootEl] = useState<HTMLDivElement | null>(null);
63
+ const setRoot = useCallback((el: HTMLDivElement | null) => {
64
+ rootRef.current = el;
65
+ setRootEl(el);
66
+ }, []);
67
+
68
+ // ── Overlay state (only meaningful when `controls` is true) ────────────
69
+ const [overviewOpen, setOverviewOpen] = useState(false);
70
+ const [helpOpen, setHelpOpen] = useState(false);
71
+ const [blackout, setBlackout] = useState<'black' | 'white' | null>(null);
72
+ const [laser, setLaser] = useState(false);
73
+ const [startedAt] = useState(() => Date.now());
74
+
75
+ const goPrev = useCallback(() => {
76
+ if (index > 0) onIndexChange(index - 1);
77
+ }, [index, onIndexChange]);
78
+ const goNext = useCallback(() => {
79
+ if (index < pages.length - 1) onIndexChange(index + 1);
80
+ }, [index, pages.length, onIndexChange]);
81
+
82
+ const overlayActive = controls && (overviewOpen || helpOpen);
83
+
84
+ useWheelPageNavigation({
85
+ ref: rootRef,
86
+ enabled: !overlayActive,
87
+ canPrev: index > 0,
88
+ canNext: index < pages.length - 1,
89
+ onPrev: goPrev,
90
+ onNext: goNext,
91
+ });
92
+
93
+ useTouchSwipe({
94
+ ref: rootRef,
95
+ enabled: controls && !overlayActive,
96
+ onPrev: goPrev,
97
+ onNext: goNext,
98
+ });
99
+
100
+ // ── Fullscreen lifecycle ───────────────────────────────────────────────
101
+ useEffect(() => {
102
+ const el = rootRef.current;
103
+ if (!el) return;
104
+ if (document.fullscreenElement !== el) {
105
+ el.requestFullscreen?.().catch(() => {});
106
+ }
107
+ return () => {
108
+ if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
109
+ };
110
+ }, []);
111
+
112
+ useEffect(() => {
113
+ if (!allowExit) return;
114
+ const onFsChange = () => {
115
+ if (!document.fullscreenElement) onExit();
116
+ };
117
+ document.addEventListener('fullscreenchange', onFsChange);
118
+ return () => document.removeEventListener('fullscreenchange', onFsChange);
119
+ }, [onExit, allowExit]);
120
+
121
+ // ── Presenter View sync ────────────────────────────────────────────────
122
+ // Player is the source of truth. It re-publishes state on every change
123
+ // and answers `request-state` pings so newly opened presenter windows
124
+ // hydrate immediately. Notes are loaded by Presenter View itself from
125
+ // the same slide module, so they don't cross the channel.
126
+ const presenterState = useMemo<PresenterState>(
127
+ () => ({ index, pageCount: pages.length, blackout, startedAt }),
128
+ [index, pages.length, blackout, startedAt],
129
+ );
130
+ const presenterStateRef = useRef(presenterState);
131
+ presenterStateRef.current = presenterState;
132
+
133
+ const handlePresenterCommand = useCallback(
134
+ (msg: PresenterCommand, send: (m: PresenterCommand) => void) => {
135
+ if (msg.type === 'next') goNext();
136
+ else if (msg.type === 'prev') goPrev();
137
+ else if (msg.type === 'goto') {
138
+ onIndexChange(Math.max(0, Math.min(pages.length - 1, msg.index)));
139
+ } else if (msg.type === 'toggle-blackout') {
140
+ setBlackout((cur) => (cur === msg.mode ? null : msg.mode));
141
+ } else if (msg.type === 'request-state') {
142
+ send({ type: 'state', state: presenterStateRef.current });
143
+ }
144
+ },
145
+ [goNext, goPrev, onIndexChange, pages.length],
146
+ );
147
+
148
+ const channel = usePresenterChannel(slideId ?? '__none__', (msg) => {
149
+ if (!controls) return;
150
+ handlePresenterCommand(msg, (m) => channel.send(m));
151
+ });
152
+
153
+ useEffect(() => {
154
+ if (!controls || !channel.available) return;
155
+ channel.send({ type: 'state', state: presenterState });
156
+ }, [controls, channel, presenterState]);
157
+
158
+ // ── Keyboard ───────────────────────────────────────────────────────────
159
+ useEffect(() => {
160
+ const onKey = (e: KeyboardEvent) => {
161
+ const tgt = e.target;
162
+ if (tgt instanceof HTMLElement && tgt.matches('input, textarea')) return;
163
+
164
+ // While an overlay is open, only Esc and the toggle that owns it
165
+ // should reach the Player. Overview installs its own capture-phase
166
+ // listener and stops propagation, so it won't double-fire here.
167
+ if (overlayActive) {
168
+ if (e.key === 'Escape') {
169
+ e.preventDefault();
170
+ if (overviewOpen) setOverviewOpen(false);
171
+ if (helpOpen) setHelpOpen(false);
172
+ } else if (helpOpen && (e.key === '?' || e.key === 'h' || e.key === 'H')) {
173
+ e.preventDefault();
174
+ setHelpOpen(false);
175
+ }
176
+ return;
177
+ }
178
+
179
+ // Esc → close blackout if any, otherwise exit fullscreen.
180
+ if (e.key === 'Escape') {
181
+ if (controls && blackout) {
182
+ e.preventDefault();
183
+ setBlackout(null);
184
+ return;
185
+ }
186
+ if (allowExit) onExit();
187
+ return;
188
+ }
189
+
190
+ const isNext =
191
+ e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown';
192
+ const isPrev = e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp';
193
+
194
+ if (isNext || isPrev) {
195
+ if (controls && blackout) setBlackout(null);
196
+ }
197
+
198
+ if (isNext) {
199
+ e.preventDefault();
200
+ goNext();
201
+ return;
202
+ }
203
+ if (isPrev) {
204
+ e.preventDefault();
205
+ goPrev();
206
+ return;
207
+ }
208
+ if (e.key === 'Home') {
209
+ onIndexChange(0);
210
+ return;
211
+ }
212
+ if (e.key === 'End') {
213
+ onIndexChange(pages.length - 1);
214
+ return;
215
+ }
216
+
217
+ if (!controls) return;
218
+ // Single-letter shortcuts only fire when no modifier is held — keeps
219
+ // browser shortcuts (Cmd/Ctrl-something) from being hijacked.
220
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
221
+
222
+ if (e.key === 'b' || e.key === 'B') {
223
+ e.preventDefault();
224
+ setBlackout((c) => (c === 'black' ? null : 'black'));
225
+ } else if (e.key === 'w' || e.key === 'W') {
226
+ e.preventDefault();
227
+ setBlackout((c) => (c === 'white' ? null : 'white'));
228
+ } else if (e.key === 'o' || e.key === 'O') {
229
+ e.preventDefault();
230
+ setOverviewOpen((v) => !v);
231
+ } else if (e.key === 'l' || e.key === 'L') {
232
+ e.preventDefault();
233
+ setLaser((v) => !v);
234
+ } else if (e.key === 'h' || e.key === 'H' || e.key === '?') {
235
+ e.preventDefault();
236
+ setHelpOpen((v) => !v);
237
+ } else if ((e.key === 'p' || e.key === 'P') && slideId) {
238
+ e.preventDefault();
239
+ openPresenterWindow(slideId);
240
+ }
241
+ };
242
+ window.addEventListener('keydown', onKey);
243
+ return () => window.removeEventListener('keydown', onKey);
244
+ }, [
245
+ controls,
246
+ overlayActive,
247
+ overviewOpen,
248
+ helpOpen,
249
+ blackout,
250
+ allowExit,
251
+ onExit,
252
+ goNext,
253
+ goPrev,
254
+ onIndexChange,
255
+ pages.length,
256
+ slideId,
257
+ ]);
258
+
259
+ // ── Chrome visibility / cursor ─────────────────────────────────────────
260
+ // The control bar + progress strip only surface when the pointer is in
261
+ // the bottom hot zone. Keyboard nav (arrows / space / PgDn) never
262
+ // reveals them — that's intentional so the deck stays clean.
263
+ const pointerNearBottom = usePointerNearBottom(BAR_HOTZONE_PX, controls && !overlayActive);
264
+ const chromeVisible = pointerNearBottom || overlayActive;
265
+ const idle = useIdle(IDLE_HIDE_MS, controls && !overlayActive);
266
+ const hideCursor = controls && (laser || (idle && !overlayActive && !pointerNearBottom));
267
+
268
+ const PageComp = pages[index];
269
+
270
+ return (
271
+ <div
272
+ ref={setRoot}
273
+ className={cn(
274
+ 'relative flex h-dvh w-screen items-center justify-center bg-black',
275
+ hideCursor && 'cursor-none',
276
+ )}
277
+ >
278
+ <SlideCanvas flat design={design}>
279
+ {PageComp ? <PageComp /> : null}
280
+ </SlideCanvas>
281
+
282
+ {/* Invisible side click zones — the original mobile-friendly nav. */}
283
+ <button
284
+ type="button"
285
+ aria-label="Previous page"
286
+ onClick={goPrev}
287
+ disabled={index === 0}
288
+ className="absolute inset-y-0 left-0 z-10 w-[30%]"
289
+ />
290
+ <button
291
+ type="button"
292
+ aria-label="Next page"
293
+ onClick={goNext}
294
+ disabled={index === pages.length - 1}
295
+ className="absolute inset-y-0 right-0 z-10 w-[30%]"
296
+ />
297
+
298
+ {controls && (
299
+ <>
300
+ <PresentProgressBar index={index} total={pages.length} visible={chromeVisible} />
301
+ <PresentBlackoutOverlay mode={blackout} />
302
+ <PresentJumpInput pageCount={pages.length} onJump={onIndexChange} />
303
+ <PresentLaserPointer enabled={laser} />
304
+ <PresentControlBar
305
+ tooltipContainer={rootEl}
306
+ index={index}
307
+ total={pages.length}
308
+ visible={chromeVisible}
309
+ startedAt={startedAt}
310
+ blackout={blackout}
311
+ laser={laser}
312
+ allowExit={allowExit}
313
+ onPrev={goPrev}
314
+ onNext={goNext}
315
+ onOverview={() => setOverviewOpen(true)}
316
+ onBlackout={(mode) => setBlackout((c) => (c === mode ? null : mode))}
317
+ onLaser={() => setLaser((v) => !v)}
318
+ onPresenter={() => slideId && openPresenterWindow(slideId)}
319
+ onHelp={() => setHelpOpen(true)}
320
+ onExit={onExit}
321
+ />
322
+ <PresentOverviewGrid
323
+ pages={pages}
324
+ design={design}
325
+ open={overviewOpen}
326
+ current={index}
327
+ onClose={() => setOverviewOpen(false)}
328
+ onSelect={onIndexChange}
329
+ />
330
+ <PresentHelpOverlay open={helpOpen} onOpenChange={setHelpOpen} container={rootEl} />
331
+ </>
332
+ )}
333
+ </div>
334
+ );
335
+ }
336
+
337
+ function openPresenterWindow(slideId: string) {
338
+ if (typeof window === 'undefined') return;
339
+ const url = `/s/${encodeURIComponent(slideId)}/presenter`;
340
+ window.open(url, `open-slide-presenter-${slideId}`, 'popup,width=1280,height=800');
341
+ }
@@ -0,0 +1,18 @@
1
+ import { cn } from '@/lib/utils';
2
+
3
+ type Props = {
4
+ mode: 'black' | 'white' | null;
5
+ };
6
+
7
+ export function PresentBlackoutOverlay({ mode }: Props) {
8
+ if (!mode) return null;
9
+ return (
10
+ <div
11
+ aria-hidden
12
+ className={cn(
13
+ 'pointer-events-none absolute inset-0 z-20 motion-safe:transition-opacity motion-safe:duration-150',
14
+ mode === 'black' ? 'bg-black' : 'bg-white',
15
+ )}
16
+ />
17
+ );
18
+ }
@@ -0,0 +1,204 @@
1
+ import {
2
+ ChevronLeft,
3
+ ChevronRight,
4
+ Crosshair,
5
+ Grid2x2,
6
+ Keyboard,
7
+ LogOut,
8
+ MonitorSpeaker,
9
+ Square,
10
+ Sun,
11
+ } from 'lucide-react';
12
+ import { createContext, useContext, useEffect, useState } from 'react';
13
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
14
+ import { cn } from '@/lib/utils';
15
+
16
+ const TooltipContainerCtx = createContext<HTMLElement | null>(null);
17
+
18
+ type Props = {
19
+ index: number;
20
+ total: number;
21
+ visible: boolean;
22
+ startedAt: number;
23
+ blackout: 'black' | 'white' | null;
24
+ laser: boolean;
25
+ allowExit: boolean;
26
+ onPrev: () => void;
27
+ onNext: () => void;
28
+ onOverview: () => void;
29
+ onBlackout: (mode: 'black' | 'white') => void;
30
+ onLaser: () => void;
31
+ onPresenter: () => void;
32
+ onHelp: () => void;
33
+ onExit: () => void;
34
+ /**
35
+ * Where to portal tooltips. Required because the Player runs fullscreen
36
+ * — the default `document.body` portal is outside the fullscreen element
37
+ * and therefore invisible. Pass the player root.
38
+ */
39
+ tooltipContainer?: HTMLElement | null;
40
+ };
41
+
42
+ export function PresentControlBar({
43
+ index,
44
+ total,
45
+ visible,
46
+ startedAt,
47
+ blackout,
48
+ laser,
49
+ allowExit,
50
+ onPrev,
51
+ onNext,
52
+ onOverview,
53
+ onBlackout,
54
+ onLaser,
55
+ onPresenter,
56
+ onHelp,
57
+ onExit,
58
+ tooltipContainer,
59
+ }: Props) {
60
+ return (
61
+ <div
62
+ data-state={visible ? 'visible' : 'hidden'}
63
+ className={cn(
64
+ 'pointer-events-none absolute inset-x-0 bottom-0 z-40 flex justify-center px-4 pb-4',
65
+ 'will-change-[translate,scale,opacity,filter]',
66
+ 'motion-safe:transition-[translate,scale,opacity,filter]',
67
+ 'motion-safe:duration-[420ms] motion-safe:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)]',
68
+ visible
69
+ ? 'translate-y-0 scale-100 opacity-100 blur-none'
70
+ : 'translate-y-8 scale-90 opacity-0 blur-md',
71
+ )}
72
+ >
73
+ <TooltipProvider delayDuration={300}>
74
+ <TooltipContainerCtx.Provider value={tooltipContainer ?? null}>
75
+ <div className="pointer-events-auto flex h-11 items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 text-white/85 shadow-[0_8px_30px_-8px_oklch(0_0_0/0.6)] backdrop-blur-md">
76
+ <BarButton label="Previous slide (←)" onClick={onPrev} disabled={index === 0}>
77
+ <ChevronLeft className="size-4" />
78
+ </BarButton>
79
+ <BarButton label="Next slide (→)" onClick={onNext} disabled={index >= total - 1}>
80
+ <ChevronRight className="size-4" />
81
+ </BarButton>
82
+
83
+ <Divider />
84
+
85
+ <span className="px-2 font-mono text-[11.5px] tracking-[0.08em] tabular-nums uppercase select-none text-white/85">
86
+ <span className="text-white">{(index + 1).toString().padStart(2, '0')}</span>
87
+ <span className="text-white/35"> / </span>
88
+ <span>{total.toString().padStart(2, '0')}</span>
89
+ </span>
90
+
91
+ <Divider />
92
+
93
+ <ElapsedClock startedAt={startedAt} />
94
+
95
+ <Divider />
96
+
97
+ <BarButton label="Slide overview (O)" onClick={onOverview}>
98
+ <Grid2x2 className="size-4" />
99
+ </BarButton>
100
+ <BarButton
101
+ label="Black screen (B)"
102
+ onClick={() => onBlackout('black')}
103
+ active={blackout === 'black'}
104
+ >
105
+ <Square className="size-4 fill-current" />
106
+ </BarButton>
107
+ <BarButton
108
+ label="White screen (W)"
109
+ onClick={() => onBlackout('white')}
110
+ active={blackout === 'white'}
111
+ >
112
+ <Sun className="size-4" />
113
+ </BarButton>
114
+ <BarButton label="Laser pointer (L)" onClick={onLaser} active={laser}>
115
+ <Crosshair className="size-4" />
116
+ </BarButton>
117
+ <BarButton label="Presenter view (P)" onClick={onPresenter}>
118
+ <MonitorSpeaker className="size-4" />
119
+ </BarButton>
120
+ <BarButton label="Keyboard shortcuts (?)" onClick={onHelp}>
121
+ <Keyboard className="size-4" />
122
+ </BarButton>
123
+
124
+ {allowExit && (
125
+ <>
126
+ <Divider />
127
+ <BarButton label="Exit (Esc)" onClick={onExit}>
128
+ <LogOut className="size-4" />
129
+ </BarButton>
130
+ </>
131
+ )}
132
+ </div>
133
+ </TooltipContainerCtx.Provider>
134
+ </TooltipProvider>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ function BarButton({
140
+ children,
141
+ label,
142
+ onClick,
143
+ disabled,
144
+ active,
145
+ }: {
146
+ children: React.ReactNode;
147
+ label: string;
148
+ onClick: () => void;
149
+ disabled?: boolean;
150
+ active?: boolean;
151
+ }) {
152
+ const container = useContext(TooltipContainerCtx);
153
+ return (
154
+ <Tooltip>
155
+ <TooltipTrigger asChild>
156
+ <button
157
+ type="button"
158
+ aria-label={label}
159
+ disabled={disabled}
160
+ onClick={onClick}
161
+ className={cn(
162
+ 'inline-flex size-8 items-center justify-center rounded-full transition-colors',
163
+ 'hover:bg-white/12 focus-visible:bg-white/12 focus-visible:outline-none',
164
+ 'disabled:pointer-events-none disabled:opacity-30',
165
+ active && 'bg-[var(--brand,#ef4444)]/85 text-white hover:bg-[var(--brand,#ef4444)]',
166
+ )}
167
+ >
168
+ {children}
169
+ </button>
170
+ </TooltipTrigger>
171
+ <TooltipContent
172
+ container={container ?? undefined}
173
+ side="top"
174
+ sideOffset={6}
175
+ className="bg-black/85 text-white"
176
+ >
177
+ {label}
178
+ </TooltipContent>
179
+ </Tooltip>
180
+ );
181
+ }
182
+
183
+ function Divider() {
184
+ return <span aria-hidden className="mx-1 h-4 w-px bg-white/15" />;
185
+ }
186
+
187
+ function ElapsedClock({ startedAt }: { startedAt: number }) {
188
+ const [now, setNow] = useState(() => Date.now());
189
+ useEffect(() => {
190
+ const id = setInterval(() => setNow(Date.now()), 1000);
191
+ return () => clearInterval(id);
192
+ }, []);
193
+ const elapsed = Math.max(0, Math.floor((now - startedAt) / 1000));
194
+ const m = Math.floor(elapsed / 60);
195
+ const s = elapsed % 60;
196
+ return (
197
+ <time
198
+ title="Elapsed time"
199
+ className="px-2 font-mono text-[11.5px] tracking-[0.08em] tabular-nums uppercase select-none text-white/70"
200
+ >
201
+ {m.toString().padStart(2, '0')}:{s.toString().padStart(2, '0')}
202
+ </time>
203
+ );
204
+ }
@@ -0,0 +1,56 @@
1
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
2
+
3
+ const SHORTCUTS: Array<{ keys: string[]; label: string }> = [
4
+ { keys: ['→', '↓', 'Space', 'PgDn'], label: 'Next slide' },
5
+ { keys: ['←', '↑', 'PgUp'], label: 'Previous slide' },
6
+ { keys: ['Home', 'End'], label: 'First / last slide' },
7
+ { keys: ['1–9', 'Enter'], label: 'Jump to slide' },
8
+ { keys: ['O'], label: 'Slide overview' },
9
+ { keys: ['B'], label: 'Black screen' },
10
+ { keys: ['W'], label: 'White screen' },
11
+ { keys: ['L'], label: 'Laser pointer' },
12
+ { keys: ['P'], label: 'Open Presenter View' },
13
+ { keys: ['?', 'H'], label: 'Toggle this help' },
14
+ { keys: ['Esc'], label: 'Close overlay / exit' },
15
+ ];
16
+
17
+ type Props = {
18
+ open: boolean;
19
+ onOpenChange: (open: boolean) => void;
20
+ /** Portal target — pass the player root so the dialog renders inside
21
+ * the fullscreen subtree (otherwise it paints invisibly under it). */
22
+ container?: HTMLElement | null;
23
+ };
24
+
25
+ export function PresentHelpOverlay({ open, onOpenChange, container }: Props) {
26
+ return (
27
+ <Dialog open={open} onOpenChange={onOpenChange}>
28
+ <DialogContent container={container ?? undefined} className="max-w-lg sm:max-w-lg">
29
+ <DialogHeader>
30
+ <span className="eyebrow">Present mode</span>
31
+ <DialogTitle>Keyboard shortcuts</DialogTitle>
32
+ </DialogHeader>
33
+ <div className="grid grid-cols-1 gap-x-8 gap-y-2 sm:grid-cols-2">
34
+ {SHORTCUTS.map((row) => (
35
+ <div
36
+ key={row.label}
37
+ className="flex items-center justify-between gap-3 border-b border-hairline py-1.5 last:border-0"
38
+ >
39
+ <span className="text-[12.5px] text-foreground/85">{row.label}</span>
40
+ <span className="flex shrink-0 items-center gap-1">
41
+ {row.keys.map((k) => (
42
+ <kbd
43
+ key={k}
44
+ className="rounded-[4px] border border-border bg-muted px-1.5 py-0.5 font-mono text-[10.5px] tabular-nums"
45
+ >
46
+ {k}
47
+ </kbd>
48
+ ))}
49
+ </span>
50
+ </div>
51
+ ))}
52
+ </div>
53
+ </DialogContent>
54
+ </Dialog>
55
+ );
56
+ }
@@ -0,0 +1,74 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ const FLUSH_DELAY_MS = 1200;
4
+
5
+ type Props = {
6
+ pageCount: number;
7
+ onJump: (index: number) => void;
8
+ };
9
+
10
+ /**
11
+ * Listens for digit keypresses anywhere on the document and shows a
12
+ * transient "→ 7" badge. Pressing Enter (or letting it idle) flushes the
13
+ * buffer and jumps to the slide. Designed to be invisible until the user
14
+ * starts typing — never steals focus, never shows an input element.
15
+ */
16
+ export function PresentJumpInput({ pageCount, onJump }: Props) {
17
+ const [buffer, setBuffer] = useState('');
18
+ const flushRef = useRef<ReturnType<typeof setTimeout> | null>(null);
19
+
20
+ useEffect(() => {
21
+ const flush = () => {
22
+ setBuffer((current) => {
23
+ if (!current) return current;
24
+ const n = Number.parseInt(current, 10);
25
+ if (Number.isFinite(n) && n >= 1) {
26
+ onJump(Math.min(pageCount, n) - 1);
27
+ }
28
+ return '';
29
+ });
30
+ };
31
+
32
+ const onKey = (e: KeyboardEvent) => {
33
+ if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
34
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
35
+
36
+ if (/^[0-9]$/.test(e.key)) {
37
+ e.preventDefault();
38
+ setBuffer((b) => (b + e.key).slice(0, 4));
39
+ if (flushRef.current) clearTimeout(flushRef.current);
40
+ flushRef.current = setTimeout(flush, FLUSH_DELAY_MS);
41
+ return;
42
+ }
43
+ if (e.key === 'Enter') {
44
+ if (flushRef.current) clearTimeout(flushRef.current);
45
+ flush();
46
+ return;
47
+ }
48
+ if (e.key === 'Backspace') {
49
+ setBuffer((b) => b.slice(0, -1));
50
+ return;
51
+ }
52
+ if (e.key === 'Escape' || e.key === ' ') {
53
+ setBuffer('');
54
+ }
55
+ };
56
+
57
+ window.addEventListener('keydown', onKey);
58
+ return () => {
59
+ window.removeEventListener('keydown', onKey);
60
+ if (flushRef.current) clearTimeout(flushRef.current);
61
+ };
62
+ }, [pageCount, onJump]);
63
+
64
+ if (!buffer) return null;
65
+ return (
66
+ <div
67
+ aria-live="polite"
68
+ className="pointer-events-none absolute top-1/2 left-1/2 z-40 -translate-x-1/2 -translate-y-1/2 select-none rounded-[10px] bg-black/70 px-6 py-4 font-mono text-[44px] font-medium tracking-[0.05em] text-white tabular-nums shadow-[0_8px_40px_-8px_oklch(0_0_0/0.6)] backdrop-blur-md"
69
+ >
70
+ <span className="text-white/60">→ </span>
71
+ {buffer}
72
+ </div>
73
+ );
74
+ }