promptslide 0.3.16 → 0.3.17
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/CHANGELOG.md +7 -0
- package/README.md +16 -12
- package/dist/index.d.ts +17 -7
- package/dist/index.js +987 -211
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/clone.mjs +54 -21
- package/src/commands/pull.mjs +57 -27
- package/src/commands/studio.mjs +3 -0
- package/src/commands/to-image.mjs +18 -5
- package/src/commands/to-pdf.mjs +27 -0
- package/src/core/animated.tsx +3 -3
- package/src/core/annotations/adapters/http.ts +8 -0
- package/src/core/annotations/annotation-overlay.tsx +77 -32
- package/src/core/annotations/annotation-panel.tsx +184 -51
- package/src/core/annotations/types.ts +5 -0
- package/src/core/annotations/use-annotations.ts +57 -7
- package/src/core/slide-deck.tsx +657 -115
- package/src/core/transitions.ts +5 -3
- package/src/core/types.ts +2 -0
- package/src/core/use-slide-navigation.ts +93 -3
- package/src/index.mjs +6 -0
- package/src/utils/deck-config.mjs +30 -11
- package/src/utils/export.mjs +151 -52
- package/src/utils/studio-discovery.mjs +37 -0
- package/src/vite/plugin.mjs +146 -16
- package/templates/default/AGENTS.md +3 -2
- package/templates/default/src/deck-config.ts +2 -2
package/src/core/slide-deck.tsx
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import { LayoutGroup } from "framer-motion"
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
ChevronLeft,
|
|
4
|
+
ChevronRight,
|
|
5
|
+
Download,
|
|
6
|
+
Grid3X3,
|
|
7
|
+
List,
|
|
8
|
+
MessageCircle,
|
|
9
|
+
Monitor,
|
|
10
|
+
PanelLeftClose,
|
|
11
|
+
PanelLeftOpen,
|
|
12
|
+
Play
|
|
13
|
+
} from "lucide-react"
|
|
14
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
|
|
4
15
|
|
|
16
|
+
import type { Annotation, AnnotationTarget } from "./annotations"
|
|
5
17
|
import type { SlideTransitionType } from "./transitions"
|
|
6
18
|
import type { SlideConfig } from "./types"
|
|
7
19
|
|
|
8
20
|
import { SLIDE_DIMENSIONS } from "./animation-config"
|
|
9
21
|
import { AnimationProvider } from "./animation-context"
|
|
10
22
|
import { AnnotationOverlay, AnnotationPanel, useAnnotations } from "./annotations"
|
|
11
|
-
import type { Annotation, AnnotationTarget } from "./annotations"
|
|
12
23
|
import { SlideErrorBoundary } from "./slide-error-boundary"
|
|
13
24
|
import { SlideRenderer } from "./slide-renderer"
|
|
14
25
|
import { useSlideNavigation } from "./use-slide-navigation"
|
|
@@ -19,6 +30,92 @@ import { cn } from "./utils"
|
|
|
19
30
|
// =============================================================================
|
|
20
31
|
|
|
21
32
|
type ViewMode = "slide" | "list" | "grid"
|
|
33
|
+
const NARROW_VIEWPORT_WIDTH = 768
|
|
34
|
+
const DEFAULT_THUMBNAIL_WIDTH = 240
|
|
35
|
+
const MIN_THUMBNAIL_WIDTH = 176
|
|
36
|
+
const MAX_THUMBNAIL_WIDTH = 480
|
|
37
|
+
const THUMBNAIL_WIDTH_STORAGE_KEY = "promptslide:thumbnail-width"
|
|
38
|
+
|
|
39
|
+
function GridThumbnail({
|
|
40
|
+
slide,
|
|
41
|
+
index,
|
|
42
|
+
total
|
|
43
|
+
}: {
|
|
44
|
+
slide: SlideConfig
|
|
45
|
+
index: number
|
|
46
|
+
total: number
|
|
47
|
+
}) {
|
|
48
|
+
const viewportRef = useRef<HTMLDivElement>(null)
|
|
49
|
+
const canvasRef = useRef<HTMLDivElement>(null)
|
|
50
|
+
const SlideComponent = slide.component
|
|
51
|
+
|
|
52
|
+
useLayoutEffect(() => {
|
|
53
|
+
const viewport = viewportRef.current
|
|
54
|
+
const canvas = canvasRef.current
|
|
55
|
+
if (!viewport || !canvas) return
|
|
56
|
+
const resize = () => {
|
|
57
|
+
canvas.style.transform = `scale(${viewport.clientWidth / SLIDE_DIMENSIONS.width})`
|
|
58
|
+
}
|
|
59
|
+
resize()
|
|
60
|
+
const observer = new ResizeObserver(resize)
|
|
61
|
+
observer.observe(viewport)
|
|
62
|
+
return () => observer.disconnect()
|
|
63
|
+
}, [])
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div ref={viewportRef} className="absolute inset-0 overflow-hidden">
|
|
67
|
+
<div
|
|
68
|
+
ref={canvasRef}
|
|
69
|
+
className="absolute top-0 left-0 origin-top-left"
|
|
70
|
+
style={{ width: SLIDE_DIMENSIONS.width, height: SLIDE_DIMENSIONS.height }}
|
|
71
|
+
>
|
|
72
|
+
<AnimationProvider currentStep={slide.steps} totalSteps={slide.steps} showAllAnimations>
|
|
73
|
+
<SlideErrorBoundary slideIndex={index} slideTitle={slide.title}>
|
|
74
|
+
<SlideComponent slideNumber={index + 1} totalSlides={total} />
|
|
75
|
+
</SlideErrorBoundary>
|
|
76
|
+
</AnimationProvider>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ListSlide({ slide, index, total }: { slide: SlideConfig; index: number; total: number }) {
|
|
83
|
+
const viewportRef = useRef<HTMLDivElement>(null)
|
|
84
|
+
const canvasRef = useRef<HTMLDivElement>(null)
|
|
85
|
+
const SlideComponent = slide.component
|
|
86
|
+
|
|
87
|
+
useLayoutEffect(() => {
|
|
88
|
+
const viewport = viewportRef.current
|
|
89
|
+
const canvas = canvasRef.current
|
|
90
|
+
if (!viewport || !canvas) return
|
|
91
|
+
const resize = () => {
|
|
92
|
+
canvas.style.transform = `scale(${viewport.clientWidth / SLIDE_DIMENSIONS.width})`
|
|
93
|
+
}
|
|
94
|
+
resize()
|
|
95
|
+
const observer = new ResizeObserver(resize)
|
|
96
|
+
observer.observe(viewport)
|
|
97
|
+
return () => observer.disconnect()
|
|
98
|
+
}, [])
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
ref={viewportRef}
|
|
103
|
+
className="relative aspect-video w-full overflow-hidden rounded-xl border border-neutral-800 bg-black shadow-sm print:m-0 print:h-[1080px] print:w-[1920px] print:break-after-page print:rounded-none print:border-0 print:shadow-none"
|
|
104
|
+
>
|
|
105
|
+
<div
|
|
106
|
+
ref={canvasRef}
|
|
107
|
+
className="list-slide-canvas absolute top-0 left-0 origin-top-left"
|
|
108
|
+
style={{ width: SLIDE_DIMENSIONS.width, height: SLIDE_DIMENSIONS.height }}
|
|
109
|
+
>
|
|
110
|
+
<AnimationProvider currentStep={slide.steps} totalSteps={slide.steps} showAllAnimations>
|
|
111
|
+
<SlideErrorBoundary slideIndex={index} slideTitle={slide.title}>
|
|
112
|
+
<SlideComponent slideNumber={index + 1} totalSlides={total} />
|
|
113
|
+
</SlideErrorBoundary>
|
|
114
|
+
</AnimationProvider>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
22
119
|
|
|
23
120
|
interface SlideDeckProps {
|
|
24
121
|
slides: SlideConfig[]
|
|
@@ -27,9 +124,16 @@ interface SlideDeckProps {
|
|
|
27
124
|
/** Annotation data to display. When provided (even empty array), annotation UI is enabled. When undefined, annotation UI is hidden. */
|
|
28
125
|
annotations?: Annotation[]
|
|
29
126
|
/** Called when the user creates an annotation */
|
|
30
|
-
onAnnotationAdd?: (
|
|
127
|
+
onAnnotationAdd?: (
|
|
128
|
+
slideIndex: number,
|
|
129
|
+
slideTitle: string,
|
|
130
|
+
target: AnnotationTarget,
|
|
131
|
+
body: string,
|
|
132
|
+
slideId?: string
|
|
133
|
+
) => void
|
|
31
134
|
/** Called when the user deletes an annotation */
|
|
32
135
|
onAnnotationDelete?: (id: string) => void
|
|
136
|
+
onAnnotationUpdate?: (id: string, patch: Partial<Pick<Annotation, "body" | "status">>) => void
|
|
33
137
|
}
|
|
34
138
|
|
|
35
139
|
// =============================================================================
|
|
@@ -43,7 +147,20 @@ function SlideExportView({ slides, slideIndex }: { slides: SlideConfig[]; slideI
|
|
|
43
147
|
const SlideComponent = slideConfig.component
|
|
44
148
|
|
|
45
149
|
useEffect(() => {
|
|
46
|
-
|
|
150
|
+
let cancelled = false
|
|
151
|
+
const markReady = async () => {
|
|
152
|
+
await document.fonts.ready
|
|
153
|
+
await Promise.all(Array.from(document.images, image => image.decode().catch(() => {})))
|
|
154
|
+
requestAnimationFrame(() =>
|
|
155
|
+
requestAnimationFrame(() => {
|
|
156
|
+
if (!cancelled) setReady(true)
|
|
157
|
+
})
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
void markReady()
|
|
161
|
+
return () => {
|
|
162
|
+
cancelled = true
|
|
163
|
+
}
|
|
47
164
|
}, [])
|
|
48
165
|
|
|
49
166
|
return (
|
|
@@ -74,7 +191,15 @@ function SlideExportView({ slides, slideIndex }: { slides: SlideConfig[]; slideI
|
|
|
74
191
|
// COMPONENT
|
|
75
192
|
// =============================================================================
|
|
76
193
|
|
|
77
|
-
export function SlideDeck({
|
|
194
|
+
export function SlideDeck({
|
|
195
|
+
slides,
|
|
196
|
+
transition,
|
|
197
|
+
directionalTransition,
|
|
198
|
+
annotations,
|
|
199
|
+
onAnnotationAdd,
|
|
200
|
+
onAnnotationDelete,
|
|
201
|
+
onAnnotationUpdate
|
|
202
|
+
}: SlideDeckProps) {
|
|
78
203
|
// Check for export mode via URL params
|
|
79
204
|
const [exportParams] = useState(() => {
|
|
80
205
|
if (typeof window === "undefined") return null
|
|
@@ -88,26 +213,183 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
88
213
|
}
|
|
89
214
|
|
|
90
215
|
// Use internal useAnnotations as fallback when no external annotations prop is provided
|
|
91
|
-
const internal = useAnnotations()
|
|
216
|
+
const internal = useAnnotations(slides)
|
|
92
217
|
const isExternallyManaged = annotations !== undefined
|
|
93
218
|
const effectiveAnnotations = isExternallyManaged ? annotations : internal.annotations
|
|
94
219
|
const effectiveAdd = isExternallyManaged ? onAnnotationAdd : internal.addAnnotation
|
|
95
220
|
const effectiveDelete = isExternallyManaged ? onAnnotationDelete : internal.deleteAnnotation
|
|
221
|
+
const effectiveUpdate = isExternallyManaged ? onAnnotationUpdate : internal.updateAnnotation
|
|
96
222
|
|
|
97
|
-
const openCount = useMemo(
|
|
98
|
-
|
|
99
|
-
(slideIndex: number) => effectiveAnnotations.filter(a => a.slideIndex === slideIndex),
|
|
223
|
+
const openCount = useMemo(
|
|
224
|
+
() => effectiveAnnotations.filter(a => a.status === "open").length,
|
|
100
225
|
[effectiveAnnotations]
|
|
101
226
|
)
|
|
227
|
+
const getSlideAnnotations = useCallback(
|
|
228
|
+
(slideIndex: number) =>
|
|
229
|
+
effectiveAnnotations.filter(a =>
|
|
230
|
+
a.slideId ? a.slideId === slides[slideIndex]?.id : a.slideIndex === slideIndex
|
|
231
|
+
),
|
|
232
|
+
[effectiveAnnotations, slides]
|
|
233
|
+
)
|
|
234
|
+
const unlinkedAnnotations = useMemo(
|
|
235
|
+
() =>
|
|
236
|
+
effectiveAnnotations.filter(a => a.slideId && !slides.some(slide => slide.id === a.slideId)),
|
|
237
|
+
[effectiveAnnotations, slides]
|
|
238
|
+
)
|
|
102
239
|
|
|
103
240
|
const [viewMode, setViewMode] = useState<ViewMode>("slide")
|
|
104
241
|
const [isPresentationMode, setIsPresentationMode] = useState(false)
|
|
242
|
+
const [viewportWidth, setViewportWidth] = useState(() =>
|
|
243
|
+
typeof window === "undefined" ? 1280 : window.innerWidth
|
|
244
|
+
)
|
|
245
|
+
const [showThumbnailRail, setShowThumbnailRail] = useState(() =>
|
|
246
|
+
typeof window === "undefined" ? true : window.innerWidth >= NARROW_VIEWPORT_WIDTH
|
|
247
|
+
)
|
|
248
|
+
const [thumbnailWidth, setThumbnailWidth] = useState(() => {
|
|
249
|
+
if (typeof window === "undefined") return DEFAULT_THUMBNAIL_WIDTH
|
|
250
|
+
const savedWidth = Number(window.localStorage.getItem(THUMBNAIL_WIDTH_STORAGE_KEY))
|
|
251
|
+
return Number.isFinite(savedWidth) && savedWidth >= MIN_THUMBNAIL_WIDTH
|
|
252
|
+
? Math.min(savedWidth, MAX_THUMBNAIL_WIDTH)
|
|
253
|
+
: DEFAULT_THUMBNAIL_WIDTH
|
|
254
|
+
})
|
|
105
255
|
const [isAnnotationMode, setIsAnnotationMode] = useState(false)
|
|
106
256
|
const [showAnnotationPanel, setShowAnnotationPanel] = useState(false)
|
|
107
257
|
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null)
|
|
258
|
+
const [hoveredAnnotationId, setHoveredAnnotationId] = useState<string | null>(null)
|
|
259
|
+
const [isResizingThumbnails, setIsResizingThumbnails] = useState(false)
|
|
260
|
+
const [isDragCollapsed, setIsDragCollapsed] = useState(false)
|
|
261
|
+
const [isAnimatingDragBoundary, setIsAnimatingDragBoundary] = useState(false)
|
|
108
262
|
const [scale, setScale] = useState(1)
|
|
109
263
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
110
264
|
const slideContainerRef = useRef<HTMLDivElement>(null)
|
|
265
|
+
const previewViewportRef = useRef<HTMLDivElement>(null)
|
|
266
|
+
const activeThumbnailRef = useRef<HTMLButtonElement>(null)
|
|
267
|
+
const preferredRailOpenRef = useRef(true)
|
|
268
|
+
const wasNarrowViewportRef = useRef(viewportWidth < NARROW_VIEWPORT_WIDTH)
|
|
269
|
+
const resizeStartRef = useRef<{
|
|
270
|
+
pointerId: number
|
|
271
|
+
pointerX: number
|
|
272
|
+
width: number
|
|
273
|
+
} | null>(null)
|
|
274
|
+
const previousUserSelectRef = useRef("")
|
|
275
|
+
const previousCursorRef = useRef("")
|
|
276
|
+
const dragCollapsedRef = useRef(false)
|
|
277
|
+
const dragAnimationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
278
|
+
const isNarrowViewport = viewportWidth < NARROW_VIEWPORT_WIDTH
|
|
279
|
+
const maxThumbnailWidth = isNarrowViewport
|
|
280
|
+
? Math.max(120, Math.floor(viewportWidth * 0.8))
|
|
281
|
+
: Math.min(MAX_THUMBNAIL_WIDTH, viewportWidth - 360)
|
|
282
|
+
const minThumbnailWidth = Math.min(MIN_THUMBNAIL_WIDTH, maxThumbnailWidth)
|
|
283
|
+
const visibleThumbnailWidth = Math.min(thumbnailWidth, maxThumbnailWidth)
|
|
284
|
+
const isThumbnailRailVisible = showThumbnailRail && !isDragCollapsed
|
|
285
|
+
|
|
286
|
+
const setThumbnailRailOpen = useCallback((open: boolean) => {
|
|
287
|
+
preferredRailOpenRef.current = open
|
|
288
|
+
setShowThumbnailRail(open)
|
|
289
|
+
}, [])
|
|
290
|
+
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
const handleResize = () => setViewportWidth(window.innerWidth)
|
|
293
|
+
window.addEventListener("resize", handleResize)
|
|
294
|
+
return () => window.removeEventListener("resize", handleResize)
|
|
295
|
+
}, [])
|
|
296
|
+
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
if (isNarrowViewport !== wasNarrowViewportRef.current) {
|
|
299
|
+
setShowThumbnailRail(isNarrowViewport ? false : preferredRailOpenRef.current)
|
|
300
|
+
if (isNarrowViewport) {
|
|
301
|
+
setIsAnnotationMode(false)
|
|
302
|
+
setShowAnnotationPanel(false)
|
|
303
|
+
setSelectedAnnotationId(null)
|
|
304
|
+
}
|
|
305
|
+
wasNarrowViewportRef.current = isNarrowViewport
|
|
306
|
+
}
|
|
307
|
+
}, [isNarrowViewport])
|
|
308
|
+
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
window.localStorage.setItem(THUMBNAIL_WIDTH_STORAGE_KEY, String(thumbnailWidth))
|
|
311
|
+
}, [thumbnailWidth])
|
|
312
|
+
|
|
313
|
+
useEffect(
|
|
314
|
+
() => () => {
|
|
315
|
+
if (resizeStartRef.current) {
|
|
316
|
+
document.body.style.userSelect = previousUserSelectRef.current
|
|
317
|
+
document.body.style.cursor = previousCursorRef.current
|
|
318
|
+
}
|
|
319
|
+
if (dragAnimationTimerRef.current) clearTimeout(dragAnimationTimerRef.current)
|
|
320
|
+
},
|
|
321
|
+
[]
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
if (
|
|
326
|
+
(!showThumbnailRail || isPresentationMode || viewMode !== "slide") &&
|
|
327
|
+
resizeStartRef.current
|
|
328
|
+
) {
|
|
329
|
+
resizeStartRef.current = null
|
|
330
|
+
dragCollapsedRef.current = false
|
|
331
|
+
setIsResizingThumbnails(false)
|
|
332
|
+
setIsDragCollapsed(false)
|
|
333
|
+
setIsAnimatingDragBoundary(false)
|
|
334
|
+
if (dragAnimationTimerRef.current) clearTimeout(dragAnimationTimerRef.current)
|
|
335
|
+
document.body.style.userSelect = previousUserSelectRef.current
|
|
336
|
+
document.body.style.cursor = previousCursorRef.current
|
|
337
|
+
}
|
|
338
|
+
}, [showThumbnailRail, isPresentationMode, viewMode])
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
if (!isResizingThumbnails) return
|
|
342
|
+
|
|
343
|
+
const getDraggedWidth = (clientX: number) => {
|
|
344
|
+
const start = resizeStartRef.current
|
|
345
|
+
return start ? start.width + clientX - start.pointerX : null
|
|
346
|
+
}
|
|
347
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
348
|
+
if (event.pointerId !== resizeStartRef.current?.pointerId) return
|
|
349
|
+
const nextWidth = getDraggedWidth(event.clientX)
|
|
350
|
+
if (nextWidth === null) return
|
|
351
|
+
const collapsed = nextWidth < minThumbnailWidth
|
|
352
|
+
if (collapsed !== dragCollapsedRef.current) {
|
|
353
|
+
dragCollapsedRef.current = collapsed
|
|
354
|
+
setIsDragCollapsed(collapsed)
|
|
355
|
+
setIsAnimatingDragBoundary(true)
|
|
356
|
+
if (dragAnimationTimerRef.current) clearTimeout(dragAnimationTimerRef.current)
|
|
357
|
+
dragAnimationTimerRef.current = setTimeout(() => {
|
|
358
|
+
setIsAnimatingDragBoundary(false)
|
|
359
|
+
dragAnimationTimerRef.current = null
|
|
360
|
+
}, 200)
|
|
361
|
+
}
|
|
362
|
+
if (!collapsed) setThumbnailWidth(Math.min(maxThumbnailWidth, nextWidth))
|
|
363
|
+
}
|
|
364
|
+
const stopResize = (event?: PointerEvent) => {
|
|
365
|
+
if (event && event.pointerId !== resizeStartRef.current?.pointerId) return
|
|
366
|
+
const nextWidth = event ? getDraggedWidth(event.clientX) : null
|
|
367
|
+
const collapsed =
|
|
368
|
+
event?.type === "pointerup" && nextWidth !== null && nextWidth < minThumbnailWidth
|
|
369
|
+
resizeStartRef.current = null
|
|
370
|
+
dragCollapsedRef.current = false
|
|
371
|
+
document.body.style.userSelect = previousUserSelectRef.current
|
|
372
|
+
document.body.style.cursor = previousCursorRef.current
|
|
373
|
+
setIsResizingThumbnails(false)
|
|
374
|
+
setIsDragCollapsed(false)
|
|
375
|
+
if (collapsed) setThumbnailRailOpen(false)
|
|
376
|
+
else if (nextWidth !== null) {
|
|
377
|
+
setThumbnailWidth(Math.max(minThumbnailWidth, Math.min(maxThumbnailWidth, nextWidth)))
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const handleBlur = () => stopResize()
|
|
381
|
+
|
|
382
|
+
window.addEventListener("pointermove", handlePointerMove)
|
|
383
|
+
window.addEventListener("pointerup", stopResize)
|
|
384
|
+
window.addEventListener("pointercancel", stopResize)
|
|
385
|
+
window.addEventListener("blur", handleBlur)
|
|
386
|
+
return () => {
|
|
387
|
+
window.removeEventListener("pointermove", handlePointerMove)
|
|
388
|
+
window.removeEventListener("pointerup", stopResize)
|
|
389
|
+
window.removeEventListener("pointercancel", stopResize)
|
|
390
|
+
window.removeEventListener("blur", handleBlur)
|
|
391
|
+
}
|
|
392
|
+
}, [isResizingThumbnails, minThumbnailWidth, maxThumbnailWidth, setThumbnailRailOpen])
|
|
111
393
|
|
|
112
394
|
const {
|
|
113
395
|
currentSlide,
|
|
@@ -140,6 +422,12 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
140
422
|
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange)
|
|
141
423
|
}, [])
|
|
142
424
|
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
if (viewMode === "slide" && !isPresentationMode && showThumbnailRail) {
|
|
427
|
+
activeThumbnailRef.current?.scrollIntoView({ block: "nearest" })
|
|
428
|
+
}
|
|
429
|
+
}, [currentSlide, isPresentationMode, showThumbnailRail, viewMode])
|
|
430
|
+
|
|
143
431
|
// Calculate scale factor for presentation mode
|
|
144
432
|
useEffect(() => {
|
|
145
433
|
const calculateScale = () => {
|
|
@@ -147,9 +435,9 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
147
435
|
setScale(1)
|
|
148
436
|
return
|
|
149
437
|
}
|
|
150
|
-
const
|
|
438
|
+
const availableWidth = window.innerWidth
|
|
151
439
|
const viewportHeight = window.innerHeight
|
|
152
|
-
const scaleX =
|
|
440
|
+
const scaleX = availableWidth / SLIDE_DIMENSIONS.width
|
|
153
441
|
const scaleY = viewportHeight / SLIDE_DIMENSIONS.height
|
|
154
442
|
setScale(Math.min(scaleX, scaleY))
|
|
155
443
|
}
|
|
@@ -159,7 +447,50 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
159
447
|
return () => window.removeEventListener("resize", calculateScale)
|
|
160
448
|
}, [isPresentationMode])
|
|
161
449
|
|
|
162
|
-
|
|
450
|
+
// Keep slide layout at its design size; only scale the rendered canvas.
|
|
451
|
+
useLayoutEffect(() => {
|
|
452
|
+
const viewport = previewViewportRef.current
|
|
453
|
+
const canvas = slideContainerRef.current
|
|
454
|
+
if (!viewport || !canvas) return
|
|
455
|
+
const resize = (width: number, height: number) => {
|
|
456
|
+
const fitScale = Math.min(width / SLIDE_DIMENSIONS.width, height / SLIDE_DIMENSIONS.height)
|
|
457
|
+
canvas.style.transform = `scale(${fitScale})`
|
|
458
|
+
}
|
|
459
|
+
resize(viewport.clientWidth, viewport.clientHeight)
|
|
460
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
461
|
+
if (!entry) return
|
|
462
|
+
resize(entry.contentRect.width, entry.contentRect.height)
|
|
463
|
+
})
|
|
464
|
+
observer.observe(viewport)
|
|
465
|
+
return () => observer.disconnect()
|
|
466
|
+
}, [viewMode, isPresentationMode])
|
|
467
|
+
|
|
468
|
+
const toggleCommentMode = useCallback(() => {
|
|
469
|
+
const next = !isAnnotationMode
|
|
470
|
+
setIsAnnotationMode(next)
|
|
471
|
+
setShowAnnotationPanel(next)
|
|
472
|
+
if (!next) {
|
|
473
|
+
setSelectedAnnotationId(null)
|
|
474
|
+
setHoveredAnnotationId(null)
|
|
475
|
+
}
|
|
476
|
+
}, [isAnnotationMode])
|
|
477
|
+
|
|
478
|
+
const handleExportPdf = useCallback(async () => {
|
|
479
|
+
try {
|
|
480
|
+
const response = await fetch("/__promptslide_pdf")
|
|
481
|
+
if (response.ok && response.headers.get("content-type")?.includes("application/pdf")) {
|
|
482
|
+
const blob = await response.blob()
|
|
483
|
+
const url = URL.createObjectURL(blob)
|
|
484
|
+
const link = document.createElement("a")
|
|
485
|
+
link.href = url
|
|
486
|
+
link.download = "slides.pdf"
|
|
487
|
+
link.click()
|
|
488
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
} catch {
|
|
492
|
+
/* Static builds use the browser print fallback. */
|
|
493
|
+
}
|
|
163
494
|
const previousMode = viewMode
|
|
164
495
|
setViewMode("list")
|
|
165
496
|
|
|
@@ -171,22 +502,59 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
171
502
|
window.addEventListener("afterprint", handleAfterPrint)
|
|
172
503
|
window.print()
|
|
173
504
|
}, 100)
|
|
174
|
-
}
|
|
505
|
+
}, [viewMode])
|
|
175
506
|
|
|
176
507
|
// Keyboard navigation
|
|
177
508
|
useEffect(() => {
|
|
178
509
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
179
|
-
|
|
180
|
-
|
|
510
|
+
const target = e.target as HTMLElement | null
|
|
511
|
+
if (target?.closest("input, textarea, [contenteditable='true']")) return
|
|
512
|
+
if (e.altKey || e.ctrlKey || e.metaKey) return
|
|
513
|
+
const key = e.key.toLowerCase()
|
|
514
|
+
if (e.repeat && "fvglcdt".includes(key)) return
|
|
515
|
+
|
|
516
|
+
if (key === "f") {
|
|
517
|
+
e.preventDefault()
|
|
518
|
+
void togglePresentationMode()
|
|
181
519
|
return
|
|
182
520
|
}
|
|
183
521
|
|
|
184
|
-
|
|
185
|
-
|
|
522
|
+
if (key === "v" && !isPresentationMode) {
|
|
523
|
+
e.preventDefault()
|
|
524
|
+
setViewMode("slide")
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (key === "g") {
|
|
529
|
+
e.preventDefault()
|
|
186
530
|
setViewMode(prev => (prev === "grid" ? "slide" : "grid"))
|
|
187
531
|
return
|
|
188
532
|
}
|
|
189
533
|
|
|
534
|
+
if (key === "l") {
|
|
535
|
+
e.preventDefault()
|
|
536
|
+
setViewMode(prev => (prev === "list" ? "slide" : "list"))
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (key === "c" && !isPresentationMode) {
|
|
541
|
+
e.preventDefault()
|
|
542
|
+
toggleCommentMode()
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (key === "d" && !isPresentationMode) {
|
|
547
|
+
e.preventDefault()
|
|
548
|
+
void handleExportPdf()
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (viewMode === "slide" && !isPresentationMode && key === "t") {
|
|
553
|
+
e.preventDefault()
|
|
554
|
+
setThumbnailRailOpen(!showThumbnailRail)
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
|
|
190
558
|
if (viewMode !== "slide" || (isAnnotationMode && !isPresentationMode)) return
|
|
191
559
|
|
|
192
560
|
if (e.key === "ArrowRight" || e.key === " ") {
|
|
@@ -200,12 +568,26 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
200
568
|
|
|
201
569
|
window.addEventListener("keydown", handleKeyDown)
|
|
202
570
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
|
203
|
-
}, [
|
|
571
|
+
}, [
|
|
572
|
+
advance,
|
|
573
|
+
goBack,
|
|
574
|
+
viewMode,
|
|
575
|
+
togglePresentationMode,
|
|
576
|
+
isAnnotationMode,
|
|
577
|
+
isPresentationMode,
|
|
578
|
+
setThumbnailRailOpen,
|
|
579
|
+
showThumbnailRail,
|
|
580
|
+
toggleCommentMode,
|
|
581
|
+
handleExportPdf
|
|
582
|
+
])
|
|
204
583
|
|
|
205
584
|
return (
|
|
206
585
|
<div className="min-h-screen w-full bg-neutral-950 text-foreground">
|
|
207
586
|
<style>{`
|
|
208
587
|
@media print {
|
|
588
|
+
.list-slide-canvas {
|
|
589
|
+
transform: scale(1.5) !important;
|
|
590
|
+
}
|
|
209
591
|
@page {
|
|
210
592
|
size: 1920px 1080px;
|
|
211
593
|
margin: 0;
|
|
@@ -229,9 +611,10 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
229
611
|
{/* Toolbar */}
|
|
230
612
|
<div
|
|
231
613
|
className={cn(
|
|
232
|
-
"fixed top-4 z-50 flex gap-1 rounded-lg border border-neutral-800 bg-neutral-950/90 p-1 backdrop-blur-sm transition-[right] print:hidden",
|
|
233
|
-
isPresentationMode &&
|
|
234
|
-
|
|
614
|
+
"fixed top-4 z-50 flex gap-1 rounded-lg border border-neutral-800 bg-neutral-950/90 p-1 backdrop-blur-sm transition-[right] duration-200 ease-out print:hidden",
|
|
615
|
+
(isPresentationMode || (isNarrowViewport && isAnnotationMode && showAnnotationPanel)) &&
|
|
616
|
+
"hidden",
|
|
617
|
+
isAnnotationMode && showAnnotationPanel ? "right-[21rem]" : "right-4"
|
|
235
618
|
)}
|
|
236
619
|
>
|
|
237
620
|
<button
|
|
@@ -240,7 +623,7 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
240
623
|
"rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white",
|
|
241
624
|
viewMode === "slide" && "bg-neutral-800 text-white"
|
|
242
625
|
)}
|
|
243
|
-
title="Presentation View"
|
|
626
|
+
title="Presentation View (V)"
|
|
244
627
|
>
|
|
245
628
|
<Monitor className="h-4 w-4" />
|
|
246
629
|
</button>
|
|
@@ -250,7 +633,7 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
250
633
|
"rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white",
|
|
251
634
|
viewMode === "list" && "bg-neutral-800 text-white"
|
|
252
635
|
)}
|
|
253
|
-
title="List View"
|
|
636
|
+
title="List View (L)"
|
|
254
637
|
>
|
|
255
638
|
<List className="h-4 w-4" />
|
|
256
639
|
</button>
|
|
@@ -260,7 +643,7 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
260
643
|
"rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white",
|
|
261
644
|
viewMode === "grid" && "bg-neutral-800 text-white"
|
|
262
645
|
)}
|
|
263
|
-
title="Grid View"
|
|
646
|
+
title="Grid View (G)"
|
|
264
647
|
>
|
|
265
648
|
<Grid3X3 className="h-4 w-4" />
|
|
266
649
|
</button>
|
|
@@ -268,19 +651,14 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
268
651
|
<div className="mx-1 w-px bg-neutral-800" />
|
|
269
652
|
|
|
270
653
|
<button
|
|
271
|
-
onClick={
|
|
272
|
-
setIsAnnotationMode(prev => {
|
|
273
|
-
const next = !prev
|
|
274
|
-
setShowAnnotationPanel(next)
|
|
275
|
-
if (!next) setSelectedAnnotationId(null)
|
|
276
|
-
return next
|
|
277
|
-
})
|
|
278
|
-
}}
|
|
654
|
+
onClick={toggleCommentMode}
|
|
279
655
|
className={cn(
|
|
280
|
-
"relative
|
|
281
|
-
isAnnotationMode
|
|
656
|
+
"relative inline-flex h-8 items-center justify-center rounded-md transition-colors",
|
|
657
|
+
isAnnotationMode
|
|
658
|
+
? "border border-[#FF6B35]/50 bg-[#FF6B35]/15 p-[7px] text-[#FF6B35] hover:border-[#FF6B35] hover:bg-[#FF6B35]/25"
|
|
659
|
+
: "p-2 text-neutral-400 hover:bg-neutral-800 hover:text-white"
|
|
282
660
|
)}
|
|
283
|
-
title="
|
|
661
|
+
title="Comment (C)"
|
|
284
662
|
>
|
|
285
663
|
<MessageCircle className="h-4 w-4" />
|
|
286
664
|
{openCount > 0 && (
|
|
@@ -295,33 +673,175 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
295
673
|
<button
|
|
296
674
|
onClick={handleExportPdf}
|
|
297
675
|
className="rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white"
|
|
298
|
-
title="Download PDF"
|
|
676
|
+
title="Download PDF (D)"
|
|
299
677
|
>
|
|
300
678
|
<Download className="h-4 w-4" />
|
|
301
679
|
</button>
|
|
302
680
|
<button
|
|
303
681
|
onClick={togglePresentationMode}
|
|
304
|
-
className="rounded-md
|
|
682
|
+
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border border-[#FF6B35]/50 bg-[#FF6B35]/15 px-3 text-sm font-semibold text-[#FF6B35] transition-colors hover:border-[#FF6B35] hover:bg-[#FF6B35]/25"
|
|
305
683
|
title="Present (F)"
|
|
306
684
|
>
|
|
307
|
-
<
|
|
685
|
+
<Play className="h-3.5 w-3.5 fill-current" />
|
|
686
|
+
<span>Present</span>
|
|
308
687
|
</button>
|
|
309
688
|
</div>
|
|
310
689
|
|
|
311
690
|
{/* Slide View */}
|
|
312
691
|
{viewMode === "slide" && (
|
|
313
692
|
<div
|
|
314
|
-
className={cn(
|
|
315
|
-
"flex h-screen w-full print:hidden",
|
|
316
|
-
isPresentationMode ? "bg-black" : ""
|
|
317
|
-
)}
|
|
693
|
+
className={cn("flex h-screen w-full print:hidden", isPresentationMode ? "bg-black" : "")}
|
|
318
694
|
>
|
|
695
|
+
{!isPresentationMode && (
|
|
696
|
+
<>
|
|
697
|
+
<div
|
|
698
|
+
className={cn(
|
|
699
|
+
"relative z-10 h-screen shrink-0",
|
|
700
|
+
isNarrowViewport && "fixed inset-y-0 left-0",
|
|
701
|
+
isResizingThumbnails && !isAnimatingDragBoundary
|
|
702
|
+
? "transition-none"
|
|
703
|
+
: "transition-[width] duration-200 ease-out motion-reduce:transition-none"
|
|
704
|
+
)}
|
|
705
|
+
style={{ width: isThumbnailRailVisible ? visibleThumbnailWidth : 0 }}
|
|
706
|
+
>
|
|
707
|
+
<div className="h-full w-full overflow-hidden">
|
|
708
|
+
<aside
|
|
709
|
+
id="presentation-thumbnails"
|
|
710
|
+
aria-label="Slide thumbnails"
|
|
711
|
+
aria-hidden={!isThumbnailRailVisible}
|
|
712
|
+
inert={!isThumbnailRailVisible}
|
|
713
|
+
className={cn(
|
|
714
|
+
"flex h-screen shrink-0 flex-col border-r border-neutral-800 bg-neutral-950 text-white transition-opacity motion-reduce:transition-none",
|
|
715
|
+
isThumbnailRailVisible
|
|
716
|
+
? "opacity-100 delay-100 duration-100"
|
|
717
|
+
: "opacity-0 duration-75"
|
|
718
|
+
)}
|
|
719
|
+
style={{ width: visibleThumbnailWidth }}
|
|
720
|
+
>
|
|
721
|
+
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
|
|
722
|
+
<span className="text-sm font-semibold">Slides ({slides.length})</span>
|
|
723
|
+
<button
|
|
724
|
+
type="button"
|
|
725
|
+
onClick={() => setThumbnailRailOpen(false)}
|
|
726
|
+
className="rounded p-2 text-neutral-400 hover:bg-neutral-800 hover:text-white"
|
|
727
|
+
aria-label="Hide slide thumbnails"
|
|
728
|
+
title="Hide thumbnails (T)"
|
|
729
|
+
>
|
|
730
|
+
<PanelLeftClose className="h-4 w-4" />
|
|
731
|
+
</button>
|
|
732
|
+
</div>
|
|
733
|
+
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
|
|
734
|
+
{slides.map((slideConfig, index) => (
|
|
735
|
+
<button
|
|
736
|
+
key={slideConfig.id ?? index}
|
|
737
|
+
ref={index === currentSlide ? activeThumbnailRef : undefined}
|
|
738
|
+
type="button"
|
|
739
|
+
onClick={() => goToSlide(index)}
|
|
740
|
+
aria-label={`Go to slide ${index + 1}${slideConfig.title ? `: ${slideConfig.title}` : ""}`}
|
|
741
|
+
aria-current={index === currentSlide ? "page" : undefined}
|
|
742
|
+
className={cn(
|
|
743
|
+
"block w-full rounded-lg border p-1.5 text-left transition-colors focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#FF6B35]",
|
|
744
|
+
index === currentSlide
|
|
745
|
+
? "border-[#FF6B35] bg-[#FF6B35]/15 ring-1 ring-[#FF6B35]"
|
|
746
|
+
: "border-transparent hover:border-neutral-600 hover:bg-neutral-800"
|
|
747
|
+
)}
|
|
748
|
+
>
|
|
749
|
+
<div className="relative aspect-video w-full overflow-hidden rounded bg-black">
|
|
750
|
+
<GridThumbnail
|
|
751
|
+
slide={slideConfig}
|
|
752
|
+
index={index}
|
|
753
|
+
total={slides.length}
|
|
754
|
+
/>
|
|
755
|
+
</div>
|
|
756
|
+
<div className="mt-1.5 flex items-baseline gap-2 px-0.5 text-xs">
|
|
757
|
+
{slideConfig.title ? (
|
|
758
|
+
<>
|
|
759
|
+
<span className="font-mono text-neutral-400">{index + 1}</span>
|
|
760
|
+
<span className="truncate">{slideConfig.title}</span>
|
|
761
|
+
</>
|
|
762
|
+
) : (
|
|
763
|
+
<span>Slide {index + 1}</span>
|
|
764
|
+
)}
|
|
765
|
+
</div>
|
|
766
|
+
</button>
|
|
767
|
+
))}
|
|
768
|
+
</div>
|
|
769
|
+
</aside>
|
|
770
|
+
</div>
|
|
771
|
+
{isThumbnailRailVisible && (
|
|
772
|
+
<div
|
|
773
|
+
role="separator"
|
|
774
|
+
aria-label="Resize slide thumbnails"
|
|
775
|
+
aria-orientation="vertical"
|
|
776
|
+
aria-valuemin={minThumbnailWidth}
|
|
777
|
+
aria-valuemax={maxThumbnailWidth}
|
|
778
|
+
aria-valuenow={visibleThumbnailWidth}
|
|
779
|
+
tabIndex={0}
|
|
780
|
+
className={cn(
|
|
781
|
+
"absolute inset-y-0 -right-1 w-2 cursor-col-resize touch-none focus-visible:outline-none after:pointer-events-none after:absolute after:inset-y-0 after:right-1 after:w-0.5 after:bg-transparent after:content-[''] after:transition-colors hover:after:bg-[#FF6B35]/50 focus-visible:after:bg-[#FF6B35]/50",
|
|
782
|
+
isResizingThumbnails && "after:bg-[#FF6B35]/60"
|
|
783
|
+
)}
|
|
784
|
+
onPointerDown={event => {
|
|
785
|
+
if (event.button !== 0) return
|
|
786
|
+
event.preventDefault()
|
|
787
|
+
resizeStartRef.current = {
|
|
788
|
+
pointerId: event.pointerId,
|
|
789
|
+
pointerX: event.clientX,
|
|
790
|
+
width: visibleThumbnailWidth
|
|
791
|
+
}
|
|
792
|
+
dragCollapsedRef.current = false
|
|
793
|
+
setIsAnimatingDragBoundary(false)
|
|
794
|
+
if (dragAnimationTimerRef.current) clearTimeout(dragAnimationTimerRef.current)
|
|
795
|
+
setIsResizingThumbnails(true)
|
|
796
|
+
previousUserSelectRef.current = document.body.style.userSelect
|
|
797
|
+
previousCursorRef.current = document.body.style.cursor
|
|
798
|
+
document.body.style.userSelect = "none"
|
|
799
|
+
document.body.style.cursor = "col-resize"
|
|
800
|
+
}}
|
|
801
|
+
onKeyDown={event => {
|
|
802
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
803
|
+
event.preventDefault()
|
|
804
|
+
event.stopPropagation()
|
|
805
|
+
const amount = event.key === "ArrowRight" ? 16 : -16
|
|
806
|
+
setThumbnailWidth(
|
|
807
|
+
Math.max(
|
|
808
|
+
minThumbnailWidth,
|
|
809
|
+
Math.min(maxThumbnailWidth, visibleThumbnailWidth + amount)
|
|
810
|
+
)
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
}}
|
|
814
|
+
/>
|
|
815
|
+
)}
|
|
816
|
+
</div>
|
|
817
|
+
<div
|
|
818
|
+
aria-hidden={isThumbnailRailVisible || isResizingThumbnails}
|
|
819
|
+
inert={isThumbnailRailVisible || isResizingThumbnails}
|
|
820
|
+
className={cn(
|
|
821
|
+
"fixed top-4 left-4 z-50 rounded-lg border border-neutral-800 bg-neutral-950/90 backdrop-blur-sm transition-opacity duration-150 motion-reduce:transition-none",
|
|
822
|
+
isThumbnailRailVisible || isResizingThumbnails
|
|
823
|
+
? "pointer-events-none opacity-0"
|
|
824
|
+
: "opacity-100 delay-200"
|
|
825
|
+
)}
|
|
826
|
+
>
|
|
827
|
+
<button
|
|
828
|
+
type="button"
|
|
829
|
+
onClick={() => setThumbnailRailOpen(true)}
|
|
830
|
+
className="rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white"
|
|
831
|
+
aria-label="Show slide thumbnails"
|
|
832
|
+
title="Show thumbnails (T)"
|
|
833
|
+
>
|
|
834
|
+
<PanelLeftOpen className="h-4 w-4" />
|
|
835
|
+
</button>
|
|
836
|
+
</div>
|
|
837
|
+
</>
|
|
838
|
+
)}
|
|
319
839
|
<div
|
|
320
840
|
ref={containerRef}
|
|
321
841
|
role="presentation"
|
|
322
842
|
tabIndex={isPresentationMode ? 0 : undefined}
|
|
323
843
|
className={cn(
|
|
324
|
-
"flex flex-1 flex-col items-center justify-center overflow-hidden",
|
|
844
|
+
"flex min-w-0 flex-1 flex-col items-center justify-center overflow-hidden",
|
|
325
845
|
isPresentationMode ? "bg-black p-0" : "p-4 md:p-8"
|
|
326
846
|
)}
|
|
327
847
|
onClick={isPresentationMode ? advance : undefined}
|
|
@@ -357,31 +877,44 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
357
877
|
/>
|
|
358
878
|
</div>
|
|
359
879
|
) : (
|
|
360
|
-
<div
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
<
|
|
880
|
+
<div
|
|
881
|
+
ref={previewViewportRef}
|
|
882
|
+
className="relative aspect-video w-full max-w-7xl overflow-hidden rounded-xl border border-neutral-800 bg-black shadow-2xl"
|
|
883
|
+
>
|
|
884
|
+
<div
|
|
885
|
+
ref={slideContainerRef}
|
|
886
|
+
className="absolute top-0 left-0 overflow-hidden"
|
|
887
|
+
style={{
|
|
888
|
+
width: SLIDE_DIMENSIONS.width,
|
|
889
|
+
height: SLIDE_DIMENSIONS.height,
|
|
890
|
+
transformOrigin: "top left"
|
|
891
|
+
}}
|
|
892
|
+
>
|
|
893
|
+
<SlideRenderer
|
|
374
894
|
slides={slides}
|
|
375
895
|
currentSlide={currentSlide}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
896
|
+
animationStep={animationStep}
|
|
897
|
+
totalSteps={totalSteps}
|
|
898
|
+
direction={direction}
|
|
899
|
+
showAllAnimations={showAllAnimations}
|
|
900
|
+
transition={transition}
|
|
901
|
+
directionalTransition={directionalTransition}
|
|
902
|
+
onTransitionComplete={onTransitionComplete}
|
|
383
903
|
/>
|
|
384
|
-
|
|
904
|
+
{isAnnotationMode && (
|
|
905
|
+
<AnnotationOverlay
|
|
906
|
+
slides={slides}
|
|
907
|
+
currentSlide={currentSlide}
|
|
908
|
+
slideContainerRef={slideContainerRef}
|
|
909
|
+
selectedId={selectedAnnotationId}
|
|
910
|
+
hoveredId={hoveredAnnotationId}
|
|
911
|
+
onSelectId={setSelectedAnnotationId}
|
|
912
|
+
onShowPanel={() => setShowAnnotationPanel(true)}
|
|
913
|
+
slideAnnotations={getSlideAnnotations(currentSlide)}
|
|
914
|
+
addAnnotation={effectiveAdd ?? (() => {})}
|
|
915
|
+
/>
|
|
916
|
+
)}
|
|
917
|
+
</div>
|
|
385
918
|
</div>
|
|
386
919
|
)}
|
|
387
920
|
</LayoutGroup>
|
|
@@ -392,6 +925,7 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
392
925
|
<button
|
|
393
926
|
onClick={goBack}
|
|
394
927
|
className="rounded-full border border-neutral-800 bg-black/50 p-2 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-neutral-900 hover:text-white"
|
|
928
|
+
title="Previous step or slide (←)"
|
|
395
929
|
>
|
|
396
930
|
<ChevronLeft className="h-5 w-5" />
|
|
397
931
|
</button>
|
|
@@ -403,6 +937,7 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
403
937
|
<button
|
|
404
938
|
onClick={advance}
|
|
405
939
|
className="rounded-full border border-neutral-800 bg-black/50 p-2 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-neutral-900 hover:text-white"
|
|
940
|
+
title="Next step or slide (→ / Space)"
|
|
406
941
|
>
|
|
407
942
|
<ChevronRight className="h-5 w-5" />
|
|
408
943
|
</button>
|
|
@@ -411,17 +946,51 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
411
946
|
</div>
|
|
412
947
|
|
|
413
948
|
{/* Annotation Panel — beside the slide */}
|
|
414
|
-
{
|
|
415
|
-
<
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
949
|
+
{!isPresentationMode && (
|
|
950
|
+
<div
|
|
951
|
+
aria-hidden={!isAnnotationMode || !showAnnotationPanel}
|
|
952
|
+
inert={!isAnnotationMode || !showAnnotationPanel}
|
|
953
|
+
className={cn(
|
|
954
|
+
"h-full shrink-0 overflow-hidden transition-[width,transform] duration-200 ease-out motion-reduce:transition-none",
|
|
955
|
+
isNarrowViewport && "fixed inset-y-0 right-0 z-50 shadow-xl",
|
|
956
|
+
isNarrowViewport &&
|
|
957
|
+
(!isAnnotationMode || !showAnnotationPanel) &&
|
|
958
|
+
"translate-x-full"
|
|
959
|
+
)}
|
|
960
|
+
style={{
|
|
961
|
+
width:
|
|
962
|
+
isAnnotationMode && showAnnotationPanel
|
|
963
|
+
? isNarrowViewport
|
|
964
|
+
? "min(20rem, 90vw)"
|
|
965
|
+
: "20rem"
|
|
966
|
+
: 0
|
|
423
967
|
}}
|
|
424
|
-
|
|
968
|
+
>
|
|
969
|
+
<div
|
|
970
|
+
className={cn(
|
|
971
|
+
"h-full transition-opacity motion-reduce:transition-none",
|
|
972
|
+
isAnnotationMode && showAnnotationPanel
|
|
973
|
+
? "opacity-100 delay-100 duration-100"
|
|
974
|
+
: "opacity-0 duration-75"
|
|
975
|
+
)}
|
|
976
|
+
>
|
|
977
|
+
<AnnotationPanel
|
|
978
|
+
annotations={getSlideAnnotations(currentSlide)}
|
|
979
|
+
unlinkedAnnotations={unlinkedAnnotations}
|
|
980
|
+
selectedId={selectedAnnotationId}
|
|
981
|
+
onSelect={setSelectedAnnotationId}
|
|
982
|
+
onHover={setHoveredAnnotationId}
|
|
983
|
+
onDelete={effectiveDelete ?? (() => {})}
|
|
984
|
+
onUpdate={effectiveUpdate ?? (() => {})}
|
|
985
|
+
onClose={() => {
|
|
986
|
+
setIsAnnotationMode(false)
|
|
987
|
+
setShowAnnotationPanel(false)
|
|
988
|
+
setSelectedAnnotationId(null)
|
|
989
|
+
setHoveredAnnotationId(null)
|
|
990
|
+
}}
|
|
991
|
+
/>
|
|
992
|
+
</div>
|
|
993
|
+
</div>
|
|
425
994
|
)}
|
|
426
995
|
</div>
|
|
427
996
|
)}
|
|
@@ -431,14 +1000,13 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
431
1000
|
<div className="mx-auto max-w-7xl p-8 pt-16 print:hidden">
|
|
432
1001
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
|
433
1002
|
{slides.map((slideConfig, index) => {
|
|
434
|
-
const SlideComponent = slideConfig.component
|
|
435
1003
|
const prevSection = index > 0 ? slides[index - 1]?.section : undefined
|
|
436
1004
|
const showSectionHeader = slideConfig.section && slideConfig.section !== prevSection
|
|
437
1005
|
|
|
438
1006
|
return (
|
|
439
|
-
<div key={index} className=
|
|
1007
|
+
<div key={slideConfig.id ?? index} className="contents">
|
|
440
1008
|
{showSectionHeader && (
|
|
441
|
-
<h3 className="mt-4 mb-
|
|
1009
|
+
<h3 className="col-span-full mt-4 mb-0 text-xs font-bold tracking-[0.2em] text-neutral-500 uppercase first:mt-0">
|
|
442
1010
|
{slideConfig.section}
|
|
443
1011
|
</h3>
|
|
444
1012
|
)}
|
|
@@ -447,22 +1015,9 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
447
1015
|
goToSlide(index)
|
|
448
1016
|
setViewMode("slide")
|
|
449
1017
|
}}
|
|
450
|
-
className="group relative aspect-video w-full overflow-hidden rounded-lg border border-neutral-800 bg-black shadow-sm transition-all hover:border-
|
|
1018
|
+
className="group relative aspect-video w-full overflow-hidden rounded-lg border border-neutral-800 bg-black shadow-sm transition-all hover:border-[#FF6B35] hover:shadow-lg hover:shadow-[#FF6B35]/10"
|
|
451
1019
|
>
|
|
452
|
-
<
|
|
453
|
-
className="h-full w-full origin-top-left scale-[0.25]"
|
|
454
|
-
style={{ width: "400%", height: "400%" }}
|
|
455
|
-
>
|
|
456
|
-
<AnimationProvider
|
|
457
|
-
currentStep={slideConfig.steps}
|
|
458
|
-
totalSteps={slideConfig.steps}
|
|
459
|
-
showAllAnimations={true}
|
|
460
|
-
>
|
|
461
|
-
<SlideErrorBoundary slideIndex={index} slideTitle={slideConfig.title}>
|
|
462
|
-
<SlideComponent slideNumber={index + 1} totalSlides={slides.length} />
|
|
463
|
-
</SlideErrorBoundary>
|
|
464
|
-
</AnimationProvider>
|
|
465
|
-
</div>
|
|
1020
|
+
<GridThumbnail slide={slideConfig} index={index} total={slides.length} />
|
|
466
1021
|
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/20" />
|
|
467
1022
|
<div className="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-xs font-medium text-white">
|
|
468
1023
|
{slideConfig.title ? `${index + 1}. ${slideConfig.title}` : index + 1}
|
|
@@ -484,27 +1039,14 @@ export function SlideDeck({ slides, transition, directionalTransition, annotatio
|
|
|
484
1039
|
)}
|
|
485
1040
|
>
|
|
486
1041
|
<div className="grid grid-cols-1 gap-8 print:block">
|
|
487
|
-
{slides.map((slideConfig, index) =>
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
<AnimationProvider
|
|
496
|
-
currentStep={slideConfig.steps}
|
|
497
|
-
totalSteps={slideConfig.steps}
|
|
498
|
-
showAllAnimations={true}
|
|
499
|
-
>
|
|
500
|
-
<SlideErrorBoundary slideIndex={index} slideTitle={slideConfig.title}>
|
|
501
|
-
<SlideComponent slideNumber={index + 1} totalSlides={slides.length} />
|
|
502
|
-
</SlideErrorBoundary>
|
|
503
|
-
</AnimationProvider>
|
|
504
|
-
</div>
|
|
505
|
-
</div>
|
|
506
|
-
)
|
|
507
|
-
})}
|
|
1042
|
+
{slides.map((slideConfig, index) => (
|
|
1043
|
+
<ListSlide
|
|
1044
|
+
key={slideConfig.id ?? index}
|
|
1045
|
+
slide={slideConfig}
|
|
1046
|
+
index={index}
|
|
1047
|
+
total={slides.length}
|
|
1048
|
+
/>
|
|
1049
|
+
))}
|
|
508
1050
|
</div>
|
|
509
1051
|
</div>
|
|
510
1052
|
</div>
|