@open-slide/core 0.0.11 → 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
@@ -1,77 +0,0 @@
1
- import { Check, Loader2, Save, Undo2 } from 'lucide-react';
2
- import { useEffect, useState } from 'react';
3
- import { Button } from '@/components/ui/button';
4
- import { useInspector } from './InspectorProvider';
5
-
6
- // Optimistic DOM updates make the canvas *look* saved, so without
7
- // this affordance a user could close the tab thinking their tweaks
8
- // hit disk when they're still buffered in memory.
9
- export function SaveBar() {
10
- const { pendingCount, commitEdits, cancelEdits, committing } = useInspector();
11
- const [justSaved, setJustSaved] = useState(false);
12
-
13
- // Brief "Saved" hold so the bar's disappearance feels intentional.
14
- useEffect(() => {
15
- if (!justSaved) return;
16
- const t = setTimeout(() => setJustSaved(false), 1200);
17
- return () => clearTimeout(t);
18
- }, [justSaved]);
19
-
20
- const visible = pendingCount > 0 || committing || justSaved;
21
- if (!visible) return null;
22
-
23
- const onSave = async () => {
24
- await commitEdits();
25
- setJustSaved(true);
26
- };
27
-
28
- return (
29
- <div
30
- data-inspector-ui
31
- className="pointer-events-none absolute bottom-6 left-1/2 z-30 -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-200"
32
- >
33
- <div className="pointer-events-auto flex items-center gap-2 rounded-full border bg-card/95 py-1 pr-1 pl-3 shadow-lg backdrop-blur">
34
- {justSaved ? (
35
- <span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
36
- <Check className="size-3.5 text-emerald-600" />
37
- Saved
38
- </span>
39
- ) : (
40
- <span className="text-xs font-medium text-foreground">
41
- {pendingCount} unsaved {pendingCount === 1 ? 'change' : 'changes'}
42
- </span>
43
- )}
44
- {!justSaved && (
45
- <Button
46
- size="sm"
47
- variant="ghost"
48
- className="h-7 rounded-full px-2.5 text-[11px] text-muted-foreground hover:text-foreground"
49
- onClick={cancelEdits}
50
- disabled={committing || pendingCount === 0}
51
- >
52
- <Undo2 className="size-3.5" />
53
- Discard
54
- </Button>
55
- )}
56
- <Button
57
- size="sm"
58
- className="h-7 rounded-full px-3 text-[11px]"
59
- onClick={onSave}
60
- disabled={committing || pendingCount === 0}
61
- >
62
- {committing ? (
63
- <>
64
- <Loader2 className="size-3.5 animate-spin" />
65
- Saving
66
- </>
67
- ) : (
68
- <>
69
- <Save className="size-3.5" />
70
- Save
71
- </>
72
- )}
73
- </Button>
74
- </div>
75
- </div>
76
- );
77
- }
@@ -1,478 +0,0 @@
1
- import config from 'virtual:open-slide/config';
2
- import { ChevronLeft, Download, FileCode2, FileText, Loader2, Pencil, Play } from 'lucide-react';
3
- import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
- import { Link, useParams, useSearchParams } from 'react-router-dom';
5
- import { toast } from 'sonner';
6
- import { AssetView } from '@/components/AssetView';
7
- import { CommentWidget } from '@/components/inspector/CommentWidget';
8
- import { InspectOverlay } from '@/components/inspector/InspectOverlay';
9
- import { InspectorPanel } from '@/components/inspector/InspectorPanel';
10
- import {
11
- InspectorProvider,
12
- InspectToggleButton,
13
- useInspector,
14
- } from '@/components/inspector/InspectorProvider';
15
- import { SaveBar } from '@/components/inspector/SaveBar';
16
- import { Button, buttonVariants } from '@/components/ui/button';
17
- import {
18
- DropdownMenu,
19
- DropdownMenuContent,
20
- DropdownMenuItem,
21
- DropdownMenuTrigger,
22
- } from '@/components/ui/dropdown-menu';
23
- import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
24
- import { useFolders } from '@/lib/folders';
25
- import { useWheelPageNavigation } from '@/lib/useWheelPageNavigation';
26
- import { cn } from '@/lib/utils';
27
- import { ClickNavZones } from '../components/ClickNavZones';
28
- import { PdfProgressToast } from '../components/PdfProgressToast';
29
- import { Player } from '../components/Player';
30
- import { SlideCanvas } from '../components/SlideCanvas';
31
- import { ThumbnailRail } from '../components/ThumbnailRail';
32
- import { exportSlideAsHtml } from '../lib/export-html';
33
- import { exportSlideAsPdf } from '../lib/export-pdf';
34
- import type { SlideModule } from '../lib/sdk';
35
- import { loadSlide } from '../lib/slides';
36
-
37
- const { showSlideUi, showSlideBrowser, allowHtmlDownload } = config.build;
38
-
39
- export function Slide() {
40
- const { slideId = '' } = useParams();
41
- const [searchParams, setSearchParams] = useSearchParams();
42
- const [slide, setSlide] = useState<SlideModule | null>(null);
43
- const [error, setError] = useState<string | null>(null);
44
- const [playing, setPlaying] = useState(false);
45
- const [exporting, setExporting] = useState(false);
46
- const { renameSlide } = useFolders();
47
- const slideViewportRef = useRef<HTMLElement>(null);
48
-
49
- useEffect(() => {
50
- let cancelled = false;
51
- setSlide(null);
52
- setError(null);
53
- loadSlide(slideId)
54
- .then((mod) => {
55
- if (!cancelled) setSlide(mod);
56
- })
57
- .catch((e) => {
58
- if (!cancelled) setError(String(e?.message ?? e));
59
- });
60
- return () => {
61
- cancelled = true;
62
- };
63
- }, [slideId]);
64
-
65
- const pages = useMemo(() => slide?.default ?? [], [slide]);
66
- const pageCount = pages.length;
67
- const rawIndex = Number(searchParams.get('p') ?? '1') - 1;
68
- const index = Number.isFinite(rawIndex) ? Math.max(0, Math.min(pageCount - 1, rawIndex)) : 0;
69
- const view = searchParams.get('view') === 'assets' ? 'assets' : 'slides';
70
-
71
- const goTo = useCallback(
72
- (i: number) => {
73
- const clamped = Math.max(0, Math.min(pageCount - 1, i));
74
- setSearchParams(
75
- (prev) => {
76
- const next = new URLSearchParams(prev);
77
- next.set('p', String(clamped + 1));
78
- return next;
79
- },
80
- { replace: true },
81
- );
82
- },
83
- [pageCount, setSearchParams],
84
- );
85
-
86
- useEffect(() => {
87
- if (playing) return;
88
- const onKey = (e: KeyboardEvent) => {
89
- if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
90
- if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === 'PageDown') {
91
- e.preventDefault();
92
- goTo(index + 1);
93
- } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
94
- e.preventDefault();
95
- goTo(index - 1);
96
- } else if (e.key === 'f' || e.key === 'F') {
97
- setPlaying(true);
98
- }
99
- };
100
- window.addEventListener('keydown', onKey);
101
- return () => window.removeEventListener('keydown', onKey);
102
- }, [index, goTo, playing]);
103
-
104
- if (error) {
105
- return (
106
- <div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
107
- {showSlideBrowser && (
108
- <Link to="/" className="text-sm font-medium text-primary hover:underline">
109
- ← Home
110
- </Link>
111
- )}
112
- <h2 className="mt-4 text-xl font-semibold text-foreground">Failed to load slide</h2>
113
- <pre className="mt-4 overflow-auto rounded-md border bg-card p-4 text-xs whitespace-pre-wrap shadow-sm">
114
- {error}
115
- </pre>
116
- </div>
117
- );
118
- }
119
-
120
- if (!slide) {
121
- return (
122
- <div className="mx-auto max-w-3xl px-8 py-16 text-sm text-muted-foreground">
123
- Loading {slideId}…
124
- </div>
125
- );
126
- }
127
-
128
- if (pageCount === 0) {
129
- return (
130
- <div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
131
- {showSlideBrowser && (
132
- <Link to="/" className="text-sm font-medium text-primary hover:underline">
133
- ← Home
134
- </Link>
135
- )}
136
- <h2 className="mt-4 text-xl font-semibold text-foreground">Empty slide</h2>
137
- <p className="mt-2 text-sm">
138
- <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
139
- slides/{slideId}/index.tsx
140
- </code>{' '}
141
- must{' '}
142
- <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">export default</code> a
143
- non-empty array of components.
144
- </p>
145
- </div>
146
- );
147
- }
148
-
149
- if (!showSlideUi) {
150
- return (
151
- <Player
152
- pages={pages}
153
- index={index}
154
- onIndexChange={goTo}
155
- onExit={() => {}}
156
- allowExit={false}
157
- />
158
- );
159
- }
160
-
161
- if (playing) {
162
- return (
163
- <Player pages={pages} index={index} onIndexChange={goTo} onExit={() => setPlaying(false)} />
164
- );
165
- }
166
-
167
- const CurrentPage = pages[index];
168
- const title = slide.meta?.title ?? slideId;
169
-
170
- return (
171
- <InspectorProvider slideId={slideId}>
172
- <div className="flex h-screen flex-col overflow-hidden bg-background">
173
- <header className="relative flex shrink-0 items-center justify-between border-b bg-card px-3 py-2 md:px-5 md:py-3">
174
- <div className="flex items-center gap-2 md:gap-3">
175
- {showSlideBrowser && (
176
- <Button asChild variant="ghost" size="sm" className="px-2 md:px-3">
177
- <Link to="/">
178
- <ChevronLeft className="size-4" />
179
- <span className="hidden md:inline">Home</span>
180
- </Link>
181
- </Button>
182
- )}
183
- {import.meta.env.DEV && (
184
- <Tabs
185
- value={view}
186
- onValueChange={(next) => {
187
- setSearchParams(
188
- (prev) => {
189
- const params = new URLSearchParams(prev);
190
- if (next === 'assets') params.set('view', 'assets');
191
- else params.delete('view');
192
- return params;
193
- },
194
- { replace: true },
195
- );
196
- }}
197
- >
198
- <TabsList className="relative h-7 rounded-md p-0.5 group-data-[orientation=horizontal]/tabs:h-7">
199
- <div
200
- aria-hidden
201
- className="pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 w-[calc(50%-2px)] rounded-[5px] bg-background shadow-sm transition-transform duration-200 ease-out"
202
- style={{
203
- transform: view === 'assets' ? 'translateX(100%)' : 'translateX(0)',
204
- }}
205
- />
206
- <TabsTrigger
207
- value="slides"
208
- className="relative z-10 h-6 px-3 text-xs data-[state=active]:bg-transparent data-[state=active]:shadow-none dark:data-[state=active]:bg-transparent"
209
- >
210
- Slides
211
- </TabsTrigger>
212
- <TabsTrigger
213
- value="assets"
214
- className="relative z-10 h-6 px-3 text-xs data-[state=active]:bg-transparent data-[state=active]:shadow-none dark:data-[state=active]:bg-transparent"
215
- >
216
- Assets
217
- </TabsTrigger>
218
- </TabsList>
219
- </Tabs>
220
- )}
221
- </div>
222
-
223
- <div className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-center px-2">
224
- <div className="pointer-events-auto min-w-0 max-w-[min(32rem,calc(100vw-20rem))]">
225
- <InlineTitleEditor title={title} onSubmit={(next) => renameSlide(slideId, next)} />
226
- </div>
227
- </div>
228
-
229
- <div className="flex items-center gap-1.5">
230
- {view === 'slides' && allowHtmlDownload && (
231
- <DropdownMenu>
232
- <DropdownMenuTrigger
233
- type="button"
234
- disabled={exporting}
235
- aria-label="Download"
236
- title="Download"
237
- className={cn(buttonVariants({ variant: 'outline', size: 'icon-sm' }))}
238
- >
239
- {exporting ? (
240
- <Loader2 className="size-4 animate-spin" />
241
- ) : (
242
- <Download className="size-4" />
243
- )}
244
- </DropdownMenuTrigger>
245
- <DropdownMenuContent align="end" className="min-w-[180px]">
246
- <DropdownMenuItem
247
- disabled={exporting}
248
- onSelect={async () => {
249
- if (!slide || exporting) return;
250
- setExporting(true);
251
- try {
252
- await exportSlideAsHtml(slide, slideId);
253
- } catch (err) {
254
- console.error('[open-slide] export failed', err);
255
- } finally {
256
- setExporting(false);
257
- }
258
- }}
259
- >
260
- <FileCode2 />
261
- Download HTML
262
- </DropdownMenuItem>
263
- <DropdownMenuItem
264
- disabled={exporting}
265
- onSelect={async () => {
266
- if (!slide || exporting) return;
267
- setExporting(true);
268
- const toastId = `pdf-export-${slideId}`;
269
- toast.custom(
270
- () => (
271
- <PdfProgressToast
272
- progress={{
273
- phase: 'processing',
274
- current: 0,
275
- total: pages.length,
276
- percent: 0,
277
- }}
278
- />
279
- ),
280
- { id: toastId, duration: Infinity },
281
- );
282
- try {
283
- await exportSlideAsPdf(slide, slideId, (p) => {
284
- toast.custom(() => <PdfProgressToast progress={p} />, {
285
- id: toastId,
286
- duration: Infinity,
287
- });
288
- });
289
- } catch (err) {
290
- console.error('[open-slide] pdf export failed', err);
291
- toast.error('PDF export failed', { id: toastId, duration: 4000 });
292
- } finally {
293
- setExporting(false);
294
- toast.dismiss(toastId);
295
- }
296
- }}
297
- >
298
- <FileText />
299
- Download PDF
300
- </DropdownMenuItem>
301
- </DropdownMenuContent>
302
- </DropdownMenu>
303
- )}
304
- {view === 'slides' && <InspectToggleButton />}
305
- {view === 'slides' && (
306
- <Button size="sm" onClick={() => setPlaying(true)} className="px-2 md:px-3">
307
- <Play className="size-4" />
308
- <span className="hidden md:inline">Play</span>
309
- <kbd className="ml-1 hidden rounded bg-primary-foreground/20 px-1 text-[10px] md:inline">
310
- F
311
- </kbd>
312
- </Button>
313
- )}
314
- </div>
315
- </header>
316
-
317
- {view === 'assets' ? (
318
- <div className="min-h-0 flex-1">
319
- <AssetView slideId={slideId} />
320
- </div>
321
- ) : (
322
- <div className="flex min-h-0 flex-1">
323
- <div className="hidden w-[17rem] shrink-0 md:block">
324
- <ThumbnailRail pages={pages} current={index} onSelect={goTo} />
325
- </div>
326
- <main
327
- ref={slideViewportRef}
328
- data-inspector-root
329
- className="relative min-h-0 min-w-0 flex-1 bg-background p-2 md:p-8"
330
- >
331
- <SlideWheelNavigation
332
- targetRef={slideViewportRef}
333
- onPrev={() => goTo(index - 1)}
334
- onNext={() => goTo(index + 1)}
335
- canPrev={index > 0}
336
- canNext={index < pageCount - 1}
337
- />
338
- <SlideCanvas>
339
- <CurrentPage />
340
- </SlideCanvas>
341
- <ClickNavZones
342
- onPrev={() => goTo(index - 1)}
343
- onNext={() => goTo(index + 1)}
344
- canPrev={index > 0}
345
- canNext={index < pageCount - 1}
346
- />
347
- <InspectOverlay />
348
- <SaveBar />
349
- <CommentWidget />
350
- <div className="pointer-events-none absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full bg-black/50 px-2.5 py-0.5 text-[11px] font-medium tabular-nums text-white backdrop-blur md:hidden">
351
- {index + 1} / {pageCount}
352
- </div>
353
- </main>
354
- <InspectorPanel />
355
- </div>
356
- )}
357
- </div>
358
- </InspectorProvider>
359
- );
360
- }
361
-
362
- function SlideWheelNavigation({
363
- targetRef,
364
- onPrev,
365
- onNext,
366
- canPrev,
367
- canNext,
368
- }: {
369
- targetRef: RefObject<HTMLElement>;
370
- onPrev: () => void;
371
- onNext: () => void;
372
- canPrev: boolean;
373
- canNext: boolean;
374
- }) {
375
- const { active } = useInspector();
376
-
377
- useWheelPageNavigation({
378
- ref: targetRef,
379
- enabled: !active,
380
- canPrev,
381
- canNext,
382
- onPrev,
383
- onNext,
384
- });
385
-
386
- return null;
387
- }
388
-
389
- function InlineTitleEditor({
390
- title,
391
- onSubmit,
392
- }: {
393
- title: string;
394
- onSubmit: (name: string) => Promise<void> | void;
395
- }) {
396
- const [editing, setEditing] = useState(false);
397
- const [value, setValue] = useState(title);
398
- const [saving, setSaving] = useState(false);
399
- const inputRef = useRef<HTMLInputElement | null>(null);
400
-
401
- useEffect(() => {
402
- if (!editing) setValue(title);
403
- }, [title, editing]);
404
-
405
- useEffect(() => {
406
- if (editing) {
407
- queueMicrotask(() => {
408
- inputRef.current?.focus();
409
- inputRef.current?.select();
410
- });
411
- }
412
- }, [editing]);
413
-
414
- const commit = async () => {
415
- const trimmed = value.trim();
416
- if (!trimmed || trimmed === title) {
417
- setValue(title);
418
- setEditing(false);
419
- return;
420
- }
421
- setSaving(true);
422
- try {
423
- await onSubmit(trimmed);
424
- setEditing(false);
425
- } finally {
426
- setSaving(false);
427
- }
428
- };
429
-
430
- const cancel = () => {
431
- setValue(title);
432
- setEditing(false);
433
- };
434
-
435
- if (editing) {
436
- return (
437
- <div className="flex flex-1 items-center justify-center">
438
- <input
439
- ref={inputRef}
440
- value={value}
441
- disabled={saving}
442
- onChange={(e) => setValue(e.target.value)}
443
- onBlur={() => {
444
- if (!saving) commit();
445
- }}
446
- onKeyDown={(e) => {
447
- if (e.key === 'Enter') {
448
- e.preventDefault();
449
- commit();
450
- } else if (e.key === 'Escape') {
451
- e.preventDefault();
452
- cancel();
453
- }
454
- }}
455
- maxLength={80}
456
- className="min-w-0 max-w-[min(32rem,90%)] rounded-md border bg-background px-2 py-0.5 text-center text-xs font-semibold tracking-tight outline-none ring-ring/40 focus:ring-2 md:text-sm"
457
- />
458
- </div>
459
- );
460
- }
461
-
462
- return (
463
- <div className="group/title flex flex-1 items-center justify-center gap-1.5 min-w-0">
464
- <h1 className="truncate text-xs font-semibold tracking-tight md:text-sm">{title}</h1>
465
- <button
466
- type="button"
467
- onClick={() => setEditing(true)}
468
- aria-label="Rename slide"
469
- className={cn(
470
- 'flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-opacity hover:bg-muted hover:text-foreground',
471
- 'opacity-0 group-hover/title:opacity-100 focus-visible:opacity-100',
472
- )}
473
- >
474
- <Pencil className="size-3.5" />
475
- </button>
476
- </div>
477
- );
478
- }