@open-slide/core 0.0.10 → 0.0.12
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/dist/{build-DHiRlpjn.js → build-aiY_8kwE.js} +2 -1
- package/dist/cli/bin.js +43 -4
- package/dist/{config-LZM903FE.js → config-CVqRAagl.js} +592 -63
- package/dist/design-CROQh0AA.js +35 -0
- package/dist/{dev-B3JzCYn7.js → dev-R2we2iaF.js} +2 -1
- package/dist/index.d.ts +55 -4
- package/dist/index.js +110 -1
- package/dist/{preview-UikovHEt.js → preview-CU4zSyGp.js} +2 -1
- package/dist/sync-3oqN1WyK.js +139 -0
- package/dist/sync-B4eLo2H6.js +3 -0
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +2 -1
- package/package.json +2 -1
- package/skills/apply-comments/SKILL.md +83 -0
- package/skills/create-slide/SKILL.md +81 -0
- package/skills/create-theme/SKILL.md +194 -0
- package/skills/slide-authoring/SKILL.md +288 -0
- package/src/app/{App.tsx → app.tsx} +8 -6
- package/src/app/components/{AssetView.tsx → asset-view.tsx} +41 -33
- package/src/app/components/{ClickNavZones.tsx → click-nav-zones.tsx} +1 -1
- package/src/app/components/history-provider.tsx +120 -0
- package/src/app/components/image-placeholder.tsx +121 -0
- package/src/app/components/inspector/{CommentWidget.tsx → comment-widget.tsx} +1 -1
- package/src/app/components/inspector/{InspectOverlay.tsx → inspect-overlay.tsx} +1 -1
- package/src/app/components/inspector/{InspectorPanel.tsx → inspector-panel.tsx} +164 -212
- package/src/app/components/inspector/{InspectorProvider.tsx → inspector-provider.tsx} +186 -18
- package/src/app/components/inspector/save-bar.tsx +47 -0
- package/src/app/components/panel/panel-fields.tsx +60 -0
- package/src/app/components/panel/panel-shell.tsx +78 -0
- package/src/app/components/panel/save-card.tsx +139 -0
- package/src/app/components/pdf-progress-toast.tsx +25 -0
- package/src/app/components/player.tsx +341 -0
- package/src/app/components/present/blackout-overlay.tsx +18 -0
- package/src/app/components/present/control-bar.tsx +204 -0
- package/src/app/components/present/help-overlay.tsx +56 -0
- package/src/app/components/present/jump-input.tsx +74 -0
- package/src/app/components/present/laser-pointer.tsx +40 -0
- package/src/app/components/present/overview-grid.tsx +184 -0
- package/src/app/components/present/progress-bar.tsx +26 -0
- package/src/app/components/present/use-idle.ts +44 -0
- package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
- package/src/app/components/present/use-presenter-channel.ts +71 -0
- package/src/app/components/present/use-touch-swipe.ts +63 -0
- package/src/app/components/sidebar/{FolderItem.tsx → folder-item.tsx} +62 -27
- package/src/app/components/sidebar/{IconPicker.tsx → icon-picker.tsx} +13 -10
- package/src/app/components/sidebar/{Sidebar.tsx → sidebar.tsx} +40 -34
- package/src/app/components/{SlideCanvas.tsx → slide-canvas.tsx} +35 -10
- package/src/app/components/style-panel/design-provider.tsx +139 -0
- package/src/app/components/style-panel/style-panel.tsx +326 -0
- package/src/app/components/style-panel/use-design.ts +112 -0
- package/src/app/components/theme-toggle.tsx +57 -0
- package/src/app/components/thumbnail-rail.tsx +151 -0
- package/src/app/components/ui/button.tsx +51 -19
- package/src/app/components/ui/card.tsx +1 -1
- package/src/app/components/ui/dialog.tsx +25 -9
- package/src/app/components/ui/dropdown-menu.tsx +29 -12
- package/src/app/components/ui/input.tsx +13 -9
- package/src/app/components/ui/popover.tsx +5 -2
- package/src/app/components/ui/progress.tsx +2 -2
- package/src/app/components/ui/select.tsx +11 -5
- package/src/app/components/ui/separator.tsx +1 -1
- package/src/app/components/ui/slider.tsx +4 -4
- package/src/app/components/ui/sonner.tsx +11 -1
- package/src/app/components/ui/tabs.tsx +6 -6
- package/src/app/components/ui/textarea.tsx +11 -7
- package/src/app/components/ui/toggle-group.tsx +2 -2
- package/src/app/components/ui/toggle.tsx +6 -6
- package/src/app/components/ui/tooltip.tsx +5 -2
- package/src/app/lib/export-html.ts +10 -1
- package/src/app/lib/export-pdf.ts +7 -0
- package/src/app/lib/folders.ts +1 -1
- package/src/app/lib/inspector/{useEditor.ts → use-editor.ts} +2 -1
- package/src/app/lib/sdk.ts +5 -0
- package/src/app/lib/slides.ts +1 -1
- package/src/app/lib/utils.ts +1 -1
- package/src/app/main.tsx +5 -2
- package/src/app/routes/{Home.tsx → home.tsx} +266 -97
- package/src/app/routes/presenter.tsx +400 -0
- package/src/app/routes/slide.tsx +519 -0
- package/src/app/styles.css +338 -67
- package/src/app/components/PdfProgressToast.tsx +0 -23
- package/src/app/components/Player.tsx +0 -100
- package/src/app/components/ThumbnailRail.tsx +0 -68
- package/src/app/components/inspector/SaveBar.tsx +0 -77
- package/src/app/routes/Slide.tsx +0 -478
- /package/dist/{config-SXL5qIl6.d.ts → config-DweCbRkQ.d.ts} +0 -0
- /package/src/app/lib/inspector/{useComments.ts → use-comments.ts} +0 -0
- /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 '../../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
|
+
}
|