@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.
- package/dist/{build-DHiRlpjn.js → build-DC3FTpWO.js} +2 -1
- package/dist/cli/bin.js +43 -4
- package/dist/{config-LZM903FE.js → config-Cuw0mC5h.js} +592 -63
- package/dist/design-BUML7uvZ.js +35 -0
- package/dist/{dev-B3JzCYn7.js → dev-BuWsdYvn.js} +2 -1
- package/dist/index.d.ts +55 -4
- package/dist/index.js +110 -1
- package/dist/{preview-UikovHEt.js → preview-CIcG-lP3.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/design.ts +64 -0
- 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,519 @@
|
|
|
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/asset-view';
|
|
7
|
+
import { HistoryProvider } from '@/components/history-provider';
|
|
8
|
+
import { CommentWidget } from '@/components/inspector/comment-widget';
|
|
9
|
+
import { InspectOverlay } from '@/components/inspector/inspect-overlay';
|
|
10
|
+
import { InspectorPanel } from '@/components/inspector/inspector-panel';
|
|
11
|
+
import {
|
|
12
|
+
InspectorProvider,
|
|
13
|
+
InspectToggleButton,
|
|
14
|
+
useInspector,
|
|
15
|
+
} from '@/components/inspector/inspector-provider';
|
|
16
|
+
import { SaveBar } from '@/components/inspector/save-bar';
|
|
17
|
+
import { DesignProvider } from '@/components/style-panel/design-provider';
|
|
18
|
+
import { DesignPanel, DesignToggleButton } from '@/components/style-panel/style-panel';
|
|
19
|
+
import { Button, buttonVariants } from '@/components/ui/button';
|
|
20
|
+
import {
|
|
21
|
+
DropdownMenu,
|
|
22
|
+
DropdownMenuContent,
|
|
23
|
+
DropdownMenuItem,
|
|
24
|
+
DropdownMenuTrigger,
|
|
25
|
+
} from '@/components/ui/dropdown-menu';
|
|
26
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
27
|
+
import { useFolders } from '@/lib/folders';
|
|
28
|
+
import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
|
|
29
|
+
import { cn } from '@/lib/utils';
|
|
30
|
+
import { ClickNavZones } from '../components/click-nav-zones';
|
|
31
|
+
import { PdfProgressToast } from '../components/pdf-progress-toast';
|
|
32
|
+
import { Player } from '../components/player';
|
|
33
|
+
import { SlideCanvas } from '../components/slide-canvas';
|
|
34
|
+
import { ThumbnailRail } from '../components/thumbnail-rail';
|
|
35
|
+
import { exportSlideAsHtml } from '../lib/export-html';
|
|
36
|
+
import { exportSlideAsPdf } from '../lib/export-pdf';
|
|
37
|
+
import type { SlideModule } from '../lib/sdk';
|
|
38
|
+
import { loadSlide } from '../lib/slides';
|
|
39
|
+
|
|
40
|
+
const { showSlideUi, showSlideBrowser, allowHtmlDownload } = config.build;
|
|
41
|
+
|
|
42
|
+
export function Slide() {
|
|
43
|
+
const { slideId = '' } = useParams();
|
|
44
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
45
|
+
const [slide, setSlide] = useState<SlideModule | null>(null);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
const [playing, setPlaying] = useState(false);
|
|
48
|
+
const [exporting, setExporting] = useState(false);
|
|
49
|
+
const [designOpen, setDesignOpen] = useState(false);
|
|
50
|
+
const { renameSlide } = useFolders();
|
|
51
|
+
const slideViewportRef = useRef<HTMLElement>(null);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
let cancelled = false;
|
|
55
|
+
setSlide(null);
|
|
56
|
+
setError(null);
|
|
57
|
+
loadSlide(slideId)
|
|
58
|
+
.then((mod) => {
|
|
59
|
+
if (!cancelled) setSlide(mod);
|
|
60
|
+
})
|
|
61
|
+
.catch((e) => {
|
|
62
|
+
if (!cancelled) setError(String(e?.message ?? e));
|
|
63
|
+
});
|
|
64
|
+
return () => {
|
|
65
|
+
cancelled = true;
|
|
66
|
+
};
|
|
67
|
+
}, [slideId]);
|
|
68
|
+
|
|
69
|
+
const pages = useMemo(() => slide?.default ?? [], [slide]);
|
|
70
|
+
const pageCount = pages.length;
|
|
71
|
+
const rawIndex = Number(searchParams.get('p') ?? '1') - 1;
|
|
72
|
+
const index = Number.isFinite(rawIndex) ? Math.max(0, Math.min(pageCount - 1, rawIndex)) : 0;
|
|
73
|
+
const view = searchParams.get('view') === 'assets' ? 'assets' : 'slides';
|
|
74
|
+
|
|
75
|
+
const goTo = useCallback(
|
|
76
|
+
(i: number) => {
|
|
77
|
+
const clamped = Math.max(0, Math.min(pageCount - 1, i));
|
|
78
|
+
setSearchParams(
|
|
79
|
+
(prev) => {
|
|
80
|
+
const next = new URLSearchParams(prev);
|
|
81
|
+
next.set('p', String(clamped + 1));
|
|
82
|
+
return next;
|
|
83
|
+
},
|
|
84
|
+
{ replace: true },
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
[pageCount, setSearchParams],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (playing) return;
|
|
92
|
+
const onKey = (e: KeyboardEvent) => {
|
|
93
|
+
if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
|
|
94
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === 'PageDown') {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
goTo(index + 1);
|
|
97
|
+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
goTo(index - 1);
|
|
100
|
+
} else if (e.key === 'f' || e.key === 'F') {
|
|
101
|
+
setPlaying(true);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
window.addEventListener('keydown', onKey);
|
|
105
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
106
|
+
}, [index, goTo, playing]);
|
|
107
|
+
|
|
108
|
+
if (error) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
|
|
111
|
+
{showSlideBrowser && (
|
|
112
|
+
<Link to="/" className="text-[12px] font-medium text-foreground/70 hover:text-foreground">
|
|
113
|
+
← Home
|
|
114
|
+
</Link>
|
|
115
|
+
)}
|
|
116
|
+
<span className="mt-6 block eyebrow text-destructive/80">Load failed</span>
|
|
117
|
+
<h2 className="mt-2 font-heading text-xl font-semibold tracking-tight text-foreground">
|
|
118
|
+
Failed to load slide
|
|
119
|
+
</h2>
|
|
120
|
+
<pre className="mt-4 overflow-auto rounded-[6px] border border-border bg-card p-4 text-[11.5px] leading-relaxed whitespace-pre-wrap shadow-edge">
|
|
121
|
+
{error}
|
|
122
|
+
</pre>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!slide) {
|
|
128
|
+
return (
|
|
129
|
+
<div className="mx-auto max-w-3xl px-8 py-16 text-[12.5px] text-muted-foreground">
|
|
130
|
+
<span className="eyebrow">Loading</span>
|
|
131
|
+
<p className="mt-2 font-mono">{slideId}</p>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (pageCount === 0) {
|
|
137
|
+
return (
|
|
138
|
+
<div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
|
|
139
|
+
{showSlideBrowser && (
|
|
140
|
+
<Link to="/" className="text-[12px] font-medium text-foreground/70 hover:text-foreground">
|
|
141
|
+
← Home
|
|
142
|
+
</Link>
|
|
143
|
+
)}
|
|
144
|
+
<span className="mt-6 block eyebrow">Empty</span>
|
|
145
|
+
<h2 className="mt-2 font-heading text-xl font-semibold tracking-tight text-foreground">
|
|
146
|
+
Nothing to show.
|
|
147
|
+
</h2>
|
|
148
|
+
<p className="mt-3 text-[13px] leading-relaxed">
|
|
149
|
+
<code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px]">
|
|
150
|
+
slides/{slideId}/index.tsx
|
|
151
|
+
</code>{' '}
|
|
152
|
+
must{' '}
|
|
153
|
+
<code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px]">
|
|
154
|
+
export default
|
|
155
|
+
</code>{' '}
|
|
156
|
+
a non-empty array of components.
|
|
157
|
+
</p>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!showSlideUi) {
|
|
163
|
+
return (
|
|
164
|
+
<Player
|
|
165
|
+
pages={pages}
|
|
166
|
+
design={slide.design}
|
|
167
|
+
index={index}
|
|
168
|
+
onIndexChange={goTo}
|
|
169
|
+
onExit={() => {}}
|
|
170
|
+
allowExit={false}
|
|
171
|
+
/>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (playing) {
|
|
176
|
+
return (
|
|
177
|
+
<Player
|
|
178
|
+
pages={pages}
|
|
179
|
+
design={slide.design}
|
|
180
|
+
index={index}
|
|
181
|
+
onIndexChange={goTo}
|
|
182
|
+
onExit={() => setPlaying(false)}
|
|
183
|
+
controls
|
|
184
|
+
slideId={slideId}
|
|
185
|
+
/>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const CurrentPage = pages[index];
|
|
190
|
+
const title = slide.meta?.title ?? slideId;
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<HistoryProvider>
|
|
194
|
+
<InspectorProvider slideId={slideId}>
|
|
195
|
+
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
|
196
|
+
{/* Editorial toolbar — three zones, hairline separators, mono-folio center */}
|
|
197
|
+
<header className="relative flex h-12 shrink-0 items-center justify-between border-b border-hairline bg-sidebar/85 px-2 backdrop-blur-md md:px-3">
|
|
198
|
+
<div className="flex items-center gap-1.5 md:gap-2">
|
|
199
|
+
{showSlideBrowser && (
|
|
200
|
+
<Button asChild variant="ghost" size="icon-sm" title="Home">
|
|
201
|
+
<Link to="/" aria-label="Back to home">
|
|
202
|
+
<ChevronLeft className="size-4" />
|
|
203
|
+
</Link>
|
|
204
|
+
</Button>
|
|
205
|
+
)}
|
|
206
|
+
<span aria-hidden className="mx-0.5 hidden h-5 w-px bg-hairline md:block" />
|
|
207
|
+
{import.meta.env.DEV && (
|
|
208
|
+
<Tabs
|
|
209
|
+
value={view}
|
|
210
|
+
onValueChange={(next) => {
|
|
211
|
+
setSearchParams(
|
|
212
|
+
(prev) => {
|
|
213
|
+
const params = new URLSearchParams(prev);
|
|
214
|
+
if (next === 'assets') params.set('view', 'assets');
|
|
215
|
+
else params.delete('view');
|
|
216
|
+
return params;
|
|
217
|
+
},
|
|
218
|
+
{ replace: true },
|
|
219
|
+
);
|
|
220
|
+
}}
|
|
221
|
+
>
|
|
222
|
+
<TabsList>
|
|
223
|
+
<TabsTrigger value="slides">Slides</TabsTrigger>
|
|
224
|
+
<TabsTrigger value="assets">Assets</TabsTrigger>
|
|
225
|
+
</TabsList>
|
|
226
|
+
</Tabs>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{/* Centered title — the rail and mobile pill carry the page count. */}
|
|
231
|
+
<div className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-center px-2">
|
|
232
|
+
<div className="pointer-events-auto min-w-0 max-w-[min(34rem,calc(100vw-22rem))]">
|
|
233
|
+
<InlineTitleEditor title={title} onSubmit={(next) => renameSlide(slideId, next)} />
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div className="flex items-center gap-1">
|
|
238
|
+
{view === 'slides' && allowHtmlDownload && (
|
|
239
|
+
<DropdownMenu>
|
|
240
|
+
<DropdownMenuTrigger
|
|
241
|
+
type="button"
|
|
242
|
+
disabled={exporting}
|
|
243
|
+
aria-label="Download"
|
|
244
|
+
title="Download"
|
|
245
|
+
className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }))}
|
|
246
|
+
>
|
|
247
|
+
{exporting ? (
|
|
248
|
+
<Loader2 className="size-4 animate-spin" />
|
|
249
|
+
) : (
|
|
250
|
+
<Download className="size-4" />
|
|
251
|
+
)}
|
|
252
|
+
</DropdownMenuTrigger>
|
|
253
|
+
<DropdownMenuContent align="end" className="min-w-[200px]">
|
|
254
|
+
<DropdownMenuItem
|
|
255
|
+
disabled={exporting}
|
|
256
|
+
onSelect={async () => {
|
|
257
|
+
if (!slide || exporting) return;
|
|
258
|
+
setExporting(true);
|
|
259
|
+
try {
|
|
260
|
+
await exportSlideAsHtml(slide, slideId);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.error('[open-slide] export failed', err);
|
|
263
|
+
} finally {
|
|
264
|
+
setExporting(false);
|
|
265
|
+
}
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
<FileCode2 />
|
|
269
|
+
Export as HTML
|
|
270
|
+
</DropdownMenuItem>
|
|
271
|
+
<DropdownMenuItem
|
|
272
|
+
disabled={exporting}
|
|
273
|
+
onSelect={async () => {
|
|
274
|
+
if (!slide || exporting) return;
|
|
275
|
+
setExporting(true);
|
|
276
|
+
const toastId = `pdf-export-${slideId}`;
|
|
277
|
+
toast.custom(
|
|
278
|
+
() => (
|
|
279
|
+
<PdfProgressToast
|
|
280
|
+
progress={{
|
|
281
|
+
phase: 'processing',
|
|
282
|
+
current: 0,
|
|
283
|
+
total: pages.length,
|
|
284
|
+
percent: 0,
|
|
285
|
+
}}
|
|
286
|
+
/>
|
|
287
|
+
),
|
|
288
|
+
{ id: toastId, duration: Infinity },
|
|
289
|
+
);
|
|
290
|
+
try {
|
|
291
|
+
await exportSlideAsPdf(slide, slideId, (p) => {
|
|
292
|
+
toast.custom(() => <PdfProgressToast progress={p} />, {
|
|
293
|
+
id: toastId,
|
|
294
|
+
duration: Infinity,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.error('[open-slide] pdf export failed', err);
|
|
299
|
+
toast.error('PDF export failed', { id: toastId, duration: 4000 });
|
|
300
|
+
} finally {
|
|
301
|
+
setExporting(false);
|
|
302
|
+
toast.dismiss(toastId);
|
|
303
|
+
}
|
|
304
|
+
}}
|
|
305
|
+
>
|
|
306
|
+
<FileText />
|
|
307
|
+
Export as PDF
|
|
308
|
+
</DropdownMenuItem>
|
|
309
|
+
</DropdownMenuContent>
|
|
310
|
+
</DropdownMenu>
|
|
311
|
+
)}
|
|
312
|
+
{view === 'slides' && (
|
|
313
|
+
<DesignToggleButton active={designOpen} onToggle={() => setDesignOpen((v) => !v)} />
|
|
314
|
+
)}
|
|
315
|
+
{view === 'slides' && <InspectToggleButton />}
|
|
316
|
+
<span aria-hidden className="mx-0.5 hidden h-5 w-px bg-hairline md:block" />
|
|
317
|
+
{view === 'slides' && (
|
|
318
|
+
<Button
|
|
319
|
+
size="sm"
|
|
320
|
+
variant="brand"
|
|
321
|
+
onClick={() => setPlaying(true)}
|
|
322
|
+
className="px-2.5 md:px-3"
|
|
323
|
+
>
|
|
324
|
+
<Play className="size-3.5 fill-current" />
|
|
325
|
+
<span className="hidden md:inline">Present</span>
|
|
326
|
+
<kbd className="ml-1 hidden rounded-[3px] bg-brand-foreground/15 px-1 font-mono text-[9.5px] tracking-[0.04em] md:inline">
|
|
327
|
+
F
|
|
328
|
+
</kbd>
|
|
329
|
+
</Button>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
</header>
|
|
333
|
+
|
|
334
|
+
{view === 'assets' ? (
|
|
335
|
+
<div className="min-h-0 flex-1">
|
|
336
|
+
<AssetView slideId={slideId} />
|
|
337
|
+
</div>
|
|
338
|
+
) : (
|
|
339
|
+
<DesignProvider slideId={slideId}>
|
|
340
|
+
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
|
341
|
+
<div className="hidden w-[16.5rem] shrink-0 md:block">
|
|
342
|
+
<ThumbnailRail
|
|
343
|
+
pages={pages}
|
|
344
|
+
design={slide.design}
|
|
345
|
+
current={index}
|
|
346
|
+
onSelect={goTo}
|
|
347
|
+
/>
|
|
348
|
+
</div>
|
|
349
|
+
<main
|
|
350
|
+
ref={slideViewportRef}
|
|
351
|
+
data-inspector-root
|
|
352
|
+
className="paper relative min-h-0 min-w-0 flex-1 bg-canvas p-2 md:p-10"
|
|
353
|
+
>
|
|
354
|
+
<SlideWheelNavigation
|
|
355
|
+
targetRef={slideViewportRef}
|
|
356
|
+
onPrev={() => goTo(index - 1)}
|
|
357
|
+
onNext={() => goTo(index + 1)}
|
|
358
|
+
canPrev={index > 0}
|
|
359
|
+
canNext={index < pageCount - 1}
|
|
360
|
+
/>
|
|
361
|
+
<SlideCanvas design={slide.design}>
|
|
362
|
+
<CurrentPage />
|
|
363
|
+
</SlideCanvas>
|
|
364
|
+
<ClickNavZones
|
|
365
|
+
onPrev={() => goTo(index - 1)}
|
|
366
|
+
onNext={() => goTo(index + 1)}
|
|
367
|
+
canPrev={index > 0}
|
|
368
|
+
canNext={index < pageCount - 1}
|
|
369
|
+
/>
|
|
370
|
+
<InspectOverlay />
|
|
371
|
+
<SaveBar />
|
|
372
|
+
{import.meta.env.DEV && <CommentWidget />}
|
|
373
|
+
</main>
|
|
374
|
+
{/* Mobile-only horizontal rail. Sits below the canvas and
|
|
375
|
+
pads its bottom for the iOS home indicator / Safari URL bar. */}
|
|
376
|
+
<div
|
|
377
|
+
className="shrink-0 border-t border-hairline md:hidden"
|
|
378
|
+
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
|
379
|
+
>
|
|
380
|
+
<ThumbnailRail
|
|
381
|
+
pages={pages}
|
|
382
|
+
design={slide.design}
|
|
383
|
+
current={index}
|
|
384
|
+
onSelect={goTo}
|
|
385
|
+
orientation="horizontal"
|
|
386
|
+
/>
|
|
387
|
+
</div>
|
|
388
|
+
<InspectorPanel />
|
|
389
|
+
<DesignPanel open={designOpen} onClose={() => setDesignOpen(false)} />
|
|
390
|
+
</div>
|
|
391
|
+
</DesignProvider>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
</InspectorProvider>
|
|
395
|
+
</HistoryProvider>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function SlideWheelNavigation({
|
|
400
|
+
targetRef,
|
|
401
|
+
onPrev,
|
|
402
|
+
onNext,
|
|
403
|
+
canPrev,
|
|
404
|
+
canNext,
|
|
405
|
+
}: {
|
|
406
|
+
targetRef: RefObject<HTMLElement>;
|
|
407
|
+
onPrev: () => void;
|
|
408
|
+
onNext: () => void;
|
|
409
|
+
canPrev: boolean;
|
|
410
|
+
canNext: boolean;
|
|
411
|
+
}) {
|
|
412
|
+
const { active } = useInspector();
|
|
413
|
+
|
|
414
|
+
useWheelPageNavigation({
|
|
415
|
+
ref: targetRef,
|
|
416
|
+
enabled: !active,
|
|
417
|
+
canPrev,
|
|
418
|
+
canNext,
|
|
419
|
+
onPrev,
|
|
420
|
+
onNext,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function InlineTitleEditor({
|
|
427
|
+
title,
|
|
428
|
+
onSubmit,
|
|
429
|
+
}: {
|
|
430
|
+
title: string;
|
|
431
|
+
onSubmit: (name: string) => Promise<void> | void;
|
|
432
|
+
}) {
|
|
433
|
+
const [editing, setEditing] = useState(false);
|
|
434
|
+
const [value, setValue] = useState(title);
|
|
435
|
+
const [saving, setSaving] = useState(false);
|
|
436
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
437
|
+
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
if (!editing) setValue(title);
|
|
440
|
+
}, [title, editing]);
|
|
441
|
+
|
|
442
|
+
useEffect(() => {
|
|
443
|
+
if (editing) {
|
|
444
|
+
queueMicrotask(() => {
|
|
445
|
+
inputRef.current?.focus();
|
|
446
|
+
inputRef.current?.select();
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}, [editing]);
|
|
450
|
+
|
|
451
|
+
const commit = async () => {
|
|
452
|
+
const trimmed = value.trim();
|
|
453
|
+
if (!trimmed || trimmed === title) {
|
|
454
|
+
setValue(title);
|
|
455
|
+
setEditing(false);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
setSaving(true);
|
|
459
|
+
try {
|
|
460
|
+
await onSubmit(trimmed);
|
|
461
|
+
setEditing(false);
|
|
462
|
+
} finally {
|
|
463
|
+
setSaving(false);
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const cancel = () => {
|
|
468
|
+
setValue(title);
|
|
469
|
+
setEditing(false);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
if (editing) {
|
|
473
|
+
return (
|
|
474
|
+
<div className="flex flex-1 items-center justify-center">
|
|
475
|
+
<input
|
|
476
|
+
ref={inputRef}
|
|
477
|
+
value={value}
|
|
478
|
+
disabled={saving}
|
|
479
|
+
onChange={(e) => setValue(e.target.value)}
|
|
480
|
+
onBlur={() => {
|
|
481
|
+
if (!saving) commit();
|
|
482
|
+
}}
|
|
483
|
+
onKeyDown={(e) => {
|
|
484
|
+
if (e.key === 'Enter') {
|
|
485
|
+
e.preventDefault();
|
|
486
|
+
commit();
|
|
487
|
+
} else if (e.key === 'Escape') {
|
|
488
|
+
e.preventDefault();
|
|
489
|
+
cancel();
|
|
490
|
+
}
|
|
491
|
+
}}
|
|
492
|
+
maxLength={80}
|
|
493
|
+
className="min-w-0 max-w-[min(34rem,90%)] rounded-[5px] border border-foreground/30 bg-card px-2 py-0.5 text-center font-heading text-[13px] font-medium tracking-tight outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
494
|
+
/>
|
|
495
|
+
</div>
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return (
|
|
500
|
+
<div className="group/title flex min-w-0 items-baseline justify-center gap-1.5">
|
|
501
|
+
<h1 className="truncate font-heading text-[13.5px] font-semibold tracking-[-0.01em]">
|
|
502
|
+
{title}
|
|
503
|
+
</h1>
|
|
504
|
+
{import.meta.env.DEV && (
|
|
505
|
+
<button
|
|
506
|
+
type="button"
|
|
507
|
+
onClick={() => setEditing(true)}
|
|
508
|
+
aria-label="Rename slide"
|
|
509
|
+
className={cn(
|
|
510
|
+
'flex size-5 shrink-0 items-center justify-center rounded-[4px] text-muted-foreground transition-opacity hover:bg-muted hover:text-foreground',
|
|
511
|
+
'opacity-0 group-hover/title:opacity-100 focus-visible:opacity-100',
|
|
512
|
+
)}
|
|
513
|
+
>
|
|
514
|
+
<Pencil className="size-3" />
|
|
515
|
+
</button>
|
|
516
|
+
)}
|
|
517
|
+
</div>
|
|
518
|
+
);
|
|
519
|
+
}
|