@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.
Files changed (88) hide show
  1. package/dist/{build-DHiRlpjn.js → build-aiY_8kwE.js} +2 -1
  2. package/dist/cli/bin.js +43 -4
  3. package/dist/{config-LZM903FE.js → config-CVqRAagl.js} +592 -63
  4. package/dist/design-CROQh0AA.js +35 -0
  5. package/dist/{dev-B3JzCYn7.js → dev-R2we2iaF.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-CU4zSyGp.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/export-html.ts +10 -1
  70. package/src/app/lib/export-pdf.ts +7 -0
  71. package/src/app/lib/folders.ts +1 -1
  72. package/src/app/lib/inspector/{useEditor.ts → use-editor.ts} +2 -1
  73. package/src/app/lib/sdk.ts +5 -0
  74. package/src/app/lib/slides.ts +1 -1
  75. package/src/app/lib/utils.ts +1 -1
  76. package/src/app/main.tsx +5 -2
  77. package/src/app/routes/{Home.tsx → home.tsx} +266 -97
  78. package/src/app/routes/presenter.tsx +400 -0
  79. package/src/app/routes/slide.tsx +519 -0
  80. package/src/app/styles.css +338 -67
  81. package/src/app/components/PdfProgressToast.tsx +0 -23
  82. package/src/app/components/Player.tsx +0 -100
  83. package/src/app/components/ThumbnailRail.tsx +0 -68
  84. package/src/app/components/inspector/SaveBar.tsx +0 -77
  85. package/src/app/routes/Slide.tsx +0 -478
  86. /package/dist/{config-SXL5qIl6.d.ts → config-DweCbRkQ.d.ts} +0 -0
  87. /package/src/app/lib/inspector/{useComments.ts → use-comments.ts} +0 -0
  88. /package/src/app/lib/{useWheelPageNavigation.ts → use-wheel-page-navigation.ts} +0 -0
@@ -0,0 +1,400 @@
1
+ import {
2
+ ChevronLeft,
3
+ ChevronRight,
4
+ Loader2,
5
+ RotateCcw,
6
+ Square,
7
+ Sun,
8
+ } from 'lucide-react';
9
+ import { useCallback, useEffect, useRef, useState } from 'react';
10
+ import { useParams } from 'react-router-dom';
11
+ import { Button } from '@/components/ui/button';
12
+ import { cn } from '@/lib/utils';
13
+ import {
14
+ type PresenterState,
15
+ usePresenterChannel,
16
+ } from '../components/present/use-presenter-channel';
17
+ import { SlideCanvas } from '../components/slide-canvas';
18
+ import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
19
+ import type { SlideModule } from '../lib/sdk';
20
+ import { loadSlide } from '../lib/slides';
21
+
22
+ export function Presenter() {
23
+ const { slideId = '' } = useParams();
24
+ const [slide, setSlide] = useState<SlideModule | null>(null);
25
+ const [error, setError] = useState<string | null>(null);
26
+
27
+ // Presenter view is a passive mirror of the projection window. It only
28
+ // tracks the index it last heard about; navigation buttons send commands
29
+ // back to the projection so both windows stay in lock-step.
30
+ const [state, setState] = useState<PresenterState | null>(null);
31
+ // Local timer fallback — counts up from when the presenter window opened
32
+ // until the projection window publishes its actual `startedAt`.
33
+ const [localStart] = useState(() => Date.now());
34
+ const [hasProjection, setHasProjection] = useState(false);
35
+ const requestedRef = useRef(false);
36
+
37
+ useEffect(() => {
38
+ let cancelled = false;
39
+ setSlide(null);
40
+ setError(null);
41
+ loadSlide(slideId)
42
+ .then((mod) => {
43
+ if (!cancelled) setSlide(mod);
44
+ })
45
+ .catch((e) => {
46
+ if (!cancelled) setError(String(e?.message ?? e));
47
+ });
48
+ return () => {
49
+ cancelled = true;
50
+ };
51
+ }, [slideId]);
52
+
53
+ const channel = usePresenterChannel(slideId, (msg) => {
54
+ if (msg.type === 'state') {
55
+ setState(msg.state);
56
+ setHasProjection(true);
57
+ }
58
+ });
59
+
60
+ // Hydrate from the projection window once.
61
+ useEffect(() => {
62
+ if (!channel.available || requestedRef.current) return;
63
+ requestedRef.current = true;
64
+ channel.send({ type: 'request-state' });
65
+ // If nothing answers within a beat, surface the "no projection" hint.
66
+ const t = setTimeout(() => setHasProjection((v) => v), 600);
67
+ return () => clearTimeout(t);
68
+ }, [channel]);
69
+
70
+ const send = channel.send;
71
+ const goPrev = useCallback(() => send({ type: 'prev' }), [send]);
72
+ const goNext = useCallback(() => send({ type: 'next' }), [send]);
73
+ const goTo = useCallback((i: number) => send({ type: 'goto', index: i }), [send]);
74
+ const toggleBlack = useCallback(
75
+ () => send({ type: 'toggle-blackout', mode: 'black' }),
76
+ [send],
77
+ );
78
+ const toggleWhite = useCallback(
79
+ () => send({ type: 'toggle-blackout', mode: 'white' }),
80
+ [send],
81
+ );
82
+
83
+ // Local-window key bindings mirror the projection's main shortcuts so the
84
+ // presenter can drive without the mouse.
85
+ useEffect(() => {
86
+ const onKey = (e: KeyboardEvent) => {
87
+ if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
88
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
89
+ if (
90
+ e.key === 'ArrowRight' ||
91
+ e.key === 'ArrowDown' ||
92
+ e.key === ' ' ||
93
+ e.key === 'PageDown'
94
+ ) {
95
+ e.preventDefault();
96
+ goNext();
97
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
98
+ e.preventDefault();
99
+ goPrev();
100
+ } else if (e.key === 'b' || e.key === 'B') {
101
+ e.preventDefault();
102
+ toggleBlack();
103
+ } else if (e.key === 'w' || e.key === 'W') {
104
+ e.preventDefault();
105
+ toggleWhite();
106
+ }
107
+ };
108
+ window.addEventListener('keydown', onKey);
109
+ return () => window.removeEventListener('keydown', onKey);
110
+ }, [goNext, goPrev, toggleBlack, toggleWhite]);
111
+
112
+ if (error) {
113
+ return (
114
+ <div className="grid h-dvh place-items-center bg-zinc-950 p-8 text-zinc-300">
115
+ <div className="max-w-md text-center">
116
+ <span className="eyebrow text-red-300/80">Load failed</span>
117
+ <h2 className="mt-2 font-heading text-xl font-semibold">Failed to load slide</h2>
118
+ <pre className="mt-4 overflow-auto rounded-[6px] border border-white/10 bg-black/40 p-4 text-left text-[11.5px] whitespace-pre-wrap">
119
+ {error}
120
+ </pre>
121
+ </div>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ if (!slide) {
127
+ return (
128
+ <div className="grid h-dvh place-items-center bg-zinc-950 text-zinc-400">
129
+ <div className="flex items-center gap-2 text-[12.5px]">
130
+ <Loader2 className="size-4 animate-spin" /> Loading {slideId}…
131
+ </div>
132
+ </div>
133
+ );
134
+ }
135
+
136
+ const pages = slide.default;
137
+ const total = pages.length;
138
+ const index = Math.max(0, Math.min(total - 1, state?.index ?? 0));
139
+ const nextIndex = Math.min(total - 1, index + 1);
140
+ const hasNext = index < total - 1;
141
+ const note = slide.notes?.[index];
142
+ const blackout = state?.blackout ?? null;
143
+ const startedAt = state?.startedAt ?? localStart;
144
+
145
+ const CurrentPage = pages[index];
146
+ const NextPage = hasNext ? pages[nextIndex] : null;
147
+
148
+ return (
149
+ <div className="flex h-dvh w-screen flex-col overflow-hidden bg-zinc-950 text-zinc-100">
150
+ <PresenterTopBar
151
+ index={index}
152
+ total={total}
153
+ startedAt={startedAt}
154
+ slideTitle={slide.meta?.title ?? slideId}
155
+ connected={hasProjection}
156
+ />
157
+
158
+ <div className="grid min-h-0 flex-1 grid-cols-1 gap-6 px-6 pb-4 lg:grid-cols-[2fr_1fr]">
159
+ {/* Now-showing */}
160
+ <section className="flex min-h-0 flex-col gap-3">
161
+ <SectionLabel>Now showing</SectionLabel>
162
+ <div className="relative min-h-0 flex-1 overflow-hidden rounded-[8px] bg-black ring-1 ring-white/10">
163
+ <SlideCanvas flat design={slide.design}>
164
+ <CurrentPage />
165
+ </SlideCanvas>
166
+ {blackout && (
167
+ <div
168
+ aria-hidden
169
+ className={cn(
170
+ 'pointer-events-none absolute inset-0 grid place-items-center text-[11px] tracking-[0.16em] uppercase',
171
+ blackout === 'black' ? 'bg-black text-white/35' : 'bg-white text-black/35',
172
+ )}
173
+ >
174
+ {blackout === 'black' ? 'Black screen' : 'White screen'}
175
+ </div>
176
+ )}
177
+ </div>
178
+ </section>
179
+
180
+ {/* Next + notes */}
181
+ <aside className="flex min-h-0 flex-col gap-4">
182
+ <div className="flex flex-col gap-2">
183
+ <SectionLabel>{hasNext ? 'Up next' : 'Last slide'}</SectionLabel>
184
+ <div
185
+ className="relative w-full overflow-hidden rounded-[6px] bg-black ring-1 ring-white/10"
186
+ style={{ aspectRatio: `${CANVAS_WIDTH}/${CANVAS_HEIGHT}` }}
187
+ >
188
+ {NextPage ? (
189
+ <SlideCanvas flat freezeMotion design={slide.design}>
190
+ <NextPage />
191
+ </SlideCanvas>
192
+ ) : (
193
+ <div className="grid h-full place-items-center text-[11.5px] text-white/40">
194
+ End of deck
195
+ </div>
196
+ )}
197
+ </div>
198
+ </div>
199
+
200
+ <div className="flex min-h-0 flex-1 flex-col gap-2">
201
+ <SectionLabel>Speaker notes</SectionLabel>
202
+ <div className="min-h-0 flex-1 overflow-y-auto rounded-[6px] border border-white/10 bg-black/40 p-3 text-[13.5px] leading-relaxed whitespace-pre-wrap text-white/85">
203
+ {note?.trim() ? (
204
+ note
205
+ ) : (
206
+ <span className="text-white/40">
207
+ No speaker notes for this slide. Add{' '}
208
+ <code className="rounded-[3px] bg-white/10 px-1 py-0.5 font-mono text-[12px]">
209
+ export const notes = […]
210
+ </code>{' '}
211
+ to your slide module to see notes here.
212
+ </span>
213
+ )}
214
+ </div>
215
+ </div>
216
+
217
+ <PresenterJumpControl total={total} current={index} onJump={goTo} />
218
+ </aside>
219
+ </div>
220
+
221
+ <PresenterBottomBar
222
+ index={index}
223
+ total={total}
224
+ blackout={blackout}
225
+ onPrev={goPrev}
226
+ onNext={goNext}
227
+ onBlackout={toggleBlack}
228
+ onWhiteout={toggleWhite}
229
+ />
230
+ </div>
231
+ );
232
+ }
233
+
234
+ function PresenterTopBar({
235
+ index,
236
+ total,
237
+ startedAt,
238
+ slideTitle,
239
+ connected,
240
+ }: {
241
+ index: number;
242
+ total: number;
243
+ startedAt: number;
244
+ slideTitle: string;
245
+ connected: boolean;
246
+ }) {
247
+ return (
248
+ <header className="flex shrink-0 items-center justify-between border-b border-white/10 px-6 py-3">
249
+ <div className="flex items-baseline gap-3">
250
+ <span className="eyebrow text-white/45">Presenter</span>
251
+ <span className="truncate font-heading text-[14px] font-semibold tracking-tight">
252
+ {slideTitle}
253
+ </span>
254
+ {!connected && (
255
+ <span className="rounded-[3px] border border-amber-300/30 bg-amber-300/10 px-1.5 py-0.5 font-mono text-[10px] tracking-[0.06em] uppercase text-amber-200/85">
256
+ Not linked
257
+ </span>
258
+ )}
259
+ </div>
260
+ <div className="flex items-center gap-6">
261
+ <Clock />
262
+ <ElapsedClock startedAt={startedAt} />
263
+ <div className="font-mono text-[18px] tabular-nums">
264
+ <span className="text-white">{(index + 1).toString().padStart(2, '0')}</span>
265
+ <span className="text-white/35"> / </span>
266
+ <span className="text-white/55">{total.toString().padStart(2, '0')}</span>
267
+ </div>
268
+ </div>
269
+ </header>
270
+ );
271
+ }
272
+
273
+ function PresenterBottomBar({
274
+ index,
275
+ total,
276
+ blackout,
277
+ onPrev,
278
+ onNext,
279
+ onBlackout,
280
+ onWhiteout,
281
+ }: {
282
+ index: number;
283
+ total: number;
284
+ blackout: 'black' | 'white' | null;
285
+ onPrev: () => void;
286
+ onNext: () => void;
287
+ onBlackout: () => void;
288
+ onWhiteout: () => void;
289
+ }) {
290
+ return (
291
+ <footer className="flex shrink-0 items-center justify-between gap-3 border-t border-white/10 px-6 py-3">
292
+ <div className="flex items-center gap-2">
293
+ <Button variant="outline" onClick={onPrev} disabled={index === 0}>
294
+ <ChevronLeft className="size-4" /> Prev
295
+ </Button>
296
+ <Button variant="outline" onClick={onNext} disabled={index >= total - 1}>
297
+ Next <ChevronRight className="size-4" />
298
+ </Button>
299
+ </div>
300
+ <div className="flex items-center gap-2">
301
+ <Button
302
+ variant={blackout === 'black' ? 'brand' : 'outline'}
303
+ onClick={onBlackout}
304
+ aria-pressed={blackout === 'black'}
305
+ >
306
+ <Square className="size-4 fill-current" /> Black
307
+ </Button>
308
+ <Button
309
+ variant={blackout === 'white' ? 'brand' : 'outline'}
310
+ onClick={onWhiteout}
311
+ aria-pressed={blackout === 'white'}
312
+ >
313
+ <Sun className="size-4" /> White
314
+ </Button>
315
+ <Button variant="ghost" onClick={() => window.location.reload()} title="Reset timer">
316
+ <RotateCcw className="size-4" /> Reset
317
+ </Button>
318
+ </div>
319
+ </footer>
320
+ );
321
+ }
322
+
323
+ function PresenterJumpControl({
324
+ total,
325
+ current,
326
+ onJump,
327
+ }: {
328
+ total: number;
329
+ current: number;
330
+ onJump: (index: number) => void;
331
+ }) {
332
+ const [value, setValue] = useState('');
333
+ return (
334
+ <form
335
+ onSubmit={(e) => {
336
+ e.preventDefault();
337
+ const n = Number.parseInt(value, 10);
338
+ if (Number.isFinite(n) && n >= 1 && n <= total) {
339
+ onJump(n - 1);
340
+ setValue('');
341
+ }
342
+ }}
343
+ className="flex items-center gap-2"
344
+ >
345
+ <SectionLabel>Jump</SectionLabel>
346
+ <input
347
+ type="number"
348
+ min={1}
349
+ max={total}
350
+ value={value}
351
+ onChange={(e) => setValue(e.target.value)}
352
+ placeholder={(current + 1).toString()}
353
+ className="h-8 w-20 rounded-[5px] border border-white/15 bg-black/40 px-2 font-mono text-[12px] tabular-nums outline-none focus-visible:border-white/30"
354
+ />
355
+ <span className="font-mono text-[11px] text-white/45">/ {total}</span>
356
+ </form>
357
+ );
358
+ }
359
+
360
+ function SectionLabel({ children }: { children: React.ReactNode }) {
361
+ return <span className="eyebrow text-white/45">{children}</span>;
362
+ }
363
+
364
+ function Clock() {
365
+ const [now, setNow] = useState(() => new Date());
366
+ useEffect(() => {
367
+ const id = setInterval(() => setNow(new Date()), 1000);
368
+ return () => clearInterval(id);
369
+ }, []);
370
+ return (
371
+ <time
372
+ title="Current time"
373
+ className="font-mono text-[12px] tabular-nums text-white/55"
374
+ >
375
+ {now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
376
+ </time>
377
+ );
378
+ }
379
+
380
+ function ElapsedClock({ startedAt }: { startedAt: number }) {
381
+ const [now, setNow] = useState(() => Date.now());
382
+ useEffect(() => {
383
+ const id = setInterval(() => setNow(Date.now()), 1000);
384
+ return () => clearInterval(id);
385
+ }, []);
386
+ const elapsed = Math.max(0, Math.floor((now - startedAt) / 1000));
387
+ const h = Math.floor(elapsed / 3600);
388
+ const m = Math.floor((elapsed % 3600) / 60);
389
+ const s = elapsed % 60;
390
+ const text =
391
+ h > 0
392
+ ? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
393
+ : `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
394
+ return (
395
+ <time title="Elapsed" className="font-mono text-[18px] tabular-nums text-white">
396
+ {text}
397
+ </time>
398
+ );
399
+ }
400
+