@open-aippt/core 1.13.2

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 (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin.js +2 -0
  4. package/dist/build-DxTqmvsO.js +17 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +86 -0
  7. package/dist/config-CjzqjrEA.js +4280 -0
  8. package/dist/config-DIC-yVPp.d.ts +23 -0
  9. package/dist/design-cpzS8aud.js +35 -0
  10. package/dist/dev-BYuTeJbA.js +20 -0
  11. package/dist/format-BCeKbTOM.js +1605 -0
  12. package/dist/index.d.ts +134 -0
  13. package/dist/index.js +467 -0
  14. package/dist/locale/index.d.ts +24 -0
  15. package/dist/locale/index.js +3 -0
  16. package/dist/preview-DlQvnJPq.js +18 -0
  17. package/dist/sync-BPZ0m27m.js +139 -0
  18. package/dist/sync-EsYusbbL.js +3 -0
  19. package/dist/types-CHmFPIG_.d.ts +430 -0
  20. package/dist/vite/index.d.ts +14 -0
  21. package/dist/vite/index.js +4 -0
  22. package/env.d.ts +59 -0
  23. package/package.json +103 -0
  24. package/skills/apply-comments/SKILL.md +83 -0
  25. package/skills/create-slide/SKILL.md +91 -0
  26. package/skills/create-theme/SKILL.md +250 -0
  27. package/skills/current-slide/SKILL.md +110 -0
  28. package/skills/slide-authoring/SKILL.md +625 -0
  29. package/src/app/app.tsx +47 -0
  30. package/src/app/components/asset-view.tsx +966 -0
  31. package/src/app/components/history-provider.tsx +120 -0
  32. package/src/app/components/image-placeholder.tsx +243 -0
  33. package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
  34. package/src/app/components/inspector/comment-widget.tsx +93 -0
  35. package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
  36. package/src/app/components/inspector/inspect-overlay.tsx +387 -0
  37. package/src/app/components/inspector/inspector-panel.tsx +1115 -0
  38. package/src/app/components/inspector/inspector-provider.tsx +1218 -0
  39. package/src/app/components/inspector/save-bar.tsx +48 -0
  40. package/src/app/components/language-toggle.tsx +39 -0
  41. package/src/app/components/notes-drawer.tsx +120 -0
  42. package/src/app/components/overview-grid.tsx +363 -0
  43. package/src/app/components/panel/panel-fields.tsx +60 -0
  44. package/src/app/components/panel/panel-shell.tsx +80 -0
  45. package/src/app/components/panel/save-card.tsx +142 -0
  46. package/src/app/components/pdf-progress-toast.tsx +32 -0
  47. package/src/app/components/player.tsx +466 -0
  48. package/src/app/components/pptx-progress-toast.tsx +32 -0
  49. package/src/app/components/present/blackout-overlay.tsx +18 -0
  50. package/src/app/components/present/control-bar.tsx +315 -0
  51. package/src/app/components/present/help-overlay.tsx +57 -0
  52. package/src/app/components/present/jump-input.tsx +74 -0
  53. package/src/app/components/present/laser-pointer.tsx +39 -0
  54. package/src/app/components/present/progress-bar.tsx +26 -0
  55. package/src/app/components/present/use-idle.ts +46 -0
  56. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  57. package/src/app/components/present/use-presenter-channel.ts +66 -0
  58. package/src/app/components/present/use-touch-swipe.ts +66 -0
  59. package/src/app/components/shared-element.tsx +48 -0
  60. package/src/app/components/sidebar/folder-item.tsx +258 -0
  61. package/src/app/components/sidebar/icon-picker.tsx +61 -0
  62. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  63. package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
  64. package/src/app/components/sidebar/sidebar.tsx +284 -0
  65. package/src/app/components/slide-canvas.tsx +102 -0
  66. package/src/app/components/slide-transition-layer.tsx +844 -0
  67. package/src/app/components/style-panel/design-provider.tsx +148 -0
  68. package/src/app/components/style-panel/style-panel.tsx +349 -0
  69. package/src/app/components/style-panel/use-design.ts +112 -0
  70. package/src/app/components/theme-toggle.tsx +59 -0
  71. package/src/app/components/themes/theme-detail.tsx +305 -0
  72. package/src/app/components/themes/themes-gallery.tsx +149 -0
  73. package/src/app/components/thumbnail-rail.tsx +805 -0
  74. package/src/app/components/ui/badge.tsx +45 -0
  75. package/src/app/components/ui/button.tsx +99 -0
  76. package/src/app/components/ui/card.tsx +92 -0
  77. package/src/app/components/ui/context-menu.tsx +237 -0
  78. package/src/app/components/ui/dialog.tsx +157 -0
  79. package/src/app/components/ui/dropdown-menu.tsx +245 -0
  80. package/src/app/components/ui/input.tsx +25 -0
  81. package/src/app/components/ui/label.tsx +24 -0
  82. package/src/app/components/ui/popover.tsx +75 -0
  83. package/src/app/components/ui/progress.tsx +31 -0
  84. package/src/app/components/ui/scroll-area.tsx +53 -0
  85. package/src/app/components/ui/select.tsx +196 -0
  86. package/src/app/components/ui/separator.tsx +28 -0
  87. package/src/app/components/ui/slider.tsx +61 -0
  88. package/src/app/components/ui/sonner.tsx +48 -0
  89. package/src/app/components/ui/tabs.tsx +79 -0
  90. package/src/app/components/ui/textarea.tsx +22 -0
  91. package/src/app/components/ui/toggle-group.tsx +83 -0
  92. package/src/app/components/ui/toggle.tsx +45 -0
  93. package/src/app/components/ui/tooltip.tsx +58 -0
  94. package/src/app/favicon.ico +0 -0
  95. package/src/app/index.html +13 -0
  96. package/src/app/lib/assets.ts +242 -0
  97. package/src/app/lib/design-presets.ts +94 -0
  98. package/src/app/lib/design.ts +58 -0
  99. package/src/app/lib/export-html.ts +326 -0
  100. package/src/app/lib/export-pdf.ts +298 -0
  101. package/src/app/lib/export-pptx.ts +284 -0
  102. package/src/app/lib/folders.ts +239 -0
  103. package/src/app/lib/inspector/fiber.test.ts +154 -0
  104. package/src/app/lib/inspector/fiber.ts +85 -0
  105. package/src/app/lib/inspector/use-comments.ts +74 -0
  106. package/src/app/lib/inspector/use-editor.ts +73 -0
  107. package/src/app/lib/inspector/use-notes.ts +134 -0
  108. package/src/app/lib/locale-store.ts +67 -0
  109. package/src/app/lib/page-context.tsx +38 -0
  110. package/src/app/lib/print-ready.test.ts +32 -0
  111. package/src/app/lib/print-ready.ts +51 -0
  112. package/src/app/lib/sdk.test.ts +13 -0
  113. package/src/app/lib/sdk.ts +37 -0
  114. package/src/app/lib/slides.ts +26 -0
  115. package/src/app/lib/step-context.tsx +261 -0
  116. package/src/app/lib/themes.ts +22 -0
  117. package/src/app/lib/transition.ts +30 -0
  118. package/src/app/lib/use-agent-socket.ts +18 -0
  119. package/src/app/lib/use-click-page-navigation.ts +60 -0
  120. package/src/app/lib/use-is-mobile.ts +21 -0
  121. package/src/app/lib/use-locale.ts +8 -0
  122. package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
  123. package/src/app/lib/use-slide-module.ts +48 -0
  124. package/src/app/lib/use-wheel-page-navigation.ts +99 -0
  125. package/src/app/lib/utils.test.ts +25 -0
  126. package/src/app/lib/utils.ts +6 -0
  127. package/src/app/main.tsx +14 -0
  128. package/src/app/routes/assets.tsx +9 -0
  129. package/src/app/routes/home-shell.tsx +213 -0
  130. package/src/app/routes/home.tsx +807 -0
  131. package/src/app/routes/presenter.tsx +418 -0
  132. package/src/app/routes/slide.tsx +1108 -0
  133. package/src/app/routes/themes.tsx +34 -0
  134. package/src/app/styles.css +429 -0
  135. package/src/app/virtual.d.ts +51 -0
  136. package/src/locale/en.ts +416 -0
  137. package/src/locale/format.ts +12 -0
  138. package/src/locale/index.ts +6 -0
  139. package/src/locale/ja.ts +422 -0
  140. package/src/locale/types.ts +443 -0
  141. package/src/locale/zh-cn.ts +414 -0
  142. package/src/locale/zh-tw.ts +414 -0
@@ -0,0 +1,1108 @@
1
+ import config from 'virtual:open-aippt/config';
2
+ import {
3
+ Check,
4
+ ChevronDown,
5
+ ChevronLeft,
6
+ Download,
7
+ FileCode2,
8
+ FileImage,
9
+ FileText,
10
+ Link2,
11
+ Loader2,
12
+ Maximize,
13
+ MonitorSpeaker,
14
+ MoreHorizontal,
15
+ Play,
16
+ Presentation,
17
+ } from 'lucide-react';
18
+ import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
19
+ import { Link, useParams, useSearchParams } from 'react-router-dom';
20
+ import { toast } from 'sonner';
21
+ import { AssetView } from '@/components/asset-view';
22
+ import { HistoryProvider } from '@/components/history-provider';
23
+ import { CommentWidget } from '@/components/inspector/comment-widget';
24
+ import { InspectOverlay } from '@/components/inspector/inspect-overlay';
25
+ import { InspectorPanel } from '@/components/inspector/inspector-panel';
26
+ import {
27
+ InspectorProvider,
28
+ InspectToggleButton,
29
+ useInspector,
30
+ } from '@/components/inspector/inspector-provider';
31
+ import { SaveBar } from '@/components/inspector/save-bar';
32
+ import { DesignProvider } from '@/components/style-panel/design-provider';
33
+ import { DesignPanel, DesignToggleButton } from '@/components/style-panel/style-panel';
34
+ import { Button, buttonVariants } from '@/components/ui/button';
35
+ import {
36
+ DropdownMenu,
37
+ DropdownMenuContent,
38
+ DropdownMenuItem,
39
+ DropdownMenuSeparator,
40
+ DropdownMenuShortcut,
41
+ DropdownMenuTrigger,
42
+ } from '@/components/ui/dropdown-menu';
43
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
44
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
45
+ import { useFolders } from '@/lib/folders';
46
+ import { useAgentSocketConnected } from '@/lib/use-agent-socket';
47
+ import { useClickPageNavigation } from '@/lib/use-click-page-navigation';
48
+ import { useIsMobile } from '@/lib/use-is-mobile';
49
+ import { format, useLocale } from '@/lib/use-locale';
50
+ import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
51
+ import { cn } from '@/lib/utils';
52
+ import { NotesDrawer } from '../components/notes-drawer';
53
+ import { OverviewGrid } from '../components/overview-grid';
54
+ import { PdfProgressToast } from '../components/pdf-progress-toast';
55
+ import { openPresenterWindow, Player } from '../components/player';
56
+ import { PptxProgressToast } from '../components/pptx-progress-toast';
57
+ import { SlideCanvas } from '../components/slide-canvas';
58
+ import { SlideTransitionLayer } from '../components/slide-transition-layer';
59
+ import { type ThumbnailActions, ThumbnailRail } from '../components/thumbnail-rail';
60
+ import { exportSlideAsHtml } from '../lib/export-html';
61
+ import { exportSlideAsPdf, isSafari } from '../lib/export-pdf';
62
+ import { exportSlideAsImagePptx } from '../lib/export-pptx';
63
+ import { remapNotesSessionCacheAfterReorder } from '../lib/inspector/use-notes';
64
+ import type { SlideModule } from '../lib/sdk';
65
+ import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
66
+ import { useSlideModule } from '../lib/use-slide-module';
67
+
68
+ const { showSlideUi, showSlideBrowser, allowHtmlDownload } = config.build;
69
+
70
+ export function Slide() {
71
+ const { slideId = '' } = useParams();
72
+ const [searchParams, setSearchParams] = useSearchParams();
73
+ const { slide, error } = useSlideModule(slideId);
74
+ const [playMode, setPlayMode] = useState<'window' | 'fullscreen' | null>(null);
75
+ const [exporting, setExporting] = useState(false);
76
+ const [linkCopied, setLinkCopied] = useState(false);
77
+ const linkCopiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
78
+ const [designOpen, setDesignOpen] = useState(false);
79
+ const [overviewOpen, setOverviewOpen] = useState(false);
80
+
81
+ useEffect(() => {
82
+ return () => {
83
+ if (linkCopiedTimerRef.current) clearTimeout(linkCopiedTimerRef.current);
84
+ };
85
+ }, []);
86
+ const { renameSlide } = useFolders();
87
+ const slideViewportRef = useRef<HTMLElement>(null);
88
+ const t = useLocale();
89
+ const isMobile = useIsMobile();
90
+ const prefersReducedMotion = usePrefersReducedMotion();
91
+
92
+ const modulePages = useMemo(() => slide?.default ?? [], [slide]);
93
+ const [pages, setPages] = useState<typeof modulePages>(modulePages);
94
+ useEffect(() => {
95
+ setPages(modulePages);
96
+ }, [modulePages]);
97
+ const pageCount = pages.length;
98
+ const rawIndex = Number(searchParams.get('p') ?? '1') - 1;
99
+ const index = Number.isFinite(rawIndex) ? Math.max(0, Math.min(pageCount - 1, rawIndex)) : 0;
100
+ const view = searchParams.get('view') === 'assets' ? 'assets' : 'slides';
101
+
102
+ useEffect(() => {
103
+ if (!import.meta.hot) return;
104
+ if (!slideId || !slide || pageCount === 0) return;
105
+ import.meta.hot.send('open-aippt:current', {
106
+ slideId,
107
+ pageIndex: index,
108
+ totalPages: pageCount,
109
+ slideTitle: slide.meta?.title ?? slideId,
110
+ view,
111
+ });
112
+ }, [slideId, index, pageCount, slide, view]);
113
+
114
+ const goTo = useCallback(
115
+ (i: number) => {
116
+ const clamped = Math.max(0, Math.min(pageCount - 1, i));
117
+ setSearchParams(
118
+ (prev) => {
119
+ const next = new URLSearchParams(prev);
120
+ next.set('p', String(clamped + 1));
121
+ return next;
122
+ },
123
+ { replace: true },
124
+ );
125
+ },
126
+ [pageCount, setSearchParams],
127
+ );
128
+
129
+ const reorderPage = useCallback(
130
+ async (from: number, to: number) => {
131
+ if (from === to) return;
132
+ const before = pages;
133
+ const nextPages = [...before];
134
+ const [moved] = nextPages.splice(from, 1);
135
+ nextPages.splice(to, 0, moved);
136
+ setPages(nextPages);
137
+
138
+ const order = before.map((_, i) => i);
139
+ const [movedIdx] = order.splice(from, 1);
140
+ order.splice(to, 0, movedIdx);
141
+
142
+ remapNotesSessionCacheAfterReorder(slideId, order);
143
+
144
+ // Keep the user looking at the same page they were on before the drag.
145
+ let nextIndex = index;
146
+ if (index === from) nextIndex = to;
147
+ else if (from < index && to >= index) nextIndex = index - 1;
148
+ else if (from > index && to <= index) nextIndex = index + 1;
149
+ if (nextIndex !== index) goTo(nextIndex);
150
+
151
+ try {
152
+ const res = await fetch(`/__slides/${encodeURIComponent(slideId)}/reorder`, {
153
+ method: 'PUT',
154
+ headers: { 'content-type': 'application/json' },
155
+ body: JSON.stringify({ order }),
156
+ });
157
+ if (!res.ok) {
158
+ const detail = await res.json().catch(() => ({ error: res.statusText }));
159
+ throw new Error(detail.error ?? `HTTP ${res.status}`);
160
+ }
161
+ } catch (err) {
162
+ setPages(before);
163
+ const inverse = order.map((_, i) => order.indexOf(i));
164
+ remapNotesSessionCacheAfterReorder(slideId, inverse);
165
+ toast.error(`Reorder failed: ${String((err as Error).message ?? err)}`);
166
+ }
167
+ },
168
+ [pages, index, slideId, goTo],
169
+ );
170
+
171
+ const duplicatePage = useCallback(
172
+ async (i: number) => {
173
+ const before = pages;
174
+ if (i < 0 || i >= before.length) return;
175
+ const nextPages = [...before];
176
+ nextPages.splice(i + 1, 0, before[i]);
177
+ setPages(nextPages);
178
+ if (index > i) goTo(index + 1);
179
+
180
+ try {
181
+ const res = await fetch(`/__slides/${encodeURIComponent(slideId)}/pages/${i}/duplicate`, {
182
+ method: 'POST',
183
+ });
184
+ if (!res.ok) {
185
+ const detail = await res.json().catch(() => ({ error: res.statusText }));
186
+ throw new Error(detail.error ?? `HTTP ${res.status}`);
187
+ }
188
+ toast.success(format(t.thumbnailRail.toastDuplicated, { n: i + 1 }));
189
+ } catch (err) {
190
+ setPages(before);
191
+ toast.error(
192
+ `${t.thumbnailRail.toastDuplicateFailed}: ${String((err as Error).message ?? err)}`,
193
+ );
194
+ }
195
+ },
196
+ [pages, index, slideId, goTo, t.thumbnailRail],
197
+ );
198
+
199
+ const deletePage = useCallback(
200
+ async (i: number) => {
201
+ const before = pages;
202
+ if (i < 0 || i >= before.length || before.length <= 1) return;
203
+ const nextPages = before.slice(0, i).concat(before.slice(i + 1));
204
+ setPages(nextPages);
205
+ if (index >= i && index > 0) {
206
+ const target = index === i ? Math.min(index, nextPages.length - 1) : index - 1;
207
+ goTo(target);
208
+ }
209
+
210
+ try {
211
+ const res = await fetch(`/__slides/${encodeURIComponent(slideId)}/pages/${i}`, {
212
+ method: 'DELETE',
213
+ });
214
+ if (!res.ok) {
215
+ const detail = await res.json().catch(() => ({ error: res.statusText }));
216
+ throw new Error(detail.error ?? `HTTP ${res.status}`);
217
+ }
218
+ toast.success(format(t.thumbnailRail.toastDeleted, { n: i + 1 }));
219
+ } catch (err) {
220
+ setPages(before);
221
+ toast.error(
222
+ `${t.thumbnailRail.toastDeleteFailed}: ${String((err as Error).message ?? err)}`,
223
+ );
224
+ }
225
+ },
226
+ [pages, index, slideId, goTo, t.thumbnailRail],
227
+ );
228
+
229
+ const thumbnailActions = useMemo<ThumbnailActions | undefined>(
230
+ () =>
231
+ import.meta.env.DEV
232
+ ? {
233
+ onDuplicate: duplicatePage,
234
+ onDelete: deletePage,
235
+ }
236
+ : undefined,
237
+ [duplicatePage, deletePage],
238
+ );
239
+
240
+ useEffect(() => {
241
+ if (playMode) return;
242
+ const onKey = (e: KeyboardEvent) => {
243
+ if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
244
+ // Letter shortcuts only fire bare so browser combos (Cmd/Ctrl-P, ⌘F…) stay intact.
245
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
246
+ // Toggle overview from either state — the overview's own capture-phase
247
+ // handler doesn't consume O, so this stays consistent open ↔ closed.
248
+ if (e.key === 'o' || e.key === 'O') {
249
+ e.preventDefault();
250
+ setOverviewOpen((v) => !v);
251
+ return;
252
+ }
253
+ // Once overview owns focus, swallow everything else here — its
254
+ // capture-phase listener drives the focused thumbnail.
255
+ if (overviewOpen) return;
256
+ if (
257
+ e.key === 'ArrowRight' ||
258
+ e.key === 'ArrowDown' ||
259
+ e.key === ' ' ||
260
+ e.key === 'PageDown'
261
+ ) {
262
+ e.preventDefault();
263
+ goTo(index + 1);
264
+ return;
265
+ }
266
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
267
+ e.preventDefault();
268
+ goTo(index - 1);
269
+ return;
270
+ }
271
+ if (e.key === 'f' || e.key === 'F') {
272
+ setPlayMode('fullscreen');
273
+ } else if (e.key === 'Enter') {
274
+ setPlayMode('window');
275
+ } else if (e.key === 'p' || e.key === 'P') {
276
+ if (slideId) openPresenterWindow(slideId);
277
+ setPlayMode('window');
278
+ } else if (import.meta.env.DEV && (e.key === 'd' || e.key === 'D')) {
279
+ setDesignOpen((v) => !v);
280
+ }
281
+ };
282
+ window.addEventListener('keydown', onKey);
283
+ return () => window.removeEventListener('keydown', onKey);
284
+ }, [index, goTo, playMode, slideId, overviewOpen]);
285
+
286
+ if (error) {
287
+ return (
288
+ <div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
289
+ {showSlideBrowser && (
290
+ <Link to="/" className="text-[12px] font-medium text-foreground/70 hover:text-foreground">
291
+ ← {t.common.home}
292
+ </Link>
293
+ )}
294
+ <span className="mt-6 block eyebrow text-destructive/80">{t.common.loadFailed}</span>
295
+ <h2 className="mt-2 font-heading text-xl font-semibold tracking-tight text-foreground">
296
+ {t.common.failedToLoadSlide}
297
+ </h2>
298
+ <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">
299
+ {error}
300
+ </pre>
301
+ </div>
302
+ );
303
+ }
304
+
305
+ if (!slide) {
306
+ return (
307
+ <div className="grid min-h-dvh place-items-center px-8 text-muted-foreground">
308
+ <div className="flex flex-col items-center gap-4">
309
+ <div className="relative h-px w-56 overflow-hidden bg-hairline">
310
+ <span
311
+ aria-hidden
312
+ className="line-loader-bar absolute inset-y-[-0.5px] left-0 w-1/4 bg-foreground"
313
+ />
314
+ </div>
315
+ <div className="flex flex-wrap items-baseline justify-center gap-x-2 text-[11.5px]">
316
+ <span className="eyebrow">{t.slide.loadingEyebrow}</span>
317
+ <span className="font-mono">{slideId}</span>
318
+ </div>
319
+ </div>
320
+ </div>
321
+ );
322
+ }
323
+
324
+ if (pageCount === 0) {
325
+ return (
326
+ <div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
327
+ {showSlideBrowser && (
328
+ <Link to="/" className="text-[12px] font-medium text-foreground/70 hover:text-foreground">
329
+ ← {t.common.home}
330
+ </Link>
331
+ )}
332
+ <span className="mt-6 block eyebrow">{t.slide.emptyEyebrow}</span>
333
+ <h2 className="mt-2 font-heading text-xl font-semibold tracking-tight text-foreground">
334
+ {t.slide.nothingToShow}
335
+ </h2>
336
+ <p className="mt-3 text-[13px] leading-relaxed">
337
+ <code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px]">
338
+ slides/{slideId}/index.tsx
339
+ </code>
340
+ {t.slide.emptyHintMust}
341
+ <code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px]">
342
+ export default
343
+ </code>
344
+ {t.slide.emptyHintSuffix}
345
+ </p>
346
+ </div>
347
+ );
348
+ }
349
+
350
+ if (!showSlideUi) {
351
+ return (
352
+ <Player
353
+ pages={pages}
354
+ design={slide.design}
355
+ index={index}
356
+ onIndexChange={goTo}
357
+ onExit={() => {}}
358
+ allowExit={false}
359
+ />
360
+ );
361
+ }
362
+
363
+ if (playMode) {
364
+ return (
365
+ <Player
366
+ pages={pages}
367
+ design={slide.design}
368
+ transition={slide.transition}
369
+ index={index}
370
+ onIndexChange={goTo}
371
+ onExit={() => setPlayMode(null)}
372
+ controls
373
+ slideId={slideId}
374
+ fullscreen={playMode === 'fullscreen'}
375
+ />
376
+ );
377
+ }
378
+
379
+ const title = slide.meta?.title ?? slideId;
380
+
381
+ const copyLink = async () => {
382
+ try {
383
+ await navigator.clipboard.writeText(window.location.href);
384
+ toast.success(t.slide.toastCopyLinkSuccess);
385
+ setLinkCopied(true);
386
+ if (linkCopiedTimerRef.current) clearTimeout(linkCopiedTimerRef.current);
387
+ linkCopiedTimerRef.current = setTimeout(() => setLinkCopied(false), 1200);
388
+ } catch (err) {
389
+ console.error('[open-aippt] copy link failed', err);
390
+ toast.error(t.slide.toastCopyLinkFailed);
391
+ }
392
+ };
393
+
394
+ const exportHtml = async () => {
395
+ if (!slide || exporting) return;
396
+ setExporting(true);
397
+ try {
398
+ await exportSlideAsHtml(slide, slideId);
399
+ } catch (err) {
400
+ console.error('[open-aippt] export failed', err);
401
+ } finally {
402
+ setExporting(false);
403
+ }
404
+ };
405
+
406
+ const exportPdf = async () => {
407
+ if (!slide || exporting) return;
408
+ if (isSafari()) {
409
+ toast.error(t.slide.pdfExportSafariUnsupported, { duration: 5000 });
410
+ return;
411
+ }
412
+ setExporting(true);
413
+ const toastId = `pdf-export-${slideId}`;
414
+ toast.custom(
415
+ () => (
416
+ <PdfProgressToast
417
+ progress={{ phase: 'processing', current: 0, total: pages.length, percent: 0 }}
418
+ />
419
+ ),
420
+ { id: toastId, duration: Infinity },
421
+ );
422
+ try {
423
+ await exportSlideAsPdf(slide, slideId, (p) => {
424
+ toast.custom(() => <PdfProgressToast progress={p} />, { id: toastId, duration: Infinity });
425
+ });
426
+ } catch (err) {
427
+ console.error('[open-aippt] pdf export failed', err);
428
+ toast.error(t.slide.pdfExportFailed, { id: toastId, duration: 4000 });
429
+ } finally {
430
+ setExporting(false);
431
+ toast.dismiss(toastId);
432
+ }
433
+ };
434
+
435
+ const exportImagePptx = async () => {
436
+ if (!slide || exporting) return;
437
+ setExporting(true);
438
+ const toastId = `pptx-export-${slideId}`;
439
+ toast.custom(
440
+ () => (
441
+ <PptxProgressToast
442
+ progress={{ phase: 'processing', current: 0, total: pages.length, percent: 0 }}
443
+ />
444
+ ),
445
+ { id: toastId, duration: Infinity },
446
+ );
447
+ try {
448
+ await exportSlideAsImagePptx(slide, slideId, (p) => {
449
+ toast.custom(() => <PptxProgressToast progress={p} />, { id: toastId, duration: Infinity });
450
+ });
451
+ } catch (err) {
452
+ console.error('[open-aippt] image pptx export failed', err);
453
+ toast.error(t.slide.imagePptxExportFailed, { id: toastId, duration: 4000 });
454
+ } finally {
455
+ setExporting(false);
456
+ toast.dismiss(toastId);
457
+ }
458
+ };
459
+
460
+ const exportMenuItems = (
461
+ <>
462
+ <DropdownMenuItem disabled={exporting} onSelect={exportHtml}>
463
+ <FileCode2 />
464
+ {t.slide.exportAsHtml}
465
+ </DropdownMenuItem>
466
+ <DropdownMenuItem disabled={exporting} onSelect={exportPdf}>
467
+ <FileText />
468
+ {t.slide.exportAsPdf}
469
+ </DropdownMenuItem>
470
+ <DropdownMenuSeparator />
471
+ <DropdownMenuItem disabled={exporting} onSelect={exportImagePptx}>
472
+ <FileImage />
473
+ {t.slide.exportAsImagePptx}
474
+ </DropdownMenuItem>
475
+ <TooltipProvider delayDuration={200}>
476
+ <Tooltip>
477
+ <TooltipTrigger asChild>
478
+ <div
479
+ aria-disabled
480
+ className="relative flex cursor-help items-center justify-between gap-2 rounded-[5px] px-2 py-1.5 text-[12.5px] opacity-45 select-none [&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:opacity-80"
481
+ >
482
+ <span className="flex items-center gap-2">
483
+ <Presentation />
484
+ {t.slide.exportAsPptx}
485
+ </span>
486
+ <span className="rounded-[3px] bg-muted px-1.5 py-0.5 font-mono text-[9.5px] tracking-[0.04em] text-muted-foreground">
487
+ {t.slide.comingSoon}
488
+ </span>
489
+ </div>
490
+ </TooltipTrigger>
491
+ <TooltipContent
492
+ side="left"
493
+ className="w-max max-w-[min(520px,calc(100vw-2rem))] text-center leading-relaxed"
494
+ >
495
+ {t.slide.pptxComingSoonTooltip}
496
+ </TooltipContent>
497
+ </Tooltip>
498
+ </TooltipProvider>
499
+ </>
500
+ );
501
+
502
+ return (
503
+ <HistoryProvider>
504
+ <InspectorProvider slideId={slideId} pageIndex={index}>
505
+ <SelectionReporter />
506
+ <div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
507
+ {/* Editorial toolbar — three zones, hairline separators, mono-folio center */}
508
+ <header className="relative flex h-12 shrink-0 items-center gap-2 border-b border-hairline bg-sidebar/85 px-2 backdrop-blur-md md:px-3">
509
+ <div className="flex flex-1 items-center gap-1.5 md:flex-none md:gap-2">
510
+ {showSlideBrowser && (
511
+ <Button asChild variant="ghost" size="icon-sm" title={t.slide.home}>
512
+ <Link to="/" aria-label={t.slide.backToHome}>
513
+ <ChevronLeft className="size-4" />
514
+ </Link>
515
+ </Button>
516
+ )}
517
+ <span aria-hidden className="mx-0.5 hidden h-5 w-px bg-hairline md:block" />
518
+ {import.meta.env.DEV && (
519
+ <Tabs
520
+ value={view}
521
+ onValueChange={(next) => {
522
+ setSearchParams(
523
+ (prev) => {
524
+ const params = new URLSearchParams(prev);
525
+ if (next === 'assets') params.set('view', 'assets');
526
+ else params.delete('view');
527
+ return params;
528
+ },
529
+ { replace: true },
530
+ );
531
+ }}
532
+ >
533
+ <TabsList>
534
+ <TabsTrigger value="slides">{t.slide.slidesTab}</TabsTrigger>
535
+ <TabsTrigger value="assets">{t.slide.assetsTab}</TabsTrigger>
536
+ </TabsList>
537
+ </Tabs>
538
+ )}
539
+ {import.meta.env.DEV && <AgentConnectedBadge />}
540
+ </div>
541
+
542
+ {/* On md+ the title centers to the viewport via absolute positioning. On mobile the
543
+ two side groups each flex-1, so the in-flow title lands at the viewport center too —
544
+ and min-w-0 lets it truncate instead of overlapping the icons on narrow widths. */}
545
+ <div className="pointer-events-none relative flex min-w-0 justify-center px-2 md:absolute md:inset-x-0">
546
+ <div className="pointer-events-auto min-w-0 max-w-[34rem]">
547
+ <InlineTitleEditor title={title} onSubmit={(next) => renameSlide(slideId, next)} />
548
+ </div>
549
+ </div>
550
+
551
+ <div className="flex flex-1 items-center justify-end gap-1 md:ml-auto md:flex-none">
552
+ {view === 'slides' && (
553
+ <button
554
+ type="button"
555
+ aria-label={t.slide.copyLink}
556
+ title={t.slide.copyLink}
557
+ className={cn(
558
+ buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
559
+ 'hidden md:inline-flex',
560
+ )}
561
+ onClick={copyLink}
562
+ >
563
+ <span className="relative grid size-4 place-items-center">
564
+ <Link2
565
+ className={cn(
566
+ 'col-start-1 row-start-1 size-4 transition-opacity duration-200',
567
+ linkCopied ? 'opacity-0' : 'opacity-100',
568
+ )}
569
+ />
570
+ <Check
571
+ className={cn(
572
+ 'col-start-1 row-start-1 size-4 transition-opacity duration-200',
573
+ linkCopied ? 'opacity-100' : 'opacity-0',
574
+ )}
575
+ />
576
+ </span>
577
+ </button>
578
+ )}
579
+ {view === 'slides' && allowHtmlDownload && (
580
+ <DropdownMenu>
581
+ <DropdownMenuTrigger
582
+ type="button"
583
+ disabled={exporting}
584
+ aria-label={t.slide.download}
585
+ title={t.slide.download}
586
+ className={cn(
587
+ buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
588
+ 'hidden md:inline-flex',
589
+ )}
590
+ >
591
+ {exporting ? (
592
+ <Loader2 className="size-4 animate-spin" />
593
+ ) : (
594
+ <Download className="size-4" />
595
+ )}
596
+ </DropdownMenuTrigger>
597
+ <DropdownMenuContent align="end" className="min-w-[200px]">
598
+ {exportMenuItems}
599
+ </DropdownMenuContent>
600
+ </DropdownMenu>
601
+ )}
602
+ {view === 'slides' && (
603
+ <DropdownMenu>
604
+ <DropdownMenuTrigger
605
+ type="button"
606
+ disabled={exporting}
607
+ aria-label={t.slide.moreActions}
608
+ title={t.slide.moreActions}
609
+ className={cn(
610
+ buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
611
+ 'inline-flex md:hidden',
612
+ )}
613
+ >
614
+ {exporting ? (
615
+ <Loader2 className="size-4 animate-spin" />
616
+ ) : (
617
+ <MoreHorizontal className="size-4" />
618
+ )}
619
+ </DropdownMenuTrigger>
620
+ <DropdownMenuContent align="end" className="min-w-[200px]">
621
+ <DropdownMenuItem onSelect={copyLink}>
622
+ <Link2 />
623
+ {t.slide.copyLink}
624
+ </DropdownMenuItem>
625
+ {allowHtmlDownload && <DropdownMenuSeparator />}
626
+ {allowHtmlDownload && exportMenuItems}
627
+ </DropdownMenuContent>
628
+ </DropdownMenu>
629
+ )}
630
+ {view === 'slides' && (
631
+ <DesignToggleButton active={designOpen} onToggle={() => setDesignOpen((v) => !v)} />
632
+ )}
633
+ {view === 'slides' && <InspectToggleButton />}
634
+ <span aria-hidden className="mx-0.5 hidden h-5 w-px bg-hairline md:block" />
635
+ {view === 'slides' && (
636
+ <div className="inline-flex items-stretch">
637
+ <Button
638
+ size="sm"
639
+ variant="brand"
640
+ onClick={() => setPlayMode(isMobile ? 'window' : 'fullscreen')}
641
+ className="px-2.5 md:rounded-r-none md:px-3"
642
+ >
643
+ <Play className="size-3.5 fill-current" />
644
+ <span className="hidden md:inline">{t.slide.present}</span>
645
+ <kbd className="ml-1 hidden rounded-[3px] bg-brand-foreground/15 px-1 font-mono text-[9.5px] tracking-[0.04em] md:inline">
646
+ F
647
+ </kbd>
648
+ </Button>
649
+ <DropdownMenu>
650
+ <DropdownMenuTrigger
651
+ type="button"
652
+ aria-label={t.slide.presentMenuAria}
653
+ title={t.slide.presentMenuAria}
654
+ className={cn(
655
+ buttonVariants({ variant: 'brand', size: 'sm' }),
656
+ 'hidden rounded-l-none px-1.5 shadow-[inset_1px_0_0_oklch(0_0_0/0.12),inset_0_1px_0_oklch(1_0_0/0.18),0_1px_0_oklch(0_0_0/0.16)] md:inline-flex',
657
+ )}
658
+ >
659
+ <ChevronDown className="size-3.5" />
660
+ </DropdownMenuTrigger>
661
+ <DropdownMenuContent align="end" className="min-w-[200px]">
662
+ <DropdownMenuItem onSelect={() => setPlayMode('window')}>
663
+ <Play />
664
+ {t.slide.presentInWindow}
665
+ <DropdownMenuShortcut>↵</DropdownMenuShortcut>
666
+ </DropdownMenuItem>
667
+ <DropdownMenuItem onSelect={() => setPlayMode('fullscreen')}>
668
+ <Maximize />
669
+ {t.slide.presentFullscreen}
670
+ <DropdownMenuShortcut>F</DropdownMenuShortcut>
671
+ </DropdownMenuItem>
672
+ <DropdownMenuItem
673
+ onSelect={() => {
674
+ if (slideId) openPresenterWindow(slideId);
675
+ setPlayMode('window');
676
+ }}
677
+ >
678
+ <MonitorSpeaker />
679
+ {t.slide.presentPresenter}
680
+ <DropdownMenuShortcut>P</DropdownMenuShortcut>
681
+ </DropdownMenuItem>
682
+ </DropdownMenuContent>
683
+ </DropdownMenu>
684
+ </div>
685
+ )}
686
+ </div>
687
+ </header>
688
+
689
+ {view === 'assets' ? (
690
+ <div className="min-h-0 flex-1">
691
+ <AssetView slideId={slideId} />
692
+ </div>
693
+ ) : (
694
+ <DesignProvider slideId={slideId}>
695
+ <div className="relative flex min-h-0 flex-1 flex-col">
696
+ <div className="flex min-h-0 flex-1 flex-col md:flex-row">
697
+ <ResizableRail
698
+ pages={pages}
699
+ design={slide.design}
700
+ current={index}
701
+ onSelect={goTo}
702
+ onReorder={import.meta.env.DEV ? reorderPage : undefined}
703
+ actions={thumbnailActions}
704
+ moduleTransition={slide.transition}
705
+ onOverview={() => setOverviewOpen(true)}
706
+ />
707
+ <main
708
+ ref={slideViewportRef}
709
+ data-inspector-root
710
+ data-slide-id={slideId}
711
+ className="paper relative min-h-0 min-w-0 flex-1 bg-canvas p-2 md:p-10"
712
+ >
713
+ <SlideViewportNavigation
714
+ targetRef={slideViewportRef}
715
+ onPrev={() => goTo(index - 1)}
716
+ onNext={() => goTo(index + 1)}
717
+ canPrev={index > 0}
718
+ canNext={index < pageCount - 1}
719
+ />
720
+ <SlideCanvas design={slide.design}>
721
+ <SlideTransitionLayer
722
+ pages={pages}
723
+ index={index}
724
+ total={pageCount}
725
+ moduleTransition={slide.transition}
726
+ disabled={prefersReducedMotion}
727
+ />
728
+ </SlideCanvas>
729
+ <InspectOverlay />
730
+ <SaveBar />
731
+ {import.meta.env.DEV && <CommentWidget />}
732
+ </main>
733
+ {/* Mobile-only horizontal rail. Sits below the canvas and
734
+ pads its bottom for the iOS home indicator / Safari URL bar. */}
735
+ <div
736
+ className="shrink-0 border-t border-hairline md:hidden"
737
+ style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
738
+ >
739
+ <ThumbnailRail
740
+ pages={pages}
741
+ design={slide.design}
742
+ current={index}
743
+ onSelect={goTo}
744
+ orientation="horizontal"
745
+ actions={thumbnailActions}
746
+ />
747
+ </div>
748
+ <InspectorPanel />
749
+ <DesignPanel open={designOpen} onClose={() => setDesignOpen(false)} />
750
+ </div>
751
+ {import.meta.env.DEV && (
752
+ <NotesDrawer
753
+ slideId={slideId}
754
+ index={index}
755
+ total={pageCount}
756
+ initial={slide.notes?.[index]}
757
+ />
758
+ )}
759
+ <OverviewGrid
760
+ pages={pages}
761
+ design={slide.design}
762
+ open={overviewOpen}
763
+ current={index}
764
+ onClose={() => setOverviewOpen(false)}
765
+ onSelect={goTo}
766
+ variant="editor"
767
+ moduleTransition={slide.transition}
768
+ />
769
+ </div>
770
+ </DesignProvider>
771
+ )}
772
+ </div>
773
+ </InspectorProvider>
774
+ </HistoryProvider>
775
+ );
776
+ }
777
+
778
+ const RAIL_WIDTH_STORAGE_KEY = 'open-aippt:thumbnail-rail-width';
779
+ const DEFAULT_RAIL_WIDTH = 264;
780
+ const MIN_RAIL_WIDTH = 200;
781
+ const MAX_RAIL_WIDTH = 480;
782
+
783
+ function readStoredRailWidth(): number {
784
+ if (typeof window === 'undefined') return DEFAULT_RAIL_WIDTH;
785
+ const raw = window.localStorage.getItem(RAIL_WIDTH_STORAGE_KEY);
786
+ const parsed = raw == null ? Number.NaN : Number(raw);
787
+ if (!Number.isFinite(parsed)) return DEFAULT_RAIL_WIDTH;
788
+ return Math.min(MAX_RAIL_WIDTH, Math.max(MIN_RAIL_WIDTH, parsed));
789
+ }
790
+
791
+ function ResizableRail(props: {
792
+ pages: SlideModule['default'];
793
+ design?: SlideModule['design'];
794
+ current: number;
795
+ onSelect: (i: number) => void;
796
+ onReorder?: (from: number, to: number) => void;
797
+ actions?: ThumbnailActions;
798
+ moduleTransition?: SlideModule['transition'];
799
+ onOverview?: () => void;
800
+ }) {
801
+ const t = useLocale();
802
+ const [width, setWidth] = useState<number>(readStoredRailWidth);
803
+ const [resizing, setResizing] = useState(false);
804
+ const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
805
+
806
+ useEffect(() => {
807
+ if (typeof window === 'undefined') return;
808
+ window.localStorage.setItem(RAIL_WIDTH_STORAGE_KEY, String(width));
809
+ }, [width]);
810
+
811
+ useEffect(() => {
812
+ if (!resizing) return;
813
+ const prev = {
814
+ cursor: document.body.style.cursor,
815
+ userSelect: document.body.style.userSelect,
816
+ };
817
+ document.body.style.cursor = 'col-resize';
818
+ document.body.style.userSelect = 'none';
819
+ return () => {
820
+ document.body.style.cursor = prev.cursor;
821
+ document.body.style.userSelect = prev.userSelect;
822
+ };
823
+ }, [resizing]);
824
+
825
+ const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
826
+ e.preventDefault();
827
+ e.currentTarget.setPointerCapture(e.pointerId);
828
+ dragRef.current = { startX: e.clientX, startWidth: width };
829
+ setResizing(true);
830
+ };
831
+
832
+ const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
833
+ if (!dragRef.current) return;
834
+ const delta = e.clientX - dragRef.current.startX;
835
+ const next = Math.min(
836
+ MAX_RAIL_WIDTH,
837
+ Math.max(MIN_RAIL_WIDTH, dragRef.current.startWidth + delta),
838
+ );
839
+ setWidth(next);
840
+ };
841
+
842
+ const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
843
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
844
+ e.currentTarget.releasePointerCapture(e.pointerId);
845
+ }
846
+ dragRef.current = null;
847
+ setResizing(false);
848
+ };
849
+
850
+ const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
851
+ const step = e.shiftKey ? 32 : 8;
852
+ if (e.key === 'ArrowLeft') {
853
+ e.preventDefault();
854
+ e.stopPropagation();
855
+ setWidth((w) => Math.max(MIN_RAIL_WIDTH, w - step));
856
+ } else if (e.key === 'ArrowRight') {
857
+ e.preventDefault();
858
+ e.stopPropagation();
859
+ setWidth((w) => Math.min(MAX_RAIL_WIDTH, w + step));
860
+ } else if (e.key === 'Home') {
861
+ e.preventDefault();
862
+ e.stopPropagation();
863
+ setWidth(DEFAULT_RAIL_WIDTH);
864
+ }
865
+ };
866
+
867
+ return (
868
+ <div className="relative hidden shrink-0 md:block" style={{ width }}>
869
+ <ThumbnailRail width={width} {...props} />
870
+ {/* biome-ignore lint/a11y/useSemanticElements: focusable resize handle (splitter pattern), not a static <hr> */}
871
+ <div
872
+ role="separator"
873
+ aria-orientation="vertical"
874
+ aria-label={t.thumbnailRail.resizeRail}
875
+ aria-valuenow={width}
876
+ aria-valuemin={MIN_RAIL_WIDTH}
877
+ aria-valuemax={MAX_RAIL_WIDTH}
878
+ tabIndex={0}
879
+ onPointerDown={onPointerDown}
880
+ onPointerMove={onPointerMove}
881
+ onPointerUp={onPointerUp}
882
+ onPointerCancel={onPointerUp}
883
+ onKeyDown={onKeyDown}
884
+ onDoubleClick={() => setWidth(DEFAULT_RAIL_WIDTH)}
885
+ className={cn(
886
+ 'group/resize absolute inset-y-0 right-0 z-20 w-1.5 translate-x-1/2 cursor-col-resize touch-none outline-none',
887
+ 'focus-visible:bg-brand/20',
888
+ )}
889
+ >
890
+ <span
891
+ aria-hidden
892
+ className={cn(
893
+ 'pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-brand opacity-0 transition-opacity',
894
+ 'group-hover/resize:opacity-100 group-focus-visible/resize:opacity-100',
895
+ resizing && 'opacity-100',
896
+ )}
897
+ />
898
+ </div>
899
+ </div>
900
+ );
901
+ }
902
+
903
+ function AgentConnectedBadge() {
904
+ const t = useLocale();
905
+ const connected = useAgentSocketConnected();
906
+ return (
907
+ <TooltipProvider delayDuration={200}>
908
+ <Tooltip>
909
+ <TooltipTrigger asChild>
910
+ <button
911
+ type="button"
912
+ className="ml-1 flex shrink-0 cursor-help items-center gap-1.5 rounded-[3px] border border-hairline bg-card px-1.5 py-0.5 text-[10.5px] text-foreground/85 outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
913
+ >
914
+ <span aria-hidden className="relative flex size-1.5 items-center justify-center">
915
+ {connected ? (
916
+ <>
917
+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
918
+ <span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
919
+ </>
920
+ ) : (
921
+ <span className="relative inline-flex size-1.5 rounded-full bg-rose-500" />
922
+ )}
923
+ </span>
924
+ {connected ? t.slide.agentConnected : t.slide.agentDisconnected}
925
+ </button>
926
+ </TooltipTrigger>
927
+ <TooltipContent
928
+ side="bottom"
929
+ align="start"
930
+ className="w-max max-w-[min(520px,calc(100vw-2rem))] text-center leading-relaxed"
931
+ >
932
+ {connected ? t.slide.agentConnectedTooltip : t.slide.agentDisconnectedTooltip}
933
+ </TooltipContent>
934
+ </Tooltip>
935
+ </TooltipProvider>
936
+ );
937
+ }
938
+
939
+ function SelectionReporter() {
940
+ const { selected } = useInspector();
941
+ useEffect(() => {
942
+ if (!import.meta.hot) return;
943
+ const selection = selected
944
+ ? {
945
+ line: selected.line,
946
+ column: selected.column,
947
+ tagName: selected.anchor.tagName.toLowerCase(),
948
+ text: (selected.anchor.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120),
949
+ }
950
+ : null;
951
+ import.meta.hot.send('open-aippt:current', { selection });
952
+ }, [selected]);
953
+ return null;
954
+ }
955
+
956
+ function SlideViewportNavigation({
957
+ targetRef,
958
+ onPrev,
959
+ onNext,
960
+ canPrev,
961
+ canNext,
962
+ }: {
963
+ targetRef: RefObject<HTMLElement>;
964
+ onPrev: () => void;
965
+ onNext: () => void;
966
+ canPrev: boolean;
967
+ canNext: boolean;
968
+ }) {
969
+ const { active } = useInspector();
970
+ const isMobile = useIsMobile();
971
+
972
+ useWheelPageNavigation({
973
+ ref: targetRef,
974
+ enabled: !active,
975
+ canPrev,
976
+ canNext,
977
+ onPrev,
978
+ onNext,
979
+ });
980
+
981
+ // Tap-to-navigate is a touch affordance — desktop has visible prev/next
982
+ // chrome, so it stays edge-only on small screens (matches the old md:hidden
983
+ // zones). Interactive slide content keeps its tap via the hook's passthrough.
984
+ useClickPageNavigation({
985
+ ref: targetRef,
986
+ enabled: isMobile && !active,
987
+ edgeRatio: 0.18,
988
+ canPrev,
989
+ canNext,
990
+ onPrev,
991
+ onNext,
992
+ });
993
+
994
+ return null;
995
+ }
996
+
997
+ function InlineTitleEditor({
998
+ title,
999
+ onSubmit,
1000
+ }: {
1001
+ title: string;
1002
+ onSubmit: (name: string) => Promise<void> | void;
1003
+ }) {
1004
+ const [editing, setEditing] = useState(false);
1005
+ const [value, setValue] = useState(title);
1006
+ const [saving, setSaving] = useState(false);
1007
+ const inputRef = useRef<HTMLInputElement | null>(null);
1008
+ const t = useLocale();
1009
+
1010
+ useEffect(() => {
1011
+ if (!editing) setValue(title);
1012
+ }, [title, editing]);
1013
+
1014
+ useEffect(() => {
1015
+ if (editing) {
1016
+ queueMicrotask(() => {
1017
+ inputRef.current?.focus();
1018
+ inputRef.current?.select();
1019
+ });
1020
+ }
1021
+ }, [editing]);
1022
+
1023
+ const commit = async () => {
1024
+ const trimmed = value.trim();
1025
+ if (!trimmed || trimmed === title) {
1026
+ setValue(title);
1027
+ setEditing(false);
1028
+ return;
1029
+ }
1030
+ setSaving(true);
1031
+ try {
1032
+ await onSubmit(trimmed);
1033
+ setEditing(false);
1034
+ } finally {
1035
+ setSaving(false);
1036
+ }
1037
+ };
1038
+
1039
+ const cancel = () => {
1040
+ setValue(title);
1041
+ setEditing(false);
1042
+ };
1043
+
1044
+ if (editing) {
1045
+ return (
1046
+ <div className="flex min-w-0 flex-1 items-center justify-center">
1047
+ <div className="inline-grid max-w-full items-center">
1048
+ <span
1049
+ aria-hidden
1050
+ className="invisible col-start-1 row-start-1 overflow-hidden whitespace-pre border border-transparent px-2 py-0.5 font-heading text-[13.5px] font-semibold tracking-[-0.01em]"
1051
+ >
1052
+ {value || ' '}
1053
+ </span>
1054
+ <input
1055
+ ref={inputRef}
1056
+ size={1}
1057
+ value={value}
1058
+ disabled={saving}
1059
+ onChange={(e) => setValue(e.target.value)}
1060
+ onBlur={() => {
1061
+ if (!saving) commit();
1062
+ }}
1063
+ onKeyDown={(e) => {
1064
+ if (e.nativeEvent.isComposing) return;
1065
+ if (e.key === 'Enter') {
1066
+ e.preventDefault();
1067
+ commit();
1068
+ } else if (e.key === 'Escape') {
1069
+ e.preventDefault();
1070
+ cancel();
1071
+ }
1072
+ }}
1073
+ maxLength={80}
1074
+ className="col-start-1 row-start-1 w-full min-w-0 rounded-[5px] border border-foreground/30 bg-card px-2 py-0.5 text-center font-heading text-[13.5px] font-semibold tracking-[-0.01em] outline-none"
1075
+ />
1076
+ </div>
1077
+ </div>
1078
+ );
1079
+ }
1080
+
1081
+ if (!import.meta.env.DEV) {
1082
+ return (
1083
+ <div className="flex min-w-0 items-baseline justify-center">
1084
+ <h1 className="truncate font-heading text-[13.5px] font-semibold tracking-[-0.01em]">
1085
+ {title}
1086
+ </h1>
1087
+ </div>
1088
+ );
1089
+ }
1090
+
1091
+ return (
1092
+ <div className="flex min-w-0 items-center justify-center">
1093
+ <button
1094
+ type="button"
1095
+ onClick={() => setEditing(true)}
1096
+ aria-label={t.slide.renameSlide}
1097
+ className={cn(
1098
+ 'min-w-0 max-w-full cursor-text rounded-[5px] border border-transparent px-2 py-0.5 transition-colors',
1099
+ 'hover:border-foreground/30 hover:bg-card focus-visible:border-foreground/30 focus-visible:bg-card focus-visible:outline-none',
1100
+ )}
1101
+ >
1102
+ <h1 className="truncate font-heading text-[13.5px] font-semibold tracking-[-0.01em]">
1103
+ {title}
1104
+ </h1>
1105
+ </button>
1106
+ </div>
1107
+ );
1108
+ }