promptslide 0.3.18 → 0.3.19

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,41 +1,67 @@
1
1
  import { LayoutGroup } from "framer-motion"
2
2
  import {
3
+ ChevronDown,
3
4
  ChevronLeft,
4
5
  ChevronRight,
5
6
  Download,
6
- Grid3X3,
7
- List,
8
7
  MessageCircle,
9
- Monitor,
10
8
  PanelLeftClose,
11
9
  PanelLeftOpen,
12
- Play
10
+ Play,
11
+ Settings2
13
12
  } from "lucide-react"
14
13
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
15
14
 
16
15
  import type { Annotation, AnnotationTarget } from "./annotations"
17
16
  import type { SlideTransitionType } from "./transitions"
18
- import type { SlideConfig } from "./types"
17
+ import type { LoopConfig, SlideConfig } from "./types"
19
18
 
20
19
  import { SLIDE_DIMENSIONS } from "./animation-config"
21
20
  import { AnimationProvider } from "./animation-context"
22
21
  import { AnnotationOverlay, AnnotationPanel, useAnnotations } from "./annotations"
22
+ import { Inspector, type InspectorMode } from "./inspector"
23
+ import {
24
+ DEFAULT_LOOP_CONFIG,
25
+ includedSlides,
26
+ loopSlideKey,
27
+ validateLoopConfig
28
+ } from "./loop/config.mjs"
29
+ import { useDefaultLoopConfig } from "./loop/context"
30
+ import { LoopSettings } from "./loop/loop-settings"
31
+ import { useLoopPlayback } from "./loop/use-loop-playback"
23
32
  import { SlideErrorBoundary } from "./slide-error-boundary"
24
33
  import { SlideRenderer } from "./slide-renderer"
34
+ import { ToolbarAction } from "./toolbar-action"
25
35
  import { useSlideNavigation } from "./use-slide-navigation"
26
36
  import { cn } from "./utils"
37
+ import { ViewMenu, type ViewMode } from "./view-menu"
27
38
 
28
39
  // =============================================================================
29
40
  // TYPES
30
41
  // =============================================================================
31
42
 
32
- type ViewMode = "slide" | "list" | "grid"
33
43
  const NARROW_VIEWPORT_WIDTH = 768
34
44
  const DEFAULT_THUMBNAIL_WIDTH = 240
35
45
  const MIN_THUMBNAIL_WIDTH = 176
36
46
  const MAX_THUMBNAIL_WIDTH = 480
37
47
  const THUMBNAIL_WIDTH_STORAGE_KEY = "promptslide:thumbnail-width"
38
48
 
