promptslide 0.3.15 → 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.
@@ -1,14 +1,25 @@
1
1
  import { LayoutGroup } from "framer-motion"
2
- import { ChevronLeft, ChevronRight, Download, Grid3X3, List, Maximize, MessageCircle, Monitor } from "lucide-react"
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react"
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?: (slideIndex: number, slideTitle: string, target: AnnotationTarget, body: string) => void
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
- setReady(true)
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({ slides, transition, directionalTransition, annotations, onAnnotationAdd, onAnnotationDelete }: SlideDeckProps) {
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(() => effectiveAnnotations.filter(a => a.status === "open").length, [effectiveAnnotations])
98
- const getSlideAnnotations = useCallback(
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 viewportWidth = window.innerWidth
438
+ const availableWidth = window.innerWidth
151
439
  const viewportHeight = window.innerHeight
152
- const scaleX = viewportWidth / SLIDE_DIMENSIONS.width
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
- const handleExportPdf = () => {
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
- if (e.key === "f" || e.key === "F") {
180
- togglePresentationMode()
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
- // G for grid view toggle
185
- if (e.key === "g" || e.key === "G") {
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
- }, [advance, goBack, viewMode, togglePresentationMode, isAnnotationMode, isPresentationMode])
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 && "hidden",
234
- isAnnotationMode && showAnnotationPanel ? "right-[19.5rem]" : "right-4"
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 rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white",
281
- isAnnotationMode && "bg-[#FF6B35] text-white hover:bg-[#FF7A4A]"
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="Annotate slides"
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 p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white"
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
- <Maximize className="h-4 w-4" />
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 ref={slideContainerRef} className="relative aspect-video w-full max-w-7xl overflow-hidden rounded-xl border border-neutral-800 bg-black shadow-2xl">
361
- <SlideRenderer
362
- slides={slides}
363
- currentSlide={currentSlide}
364
- animationStep={animationStep}
365
- totalSteps={totalSteps}
366
- direction={direction}
367
- showAllAnimations={showAllAnimations}
368
- transition={transition}
369
- directionalTransition={directionalTransition}
370
- onTransitionComplete={onTransitionComplete}
371
- />
372
- {isAnnotationMode && (
373
- <AnnotationOverlay
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
- slideContainerRef={slideContainerRef}
377
- selectedId={selectedAnnotationId}
378
- onSelectId={setSelectedAnnotationId}
379
- onShowPanel={() => setShowAnnotationPanel(true)}
380
- slideAnnotations={getSlideAnnotations(currentSlide)}
381
- addAnnotation={effectiveAdd ?? (() => {})}
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
- {isAnnotationMode && showAnnotationPanel && !isPresentationMode && (
415
- <AnnotationPanel
416
- annotations={getSlideAnnotations(currentSlide)}
417
- selectedId={selectedAnnotationId}
418
- onSelect={setSelectedAnnotationId}
419
- onDelete={effectiveDelete ?? (() => {})}
420
- onClose={() => {
421
- setShowAnnotationPanel(false)
422
- setSelectedAnnotationId(null)
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={showSectionHeader ? "col-span-full" : undefined}>
1007
+ <div key={slideConfig.id ?? index} className="contents">
440
1008
  {showSectionHeader && (
441
- <h3 className="mt-4 mb-3 text-xs font-bold tracking-[0.2em] text-neutral-500 uppercase first:mt-0">
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-primary hover:shadow-lg hover:shadow-primary/10"
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
- <div
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
- const SlideComponent = slideConfig.component
489
- return (
490
- <div
491
- key={index}
492
- className="aspect-video w-full overflow-hidden rounded-xl border border-neutral-800 bg-black shadow-sm print:relative print:m-0 print:h-[1080px] print:w-[1920px] print:break-after-page print:overflow-hidden print:rounded-none print:border-0 print:shadow-none"
493
- >
494
- <div className="h-full w-full print:h-[720px] print:w-[1280px] print:origin-top-left print:scale-[1.5]">
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>