@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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/bin.js +2 -0
- package/dist/build-DxTqmvsO.js +17 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +86 -0
- package/dist/config-CjzqjrEA.js +4280 -0
- package/dist/config-DIC-yVPp.d.ts +23 -0
- package/dist/design-cpzS8aud.js +35 -0
- package/dist/dev-BYuTeJbA.js +20 -0
- package/dist/format-BCeKbTOM.js +1605 -0
- package/dist/index.d.ts +134 -0
- package/dist/index.js +467 -0
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +3 -0
- package/dist/preview-DlQvnJPq.js +18 -0
- package/dist/sync-BPZ0m27m.js +139 -0
- package/dist/sync-EsYusbbL.js +3 -0
- package/dist/types-CHmFPIG_.d.ts +430 -0
- package/dist/vite/index.d.ts +14 -0
- package/dist/vite/index.js +4 -0
- package/env.d.ts +59 -0
- package/package.json +103 -0
- package/skills/apply-comments/SKILL.md +83 -0
- package/skills/create-slide/SKILL.md +91 -0
- package/skills/create-theme/SKILL.md +250 -0
- package/skills/current-slide/SKILL.md +110 -0
- package/skills/slide-authoring/SKILL.md +625 -0
- package/src/app/app.tsx +47 -0
- package/src/app/components/asset-view.tsx +966 -0
- package/src/app/components/history-provider.tsx +120 -0
- package/src/app/components/image-placeholder.tsx +243 -0
- package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
- package/src/app/components/inspector/comment-widget.tsx +93 -0
- package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
- package/src/app/components/inspector/inspect-overlay.tsx +387 -0
- package/src/app/components/inspector/inspector-panel.tsx +1115 -0
- package/src/app/components/inspector/inspector-provider.tsx +1218 -0
- package/src/app/components/inspector/save-bar.tsx +48 -0
- package/src/app/components/language-toggle.tsx +39 -0
- package/src/app/components/notes-drawer.tsx +120 -0
- package/src/app/components/overview-grid.tsx +363 -0
- package/src/app/components/panel/panel-fields.tsx +60 -0
- package/src/app/components/panel/panel-shell.tsx +80 -0
- package/src/app/components/panel/save-card.tsx +142 -0
- package/src/app/components/pdf-progress-toast.tsx +32 -0
- package/src/app/components/player.tsx +466 -0
- package/src/app/components/pptx-progress-toast.tsx +32 -0
- package/src/app/components/present/blackout-overlay.tsx +18 -0
- package/src/app/components/present/control-bar.tsx +315 -0
- package/src/app/components/present/help-overlay.tsx +57 -0
- package/src/app/components/present/jump-input.tsx +74 -0
- package/src/app/components/present/laser-pointer.tsx +39 -0
- package/src/app/components/present/progress-bar.tsx +26 -0
- package/src/app/components/present/use-idle.ts +46 -0
- package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
- package/src/app/components/present/use-presenter-channel.ts +66 -0
- package/src/app/components/present/use-touch-swipe.ts +66 -0
- package/src/app/components/shared-element.tsx +48 -0
- package/src/app/components/sidebar/folder-item.tsx +258 -0
- package/src/app/components/sidebar/icon-picker.tsx +61 -0
- package/src/app/components/sidebar/mobile-pill.tsx +34 -0
- package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
- package/src/app/components/sidebar/sidebar.tsx +284 -0
- package/src/app/components/slide-canvas.tsx +102 -0
- package/src/app/components/slide-transition-layer.tsx +844 -0
- package/src/app/components/style-panel/design-provider.tsx +148 -0
- package/src/app/components/style-panel/style-panel.tsx +349 -0
- package/src/app/components/style-panel/use-design.ts +112 -0
- package/src/app/components/theme-toggle.tsx +59 -0
- package/src/app/components/themes/theme-detail.tsx +305 -0
- package/src/app/components/themes/themes-gallery.tsx +149 -0
- package/src/app/components/thumbnail-rail.tsx +805 -0
- package/src/app/components/ui/badge.tsx +45 -0
- package/src/app/components/ui/button.tsx +99 -0
- package/src/app/components/ui/card.tsx +92 -0
- package/src/app/components/ui/context-menu.tsx +237 -0
- package/src/app/components/ui/dialog.tsx +157 -0
- package/src/app/components/ui/dropdown-menu.tsx +245 -0
- package/src/app/components/ui/input.tsx +25 -0
- package/src/app/components/ui/label.tsx +24 -0
- package/src/app/components/ui/popover.tsx +75 -0
- package/src/app/components/ui/progress.tsx +31 -0
- package/src/app/components/ui/scroll-area.tsx +53 -0
- package/src/app/components/ui/select.tsx +196 -0
- package/src/app/components/ui/separator.tsx +28 -0
- package/src/app/components/ui/slider.tsx +61 -0
- package/src/app/components/ui/sonner.tsx +48 -0
- package/src/app/components/ui/tabs.tsx +79 -0
- package/src/app/components/ui/textarea.tsx +22 -0
- package/src/app/components/ui/toggle-group.tsx +83 -0
- package/src/app/components/ui/toggle.tsx +45 -0
- package/src/app/components/ui/tooltip.tsx +58 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/index.html +13 -0
- package/src/app/lib/assets.ts +242 -0
- package/src/app/lib/design-presets.ts +94 -0
- package/src/app/lib/design.ts +58 -0
- package/src/app/lib/export-html.ts +326 -0
- package/src/app/lib/export-pdf.ts +298 -0
- package/src/app/lib/export-pptx.ts +284 -0
- package/src/app/lib/folders.ts +239 -0
- package/src/app/lib/inspector/fiber.test.ts +154 -0
- package/src/app/lib/inspector/fiber.ts +85 -0
- package/src/app/lib/inspector/use-comments.ts +74 -0
- package/src/app/lib/inspector/use-editor.ts +73 -0
- package/src/app/lib/inspector/use-notes.ts +134 -0
- package/src/app/lib/locale-store.ts +67 -0
- package/src/app/lib/page-context.tsx +38 -0
- package/src/app/lib/print-ready.test.ts +32 -0
- package/src/app/lib/print-ready.ts +51 -0
- package/src/app/lib/sdk.test.ts +13 -0
- package/src/app/lib/sdk.ts +37 -0
- package/src/app/lib/slides.ts +26 -0
- package/src/app/lib/step-context.tsx +261 -0
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/transition.ts +30 -0
- package/src/app/lib/use-agent-socket.ts +18 -0
- package/src/app/lib/use-click-page-navigation.ts +60 -0
- package/src/app/lib/use-is-mobile.ts +21 -0
- package/src/app/lib/use-locale.ts +8 -0
- package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
- package/src/app/lib/use-slide-module.ts +48 -0
- package/src/app/lib/use-wheel-page-navigation.ts +99 -0
- package/src/app/lib/utils.test.ts +25 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +213 -0
- package/src/app/routes/home.tsx +807 -0
- package/src/app/routes/presenter.tsx +418 -0
- package/src/app/routes/slide.tsx +1108 -0
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/styles.css +429 -0
- package/src/app/virtual.d.ts +51 -0
- package/src/locale/en.ts +416 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +422 -0
- package/src/locale/types.ts +443 -0
- package/src/locale/zh-cn.ts +414 -0
- 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
|
+
}
|