49
+ function loadLoopConfig(base: LoopConfig, storageKey: string): LoopConfig {
50
+ let fallback = DEFAULT_LOOP_CONFIG
51
+ try {
52
+ fallback = validateLoopConfig(base)
53
+ } catch {
54
+ /* Invalid host settings use defaults. */
55
+ }
56
+ try {
57
+ const saved = JSON.parse(localStorage.getItem(storageKey) || "null")
58
+ if (saved?.base === JSON.stringify(base)) return validateLoopConfig(saved.config)
59
+ } catch {
60
+ /* Storage may be disabled or stale. */
61
+ }
62
+ return fallback
63
+ }
64
+
39
65
  function GridThumbnail({
40
66
  slide,
41
67
  index,
@@ -121,6 +147,10 @@ interface SlideDeckProps {
121
147
  slides: SlideConfig[]
122
148
  transition?: SlideTransitionType
123
149
  directionalTransition?: boolean
150
+ /** Overrides the CLI-provided loop settings. */
151
+ loopConfig?: LoopConfig
152
+ /** Persist settings in a custom host. CLI Studio saves loop-config.json automatically. */
153
+ onLoopConfigChange?: (config: LoopConfig) => void | Promise<void>
124
154
  /** Annotation data to display. When provided (even empty array), annotation UI is enabled. When undefined, annotation UI is hidden. */
125
155
  annotations?: Annotation[]
126
156
  /** Called when the user creates an annotation */
@@ -195,6 +225,8 @@ export function SlideDeck({
195
225
  slides,
196
226
  transition,
197
227
  directionalTransition,
228
+ loopConfig: suppliedLoopConfig,
229
+ onLoopConfigChange,
198
230
  annotations,
199
231
  onAnnotationAdd,
200
232
  onAnnotationDelete,
@@ -239,26 +271,107 @@ export function SlideDeck({
239
271
 
240
272
  const [viewMode, setViewMode] = useState<ViewMode>("slide")
241
273
  const [isEditorOpen, setIsEditorOpen] = useState(false)
274
+ const editorOpenRef = useRef(false)
275
+ const [editorAvailable, setEditorAvailable] = useState(false)
276
+ const [inspectorMode, setInspectorMode] = useState<InspectorMode | null>(null)
277
+ const pendingInspector = useRef<InspectorMode | null | undefined>(undefined)
242
278
  const [editorRevision, setEditorRevision] = useState(0)
279
+ const activateInspector = useCallback((mode: InspectorMode | null) => {
280
+ setInspectorMode(mode)
281
+ setLoopMenuOpen(false)
282
+ if (mode) setViewMode("slide")
283
+ if (mode !== "comments") {
284
+ setSelectedAnnotationId(null)
285
+ setHoveredAnnotationId(null)
286
+ }
287
+ }, [])
288
+ const requestInspector = useCallback(
289
+ (mode: InspectorMode | null) => {
290
+ if (isEditorOpen) {
291
+ if (mode === "editor") return
292
+ pendingInspector.current = mode
293
+ window.dispatchEvent(
294
+ new CustomEvent("promptslide:editor-request", { detail: { open: false } })
295
+ )
296
+ return
297
+ }
298
+ if (mode === "editor") {
299
+ if (editorAvailable)
300
+ window.dispatchEvent(
301
+ new CustomEvent("promptslide:editor-request", { detail: { open: true } })
302
+ )
303
+ return
304
+ }
305
+ activateInspector(mode)
306
+ },
307
+ [isEditorOpen, editorAvailable, activateInspector]
308
+ )
243
309
  useEffect(() => {
244
310
  const onEditorState = (event: Event) => {
245
311
  const open = Boolean((event as CustomEvent).detail?.open)
312
+ const changed = editorOpenRef.current !== open
313
+ editorOpenRef.current = open
246
314
  setIsEditorOpen(open)
247
315
  if (open) {
248
- setViewMode("slide")
249
- setIsAnnotationMode(false)
250
- setShowAnnotationPanel(false)
316
+ if (changed) pendingInspector.current = undefined
317
+ activateInspector("editor")
318
+ } else if (changed) {
319
+ const next = pendingInspector.current
320
+ pendingInspector.current = undefined
321
+ activateInspector(next === undefined ? null : next)
251
322
  }
252
323
  }
324
+ const onEditorReady = (event: Event) =>
325
+ setEditorAvailable(Boolean((event as CustomEvent).detail?.available))
326
+ const onCloseCancelled = () => {
327
+ pendingInspector.current = undefined
328
+ }
253
329
  const refreshEditor = () => setEditorRevision(value => value + 1)
254
330
  window.addEventListener("promptslide:editor-state", onEditorState)
331
+ window.addEventListener("promptslide:editor-ready", onEditorReady)
332
+ window.addEventListener("promptslide:editor-close-cancelled", onCloseCancelled)
255
333
  window.addEventListener("promptslide:editor-refresh", refreshEditor)
256
334
  return () => {
257
335
  window.removeEventListener("promptslide:editor-state", onEditorState)
336
+ window.removeEventListener("promptslide:editor-ready", onEditorReady)
337
+ window.removeEventListener("promptslide:editor-close-cancelled", onCloseCancelled)
258
338
  window.removeEventListener("promptslide:editor-refresh", refreshEditor)
259
339
  }
260
- }, [])
340
+ }, [activateInspector])
261
341
  const [isPresentationMode, setIsPresentationMode] = useState(false)
342
+ const providedLoopConfig = useDefaultLoopConfig()
343
+ const defaultLoopKey = JSON.stringify(suppliedLoopConfig ?? providedLoopConfig)
344
+ const defaultLoopConfig = useMemo<LoopConfig>(() => JSON.parse(defaultLoopKey), [defaultLoopKey])
345
+ const storageKey = `promptslide:loop:${typeof location === "undefined" ? "" : location.pathname}:${JSON.stringify(slides.map(loopSlideKey))}`
346
+ const [loopConfig, setLoopConfig] = useState<LoopConfig>(() =>
347
+ loadLoopConfig(defaultLoopConfig, storageKey)
348
+ )
349
+ const [loopDraft, setLoopDraft] = useState<LoopConfig>(() =>
350
+ loadLoopConfig(defaultLoopConfig, storageKey)
351
+ )
352
+ const [playbackConfig, setPlaybackConfig] = useState(loopConfig)
353
+ const [isLooping, setIsLooping] = useState(false)
354
+ const [loopMenuOpen, setLoopMenuOpen] = useState(false)
355
+ const [loopError, setLoopError] = useState("")
356
+ const [loopStartScreen, setLoopStartScreen] = useState(
357
+ () => typeof window !== "undefined" && new URLSearchParams(window.location.search).has("loop")
358
+ )
359
+ const loopMenuRef = useRef<HTMLDivElement>(null)
360
+ useEffect(() => {
361
+ const config = loadLoopConfig(defaultLoopConfig, storageKey)
362
+ setLoopConfig(config)
363
+ setLoopDraft(config)
364
+ setPlaybackConfig(config)
365
+ setIsLooping(false)
366
+ }, [defaultLoopConfig, storageKey])
367
+ useEffect(() => {
368
+ if (!loopMenuOpen) return
369
+ const close = (event: PointerEvent) => {
370
+ if (!loopMenuRef.current?.contains(event.target as Node)) setLoopMenuOpen(false)
371
+ }
372
+ document.addEventListener("pointerdown", close)
373
+ return () => document.removeEventListener("pointerdown", close)
374
+ }, [loopMenuOpen])
262
375
  const [viewportWidth, setViewportWidth] = useState(() =>
263
376
  typeof window === "undefined" ? 1280 : window.innerWidth
264
377
  )
@@ -272,8 +385,7 @@ export function SlideDeck({
272
385
  ? Math.min(savedWidth, MAX_THUMBNAIL_WIDTH)
273
386
  : DEFAULT_THUMBNAIL_WIDTH
274
387
  })
275
- const [isAnnotationMode, setIsAnnotationMode] = useState(false)
276
- const [showAnnotationPanel, setShowAnnotationPanel] = useState(false)
388
+ const isAnnotationMode = inspectorMode === "comments"
277
389
  const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null)
278
390
  const [hoveredAnnotationId, setHoveredAnnotationId] = useState<string | null>(null)
279
391
  const [isResizingThumbnails, setIsResizingThumbnails] = useState(false)
@@ -318,8 +430,7 @@ export function SlideDeck({
318
430
  if (isNarrowViewport !== wasNarrowViewportRef.current) {
319
431
  setShowThumbnailRail(isNarrowViewport ? false : preferredRailOpenRef.current)
320
432
  if (isNarrowViewport) {
321
- setIsAnnotationMode(false)
322
- setShowAnnotationPanel(false)
433
+ setInspectorMode(mode => (mode === "comments" ? null : mode))
323
434
  setSelectedAnnotationId(null)
324
435
  }
325
436
  wasNarrowViewportRef.current = isNarrowViewport
@@ -415,16 +526,97 @@ export function SlideDeck({
415
526
  currentSlide,
416
527
  animationStep,
417
528
  totalSteps,
529
+ isTransitioning,
418
530
  direction,
419
531
  showAllAnimations,
420
532
  advance,
421
533
  goBack,
422
534
  goToSlide,
535
+ goToStep,
536
+ restartAtSlide,
423
537
  onTransitionComplete
424
538
  } = useSlideNavigation({
425
539
  slides
426
540
  })
427
541
 
542
+ const playback = useLoopPlayback({
543
+ config: playbackConfig,
544
+ slides,
545
+ currentSlide,
546
+ animationStep,
547
+ isTransitioning,
548
+ enabled: isLooping && isPresentationMode,
549
+ advance,
550
+ goBack,
551
+ restartAtSlide
552
+ })
553
+ const rememberLoopConfig = (config: LoopConfig) => {
554
+ setLoopConfig(config)
555
+ setLoopDraft(config)
556
+ try {
557
+ localStorage.setItem(
558
+ storageKey,
559
+ JSON.stringify({ base: JSON.stringify(defaultLoopConfig), config })
560
+ )
561
+ return true
562
+ } catch {
563
+ return false
564
+ }
565
+ }
566
+ const saveLoopConfig = async (config: LoopConfig) => {
567
+ if (onLoopConfigChange) {
568
+ await onLoopConfigChange(config)
569
+ rememberLoopConfig(config)
570
+ return "Loop settings saved."
571
+ }
572
+ try {
573
+ const response = await fetch("/__promptslide_loop", {
574
+ method: "POST",
575
+ headers: { "Content-Type": "application/json" },
576
+ body: JSON.stringify(config)
577
+ })
578
+ if (response.status === 204) {
579
+ rememberLoopConfig(config)
580
+ return "Saved to loop-config.json. Included in future builds and publishing."
581
+ }
582
+ if (response.status !== 404 && response.status !== 405)
583
+ throw new Error(
584
+ "Studio could not save loop-config.json. Download the settings to keep a copy."
585
+ )
586
+ } catch (error) {
587
+ if (error instanceof Error && error.message.startsWith("Studio")) throw error
588
+ }
589
+ return rememberLoopConfig(config)
590
+ ? "Saved in this browser. Download settings and place loop-config.json in the project folder to share or publish them."
591
+ : "Applied for this session. Browser storage is unavailable; download the settings to keep them."
592
+ }
593
+ const startLoop = async (config = loopDraft) => {
594
+ if (isEditorOpen) return
595
+ const first = includedSlides(config, slides)[0]
596
+ if (first === undefined) {
597
+ setLoopError("Include at least one slide before starting the loop.")
598
+ return
599
+ }
600
+ setLoopError("")
601
+ try {
602
+ // Request synchronously from the click, before any asynchronous work.
603
+ if (!document.fullscreenElement) await document.documentElement.requestFullscreen()
604
+ setPlaybackConfig(config)
605
+ setLoopDraft(config)
606
+ setViewMode("slide")
607
+ setInspectorMode(null)
608
+ setLoopMenuOpen(false)
609
+ setLoopStartScreen(false)
610
+ playback.setPaused(false)
611
+ playback.restart(first)
612
+ setIsLooping(true)
613
+ } catch {
614
+ setLoopError(
615
+ "Full screen could not start. Allow full screen in your browser, then try again."
616
+ )
617
+ }
618
+ }
619
+
428
620
  const togglePresentationMode = useCallback(async () => {
429
621
  if (!document.fullscreenElement) {
430
622
  await document.documentElement.requestFullscreen()
@@ -437,6 +629,7 @@ export function SlideDeck({
437
629
  useEffect(() => {
438
630
  const handleFullscreenChange = () => {
439
631
  setIsPresentationMode(!!document.fullscreenElement)
632
+ if (!document.fullscreenElement) setIsLooping(false)
440
633
  }
441
634
  document.addEventListener("fullscreenchange", handleFullscreenChange)
442
635
  return () => document.removeEventListener("fullscreenchange", handleFullscreenChange)
@@ -486,14 +679,8 @@ export function SlideDeck({
486
679
  }, [viewMode, isPresentationMode])
487
680
 
488
681
  const toggleCommentMode = useCallback(() => {
489
- const next = !isAnnotationMode
490
- setIsAnnotationMode(next)
491
- setShowAnnotationPanel(next)
492
- if (!next) {
493
- setSelectedAnnotationId(null)
494
- setHoveredAnnotationId(null)
495
- }
496
- }, [isAnnotationMode])
682
+ requestInspector(isAnnotationMode && viewMode === "slide" ? null : "comments")
683
+ }, [isAnnotationMode, viewMode, requestInspector])
497
684
 
498
685
  const handleExportPdf = useCallback(async () => {
499
686
  try {
@@ -534,6 +721,28 @@ export function SlideDeck({
534
721
  if (e.repeat && "fvglcdt".includes(key)) return
535
722
  if (isEditorOpen && ["f", "v", "g", "l", "c", "d"].includes(key)) return
536
723
 
724
+ if (loopStartScreen) return
725
+ if (loopMenuOpen) {
726
+ if (e.key === "Escape") {
727
+ setLoopMenuOpen(false)
728
+ loopMenuRef.current?.querySelector("button")?.focus()
729
+ }
730
+ return
731
+ }
732
+ if (isLooping) {
733
+ if (e.key === "Escape") {
734
+ setIsLooping(false)
735
+ if (document.fullscreenElement) void document.exitFullscreen().catch(() => {})
736
+ } else if (e.key === " ") {
737
+ e.preventDefault()
738
+ if (!e.repeat) playback.setPaused(value => !value)
739
+ } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
740
+ e.preventDefault()
741
+ playback.move(e.key === "ArrowRight" ? 1 : -1)
742
+ }
743
+ return
744
+ }
745
+
537
746
  if (key === "f") {
538
747
  e.preventDefault()
539
748
  void togglePresentationMode()
@@ -592,6 +801,10 @@ export function SlideDeck({
592
801
  }, [
593
802
  advance,
594
803
  goBack,
804
+ playback,
805
+ isLooping,
806
+ loopMenuOpen,
807
+ loopStartScreen,
595
808
  viewMode,
596
809
  togglePresentationMode,
597
810
  isAnnotationMode,
@@ -603,7 +816,8 @@ export function SlideDeck({
603
816
  handleExportPdf
604
817
  ])
605
818
 
606
- const editorPanelWidth = viewportWidth <= 850 ? 280 : 320
819
+ const inspectorWidth = viewportWidth <= 850 ? 280 : 320
820
+ const inspectorVisible = inspectorMode !== null && !isPresentationMode && viewMode === "slide"
607
821
 
608
822
  return (
609
823
  <div className="min-h-screen w-full bg-neutral-950 text-foreground">
@@ -654,102 +868,145 @@ export function SlideDeck({
654
868
  }
655
869
  `}</style>
656
870
 
871
+ {loopError && (
872
+ <div
873
+ role="alert"
874
+ className="fixed bottom-4 left-4 z-[110] max-w-md rounded-lg border border-red-700 bg-neutral-950 p-4 text-sm text-red-300"
875
+ >
876
+ {loopError}
877
+ <button className="ml-3 underline" onClick={() => setLoopError("")}>
878
+ Dismiss
879
+ </button>
880
+ </div>
881
+ )}
882
+ {loopStartScreen && (
883
+ <div className="fixed inset-0 z-[90] flex flex-col items-center justify-center gap-5 bg-neutral-950 text-white">
884
+ <h1 className="text-3xl font-semibold">Ready to loop</h1>
885
+ <p className="text-neutral-400">Space pauses or resumes. Escape stops the loop.</p>
886
+ <button
887
+ className="rounded-lg border border-orange-500 px-6 py-3 text-orange-400"
888
+ onClick={() => void startLoop()}
889
+ >
890
+ Click to start
891
+ </button>
892
+ <button
893
+ className="text-sm text-neutral-400 underline"
894
+ onClick={() => {
895
+ setLoopStartScreen(false)
896
+ requestInspector("slide")
897
+ }}
898
+ >
899
+ Configure loop
900
+ </button>
901
+ </div>
902
+ )}
903
+ {isLooping && playback.paused && (
904
+ <div
905
+ role="status"
906
+ className="pointer-events-none fixed bottom-4 left-4 z-[90] rounded-md bg-neutral-950 px-4 py-2 text-sm text-white"
907
+ >
908
+ Loop paused · Space to resume
909
+ </div>
910
+ )}
911
+
657
912
  {/* Toolbar */}
658
913
  <div
659
914
  data-ps-viewer-toolbar
660
915
  style={
661
- isEditorOpen
662
- ? { right: editorPanelWidth + 16, maxWidth: `calc(100vw - ${editorPanelWidth + 32}px)` }
916
+ inspectorVisible
917
+ ? { right: inspectorWidth + 16, maxWidth: `calc(100vw - ${inspectorWidth + 32}px)` }
663
918
  : undefined
664
919
  }
665
920
  className={cn(
666
- "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",
667
- (isPresentationMode || (isNarrowViewport && isAnnotationMode && showAnnotationPanel)) &&
921
+ "fixed top-4 z-50 flex flex-wrap 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",
922
+ (isPresentationMode || (isNarrowViewport && inspectorVisible && !isEditorOpen)) &&
668
923
  "hidden",
669
- isEditorOpen && "flex-wrap",
670
924
  isEditorOpen && viewportWidth <= 850 && "[&_button:disabled]:hidden [&>div]:hidden",
671
- !isEditorOpen && (isAnnotationMode && showAnnotationPanel ? "right-[21rem]" : "right-4")
925
+ !inspectorVisible && "right-4"
672
926
  )}
673
927
  >
674
- <button
675
- onClick={() => setViewMode("slide")}
676
- className={cn(
677
- "rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white",
678
- viewMode === "slide" && "bg-neutral-800 text-white"
679
- )}
680
- disabled={isEditorOpen}
681
- title="Presentation View (V)"
682
- >
683
- <Monitor className="h-4 w-4" />
684
- </button>
685
- <button
686
- onClick={() => setViewMode("list")}
687
- className={cn(
688
- "rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white",
689
- viewMode === "list" && "bg-neutral-800 text-white"
690
- )}
691
- disabled={isEditorOpen}
692
- title="List View (L)"
693
- >
694
- <List className="h-4 w-4" />
695
- </button>
696
- <button
697
- onClick={() => setViewMode("grid")}
698
- className={cn(
699
- "rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white",
700
- viewMode === "grid" && "bg-neutral-800 text-white"
701
- )}
702
- disabled={isEditorOpen}
703
- title="Grid View (G)"
704
- >
705
- <Grid3X3 className="h-4 w-4" />
706
- </button>
928
+ <ViewMenu value={viewMode} onChange={setViewMode} disabled={isEditorOpen} />
707
929
 
708
930
  <div className="mx-1 w-px bg-neutral-800" />
709
931
 
710
- <button
932
+ <ToolbarAction
711
933
  onClick={toggleCommentMode}
712
- className={cn(
713
- "relative inline-flex h-8 items-center justify-center gap-1.5 rounded-md font-[family-name:system-ui] text-[13px] leading-none font-medium transition-colors",
714
- isAnnotationMode
715
- ? "border border-[#FF6B35]/50 bg-[#FF6B35]/15 p-[7px] text-[#FF6B35] hover:border-[#FF6B35] hover:bg-[#FF6B35]/25"
716
- : "px-2.5 text-neutral-400 hover:bg-neutral-800 hover:text-white"
717
- )}
718
- disabled={isEditorOpen}
719
- aria-label="Comment"
720
- aria-pressed={isAnnotationMode}
934
+ label="Comment"
935
+ selected={isAnnotationMode}
721
936
  title="Comment (C)"
722
937
  >
723
938
  <MessageCircle className="h-[15px] w-[15px] shrink-0" />
724
- {!isAnnotationMode && <span>Comment</span>}
725
939
  {openCount > 0 && (
726
940
  <span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-[#FF6B35] text-[10px] font-bold text-white">
727
941
  {openCount}
728
942
  </span>
729
943
  )}
730
- </button>
944
+ </ToolbarAction>
731
945
 
732
946
  <span data-ps-editor-toolbar className="contents" />
947
+ <ToolbarAction
948
+ label="Slide settings"
949
+ selected={inspectorMode === "slide" && inspectorVisible}
950
+ onClick={() =>
951
+ requestInspector(inspectorMode === "slide" && inspectorVisible ? null : "slide")
952
+ }
953
+ >
954
+ <Settings2 size={16} />
955
+ </ToolbarAction>
733
956
 
734
957
  <div className="mx-1 w-px bg-neutral-800" />
735
958
 
736
- <button
959
+ <ToolbarAction
737
960
  onClick={handleExportPdf}
738
- className="rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white"
739
961
  disabled={isEditorOpen}
962
+ label="Download PDF"
740
963
  title="Download PDF (D)"
741
964
  >
742
965
  <Download className="h-4 w-4" />
743
- </button>
744
- <button
745
- onClick={togglePresentationMode}
746
- 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"
747
- disabled={isEditorOpen}
748
- title="Present (F)"
749
- >
750
- <Play className="h-3.5 w-3.5 fill-current" />
751
- <span>Present</span>
752
- </button>
966
+ </ToolbarAction>
967
+ <div ref={loopMenuRef} className="relative flex">
968
+ <button
969
+ onClick={togglePresentationMode}
970
+ className="inline-flex h-8 items-center justify-center gap-1.5 rounded-l-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"
971
+ disabled={isEditorOpen}
972
+ title="Present (F)"
973
+ >
974
+ <Play className="h-3.5 w-3.5 fill-current" />
975
+ <span>Present</span>
976
+ </button>
977
+ <button
978
+ disabled={isEditorOpen}
979
+ aria-label="Presentation options"
980
+ aria-expanded={loopMenuOpen}
981
+ aria-controls="presentation-options"
982
+ className="rounded-r-md border border-l-0 border-[#FF6B35]/50 bg-[#FF6B35]/15 px-2 text-[#FF6B35] hover:bg-[#FF6B35]/25"
983
+ onClick={() => setLoopMenuOpen(value => !value)}
984
+ >
985
+ <ChevronDown size={16} />
986
+ </button>
987
+ {loopMenuOpen && (
988
+ <div
989
+ id="presentation-options"
990
+ className="absolute right-0 top-10 w-52 rounded-lg border border-neutral-700 bg-neutral-950 p-1 text-sm text-white"
991
+ >
992
+ <button
993
+ className="w-full rounded px-3 py-2 text-left hover:bg-neutral-800"
994
+ onClick={() => void startLoop()}
995
+ >
996
+ Auto-loop
997
+ </button>
998
+ <button
999
+ className="w-full rounded px-3 py-2 text-left hover:bg-neutral-800"
1000
+ onClick={() => {
1001
+ setLoopMenuOpen(false)
1002
+ requestInspector("slide")
1003
+ }}
1004
+ >
1005
+ Configure loop…
1006
+ </button>
1007
+ </div>
1008
+ )}
1009
+ </div>
753
1010
  </div>
754
1011
 
755
1012
  {/* Slide View */}
@@ -812,6 +1069,11 @@ export function SlideDeck({
812
1069
  )}
813
1070
  >
814
1071
  <div className="relative aspect-video w-full overflow-hidden rounded bg-black">
1072
+ {loopDraft.slides[loopSlideKey(slideConfig, index)]?.excluded && (
1073
+ <span className="absolute right-1 top-1 z-10 rounded border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[9px] text-neutral-400">
1074
+ Skip in loop
1075
+ </span>
1076
+ )}
815
1077
  <GridThumbnail
816
1078
  slide={slideConfig}
817
1079
  index={index}
@@ -883,7 +1145,8 @@ export function SlideDeck({
883
1145
  aria-hidden={isThumbnailRailVisible || isResizingThumbnails}
884
1146
  inert={isThumbnailRailVisible || isResizingThumbnails}
885
1147
  className={cn(
886
- "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",
1148
+ "fixed 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",
1149
+ isNarrowViewport && !inspectorVisible ? "top-16" : "top-4",
887
1150
  isThumbnailRailVisible || isResizingThumbnails
888
1151
  ? "pointer-events-none opacity-0"
889
1152
  : "opacity-100 delay-200"
@@ -904,16 +1167,24 @@ export function SlideDeck({
904
1167
  <div
905
1168
  ref={containerRef}
906
1169
  role="presentation"
1170
+ data-loop-running={isLooping ? "true" : undefined}
907
1171
  tabIndex={isPresentationMode ? 0 : undefined}
908
1172
  className={cn(
909
1173
  "flex min-w-0 flex-1 flex-col items-center justify-center overflow-hidden",
910
- isPresentationMode ? "bg-black p-0" : "p-4 md:p-8"
1174
+ isPresentationMode ? `bg-black p-0 ${isLooping ? "cursor-none" : ""}` : "p-4 md:p-8"
911
1175
  )}
912
- onClick={isPresentationMode ? advance : undefined}
1176
+ onClick={
1177
+ isPresentationMode ? () => (isLooping ? playback.move(1) : advance()) : undefined
1178
+ }
913
1179
  onKeyDown={
914
1180
  isPresentationMode
915
1181
  ? e => {
916
- if (e.key === "Enter" || e.key === " ") advance()
1182
+ if (e.key === "Enter") {
1183
+ e.preventDefault()
1184
+ e.stopPropagation()
1185
+ if (isLooping) playback.move(1)
1186
+ else advance()
1187
+ }
917
1188
  }
918
1189
  : undefined
919
1190
  }
@@ -930,6 +1201,7 @@ export function SlideDeck({
930
1201
  }}
931
1202
  >
932
1203
  <SlideRenderer
1204
+ playbackKey={isLooping ? playback.epoch : undefined}
933
1205
  slides={slides}
934
1206
  currentSlide={currentSlide}
935
1207
  animationStep={animationStep}
@@ -978,7 +1250,7 @@ export function SlideDeck({
978
1250
  selectedId={selectedAnnotationId}
979
1251
  hoveredId={hoveredAnnotationId}
980
1252
  onSelectId={setSelectedAnnotationId}
981
- onShowPanel={() => setShowAnnotationPanel(true)}
1253
+ onShowPanel={() => requestInspector("comments")}
982
1254
  slideAnnotations={getSlideAnnotations(currentSlide)}
983
1255
  addAnnotation={effectiveAdd ?? (() => {})}
984
1256
  />
@@ -1018,62 +1290,42 @@ export function SlideDeck({
1018
1290
  )}
1019
1291
  </div>
1020
1292
 
1021
- <div
1022
- data-ps-editor-panel
1023
- data-open={isEditorOpen}
1024
- aria-hidden={!isEditorOpen}
1025
- inert={!isEditorOpen}
1026
- className="h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out motion-reduce:transition-none"
1027
- style={{ width: isEditorOpen ? editorPanelWidth : 0 }}
1293
+ <Inspector
1294
+ mode={inspectorMode}
1295
+ visible={inspectorVisible}
1296
+ editorAvailable={editorAvailable}
1297
+ width={inspectorWidth}
1298
+ overlay={isNarrowViewport}
1299
+ onSelect={requestInspector}
1300
+ onClose={() => requestInspector(null)}
1301
+ slideSettings={
1302
+ <LoopSettings
1303
+ slides={slides}
1304
+ currentSlide={currentSlide}
1305
+ animationStep={animationStep}
1306
+ config={loopDraft}
1307
+ onChange={setLoopDraft}
1308
+ onStepChange={goToStep}
1309
+ onSave={saveLoopConfig}
1310
+ onStart={config => void startLoop(config)}
1311
+ onRevert={() => setLoopDraft(loopConfig)}
1312
+ dirty={JSON.stringify(loopDraft) !== JSON.stringify(loopConfig)}
1313
+ />
1314
+ }
1315
+ comments={
1316
+ <AnnotationPanel
1317
+ embedded
1318
+ annotations={getSlideAnnotations(currentSlide)}
1319
+ unlinkedAnnotations={unlinkedAnnotations}
1320
+ selectedId={selectedAnnotationId}
1321
+ onSelect={setSelectedAnnotationId}
1322
+ onHover={setHoveredAnnotationId}
1323
+ onDelete={effectiveDelete ?? (() => {})}
1324
+ onUpdate={effectiveUpdate ?? (() => {})}
1325
+ onClose={() => requestInspector(null)}
1326
+ />
1327
+ }
1028
1328
  />
1029
-
1030
- {/* Annotation Panel — beside the slide */}
1031
- {!isPresentationMode && (
1032
- <div
1033
- aria-hidden={!isAnnotationMode || !showAnnotationPanel}
1034
- inert={!isAnnotationMode || !showAnnotationPanel}
1035
- className={cn(
1036
- "h-full shrink-0 overflow-hidden transition-[width,transform] duration-200 ease-out motion-reduce:transition-none",
1037
- isNarrowViewport && "fixed inset-y-0 right-0 z-50 shadow-xl",
1038
- isNarrowViewport &&
1039
- (!isAnnotationMode || !showAnnotationPanel) &&
1040
- "translate-x-full"
1041
- )}
1042
- style={{
1043
- width:
1044
- isAnnotationMode && showAnnotationPanel
1045
- ? isNarrowViewport
1046
- ? "min(20rem, 90vw)"
1047
- : "20rem"
1048
- : 0
1049
- }}
1050
- >
1051
- <div
1052
- className={cn(
1053
- "h-full transition-opacity motion-reduce:transition-none",
1054
- isAnnotationMode && showAnnotationPanel
1055
- ? "opacity-100 delay-100 duration-100"
1056
- : "opacity-0 duration-75"
1057
- )}
1058
- >
1059
- <AnnotationPanel
1060
- annotations={getSlideAnnotations(currentSlide)}
1061
- unlinkedAnnotations={unlinkedAnnotations}
1062
- selectedId={selectedAnnotationId}
1063
- onSelect={setSelectedAnnotationId}
1064
- onHover={setHoveredAnnotationId}
1065
- onDelete={effectiveDelete ?? (() => {})}
1066
- onUpdate={effectiveUpdate ?? (() => {})}
1067
- onClose={() => {
1068
- setIsAnnotationMode(false)
1069
- setShowAnnotationPanel(false)
1070
- setSelectedAnnotationId(null)
1071
- setHoveredAnnotationId(null)
1072
- }}
1073
- />
1074
- </div>
1075
- </div>
1076
- )}
1077
1329
  </div>
1078
1330
  )}
1079
1331