promptslide 0.3.17 → 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,
@@ -238,7 +270,108 @@ export function SlideDeck({
238
270
  )
239
271
 
240
272
  const [viewMode, setViewMode] = useState<ViewMode>("slide")
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)
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
+ )
309
+ useEffect(() => {
310
+ const onEditorState = (event: Event) => {
311
+ const open = Boolean((event as CustomEvent).detail?.open)
312
+ const changed = editorOpenRef.current !== open
313
+ editorOpenRef.current = open
314
+ setIsEditorOpen(open)
315
+ if (open) {
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)
322
+ }
323
+ }
324
+ const onEditorReady = (event: Event) =>
325
+ setEditorAvailable(Boolean((event as CustomEvent).detail?.available))
326
+ const onCloseCancelled = () => {
327
+ pendingInspector.current = undefined
328
+ }
329
+ const refreshEditor = () => setEditorRevision(value => value + 1)
330
+ window.addEventListener("promptslide:editor-state", onEditorState)
331
+ window.addEventListener("promptslide:editor-ready", onEditorReady)
332
+ window.addEventListener("promptslide:editor-close-cancelled", onCloseCancelled)
333
+ window.addEventListener("promptslide:editor-refresh", refreshEditor)
334
+ return () => {
335
+ window.removeEventListener("promptslide:editor-state", onEditorState)
336
+ window.removeEventListener("promptslide:editor-ready", onEditorReady)
337
+ window.removeEventListener("promptslide:editor-close-cancelled", onCloseCancelled)
338
+ window.removeEventListener("promptslide:editor-refresh", refreshEditor)
339
+ }
340
+ }, [activateInspector])
241
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])
242
375
  const [viewportWidth, setViewportWidth] = useState(() =>
243
376
  typeof window === "undefined" ? 1280 : window.innerWidth
244
377
  )
@@ -252,8 +385,7 @@ export function SlideDeck({
252
385
  ? Math.min(savedWidth, MAX_THUMBNAIL_WIDTH)
253
386
  : DEFAULT_THUMBNAIL_WIDTH
254
387
  })
255
- const [isAnnotationMode, setIsAnnotationMode] = useState(false)
256
- const [showAnnotationPanel, setShowAnnotationPanel] = useState(false)
388
+ const isAnnotationMode = inspectorMode === "comments"
257
389
  const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null)
258
390
  const [hoveredAnnotationId, setHoveredAnnotationId] = useState<string | null>(null)
259
391
  const [isResizingThumbnails, setIsResizingThumbnails] = useState(false)
@@ -298,8 +430,7 @@ export function SlideDeck({
298
430
  if (isNarrowViewport !== wasNarrowViewportRef.current) {
299
431
  setShowThumbnailRail(isNarrowViewport ? false : preferredRailOpenRef.current)
300
432
  if (isNarrowViewport) {
301
- setIsAnnotationMode(false)
302
- setShowAnnotationPanel(false)
433
+ setInspectorMode(mode => (mode === "comments" ? null : mode))
303
434
  setSelectedAnnotationId(null)
304
435
  }
305
436
  wasNarrowViewportRef.current = isNarrowViewport
@@ -395,16 +526,97 @@ export function SlideDeck({
395
526
  currentSlide,
396
527
  animationStep,
397
528
  totalSteps,
529
+ isTransitioning,
398
530
  direction,
399
531
  showAllAnimations,
400
532
  advance,
401
533
  goBack,
402
534
  goToSlide,
535
+ goToStep,
536
+ restartAtSlide,
403
537
  onTransitionComplete
404
538
  } = useSlideNavigation({
405
539
  slides
406
540
  })
407
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
+
408
620
  const togglePresentationMode = useCallback(async () => {
409
621
  if (!document.fullscreenElement) {
410
622
  await document.documentElement.requestFullscreen()
@@ -417,6 +629,7 @@ export function SlideDeck({
417
629
  useEffect(() => {
418
630
  const handleFullscreenChange = () => {
419
631
  setIsPresentationMode(!!document.fullscreenElement)
632
+ if (!document.fullscreenElement) setIsLooping(false)
420
633
  }
421
634
  document.addEventListener("fullscreenchange", handleFullscreenChange)
422
635
  return () => document.removeEventListener("fullscreenchange", handleFullscreenChange)
@@ -466,14 +679,8 @@ export function SlideDeck({
466
679
  }, [viewMode, isPresentationMode])
467
680
 
468
681
  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])
682
+ requestInspector(isAnnotationMode && viewMode === "slide" ? null : "comments")
683
+ }, [isAnnotationMode, viewMode, requestInspector])
477
684
 
478
685
  const handleExportPdf = useCallback(async () => {
479
686
  try {
@@ -512,6 +719,29 @@ export function SlideDeck({
512
719
  if (e.altKey || e.ctrlKey || e.metaKey) return
513
720
  const key = e.key.toLowerCase()
514
721
  if (e.repeat && "fvglcdt".includes(key)) return
722
+ if (isEditorOpen && ["f", "v", "g", "l", "c", "d"].includes(key)) return
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
+ }
515
745
 
516
746
  if (key === "f") {
517
747
  e.preventDefault()
@@ -571,9 +801,14 @@ export function SlideDeck({
571
801
  }, [
572
802
  advance,
573
803
  goBack,
804
+ playback,
805
+ isLooping,
806
+ loopMenuOpen,
807
+ loopStartScreen,
574
808
  viewMode,
575
809
  togglePresentationMode,
576
810
  isAnnotationMode,
811
+ isEditorOpen,
577
812
  isPresentationMode,
578
813
  setThumbnailRailOpen,
579
814
  showThumbnailRail,
@@ -581,9 +816,34 @@ export function SlideDeck({
581
816
  handleExportPdf
582
817
  ])
583
818
 
819
+ const inspectorWidth = viewportWidth <= 850 ? 280 : 320
820
+ const inspectorVisible = inspectorMode !== null && !isPresentationMode && viewMode === "slide"
821
+
584
822
  return (
585
823
  <div className="min-h-screen w-full bg-neutral-950 text-foreground">
586
824
  <style>{`
825
+ .slide-thumbnail-scroll-area {
826
+ scrollbar-width: thin;
827
+ scrollbar-color: #404040 transparent;
828
+ }
829
+ .slide-thumbnail-scroll-area::-webkit-scrollbar {
830
+ width: 10px;
831
+ }
832
+ .slide-thumbnail-scroll-area::-webkit-scrollbar-track {
833
+ background: transparent;
834
+ }
835
+ .slide-thumbnail-scroll-area::-webkit-scrollbar-thumb {
836
+ background-color: #404040;
837
+ border: 3px solid transparent;
838
+ border-radius: 9999px;
839
+ background-clip: padding-box;
840
+ }
841
+ .slide-thumbnail-scroll-area::-webkit-scrollbar-thumb:hover {
842
+ background-color: #525252;
843
+ }
844
+ .slide-thumbnail-scroll-area::-webkit-scrollbar-corner {
845
+ background: transparent;
846
+ }
587
847
  @media print {
588
848
  .list-slide-canvas {
589
849
  transform: scale(1.5) !important;
@@ -608,83 +868,145 @@ export function SlideDeck({
608
868
  }
609
869
  `}</style>
610
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
+
611
912
  {/* Toolbar */}
612
913
  <div
914
+ data-ps-viewer-toolbar
915
+ style={
916
+ inspectorVisible
917
+ ? { right: inspectorWidth + 16, maxWidth: `calc(100vw - ${inspectorWidth + 32}px)` }
918
+ : undefined
919
+ }
613
920
  className={cn(
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)) &&
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)) &&
616
923
  "hidden",
617
- isAnnotationMode && showAnnotationPanel ? "right-[21rem]" : "right-4"
924
+ isEditorOpen && viewportWidth <= 850 && "[&_button:disabled]:hidden [&>div]:hidden",
925
+ !inspectorVisible && "right-4"
618
926
  )}
619
927
  >
620
- <button
621
- onClick={() => setViewMode("slide")}
622
- className={cn(
623
- "rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white",
624
- viewMode === "slide" && "bg-neutral-800 text-white"
625
- )}
626
- title="Presentation View (V)"
627
- >
628
- <Monitor className="h-4 w-4" />
629
- </button>
630
- <button
631
- onClick={() => setViewMode("list")}
632
- className={cn(
633
- "rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white",
634
- viewMode === "list" && "bg-neutral-800 text-white"
635
- )}
636
- title="List View (L)"
637
- >
638
- <List className="h-4 w-4" />
639
- </button>
640
- <button
641
- onClick={() => setViewMode("grid")}
642
- className={cn(
643
- "rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white",
644
- viewMode === "grid" && "bg-neutral-800 text-white"
645
- )}
646
- title="Grid View (G)"
647
- >
648
- <Grid3X3 className="h-4 w-4" />
649
- </button>
928
+ <ViewMenu value={viewMode} onChange={setViewMode} disabled={isEditorOpen} />
650
929
 
651
930
  <div className="mx-1 w-px bg-neutral-800" />
652
931
 
653
- <button
932
+ <ToolbarAction
654
933
  onClick={toggleCommentMode}
655
- className={cn(
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"
660
- )}
934
+ label="Comment"
935
+ selected={isAnnotationMode}
661
936
  title="Comment (C)"
662
937
  >
663
- <MessageCircle className="h-4 w-4" />
938
+ <MessageCircle className="h-[15px] w-[15px] shrink-0" />
664
939
  {openCount > 0 && (
665
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">
666
941
  {openCount}
667
942
  </span>
668
943
  )}
669
- </button>
944
+ </ToolbarAction>
945
+
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>
670
956
 
671
957
  <div className="mx-1 w-px bg-neutral-800" />
672
958
 
673
- <button
959
+ <ToolbarAction
674
960
  onClick={handleExportPdf}
675
- className="rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white"
961
+ disabled={isEditorOpen}
962
+ label="Download PDF"
676
963
  title="Download PDF (D)"
677
964
  >
678
965
  <Download className="h-4 w-4" />
679
- </button>
680
- <button
681
- onClick={togglePresentationMode}
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"
683
- title="Present (F)"
684
- >
685
- <Play className="h-3.5 w-3.5 fill-current" />
686
- <span>Present</span>
687
- </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>
688
1010
  </div>
689
1011
 
690
1012
  {/* Slide View */}
@@ -730,7 +1052,7 @@ export function SlideDeck({
730
1052
  <PanelLeftClose className="h-4 w-4" />
731
1053
  </button>
732
1054
  </div>
733
- <div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
1055
+ <div className="slide-thumbnail-scroll-area min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
734
1056
  {slides.map((slideConfig, index) => (
735
1057
  <button
736
1058
  key={slideConfig.id ?? index}
@@ -747,6 +1069,11 @@ export function SlideDeck({
747
1069
  )}
748
1070
  >
749
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
+ )}
750
1077
  <GridThumbnail
751
1078
  slide={slideConfig}
752
1079
  index={index}
@@ -818,7 +1145,8 @@ export function SlideDeck({
818
1145
  aria-hidden={isThumbnailRailVisible || isResizingThumbnails}
819
1146
  inert={isThumbnailRailVisible || isResizingThumbnails}
820
1147
  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",
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",
822
1150
  isThumbnailRailVisible || isResizingThumbnails
823
1151
  ? "pointer-events-none opacity-0"
824
1152
  : "opacity-100 delay-200"
@@ -839,16 +1167,24 @@ export function SlideDeck({
839
1167
  <div
840
1168
  ref={containerRef}
841
1169
  role="presentation"
1170
+ data-loop-running={isLooping ? "true" : undefined}
842
1171
  tabIndex={isPresentationMode ? 0 : undefined}
843
1172
  className={cn(
844
1173
  "flex min-w-0 flex-1 flex-col items-center justify-center overflow-hidden",
845
- isPresentationMode ? "bg-black p-0" : "p-4 md:p-8"
1174
+ isPresentationMode ? `bg-black p-0 ${isLooping ? "cursor-none" : ""}` : "p-4 md:p-8"
846
1175
  )}
847
- onClick={isPresentationMode ? advance : undefined}
1176
+ onClick={
1177
+ isPresentationMode ? () => (isLooping ? playback.move(1) : advance()) : undefined
1178
+ }
848
1179
  onKeyDown={
849
1180
  isPresentationMode
850
1181
  ? e => {
851
- 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
+ }
852
1188
  }
853
1189
  : undefined
854
1190
  }
@@ -865,6 +1201,7 @@ export function SlideDeck({
865
1201
  }}
866
1202
  >
867
1203
  <SlideRenderer
1204
+ playbackKey={isLooping ? playback.epoch : undefined}
868
1205
  slides={slides}
869
1206
  currentSlide={currentSlide}
870
1207
  animationStep={animationStep}
@@ -883,7 +1220,10 @@ export function SlideDeck({
883
1220
  >
884
1221
  <div
885
1222
  ref={slideContainerRef}
886
- className="absolute top-0 left-0 overflow-hidden"
1223
+ data-ps-editor-canvas
1224
+ tabIndex={-1}
1225
+ data-slide-index={currentSlide}
1226
+ className="absolute top-0 left-0 overflow-hidden outline-none"
887
1227
  style={{
888
1228
  width: SLIDE_DIMENSIONS.width,
889
1229
  height: SLIDE_DIMENSIONS.height,
@@ -891,6 +1231,7 @@ export function SlideDeck({
891
1231
  }}
892
1232
  >
893
1233
  <SlideRenderer
1234
+ key={editorRevision}
894
1235
  slides={slides}
895
1236
  currentSlide={currentSlide}
896
1237
  animationStep={animationStep}
@@ -909,12 +1250,16 @@ export function SlideDeck({
909
1250
  selectedId={selectedAnnotationId}
910
1251
  hoveredId={hoveredAnnotationId}
911
1252
  onSelectId={setSelectedAnnotationId}
912
- onShowPanel={() => setShowAnnotationPanel(true)}
1253
+ onShowPanel={() => requestInspector("comments")}
913
1254
  slideAnnotations={getSlideAnnotations(currentSlide)}
914
1255
  addAnnotation={effectiveAdd ?? (() => {})}
915
1256
  />
916
1257
  )}
917
1258
  </div>
1259
+ <div
1260
+ data-ps-editor-overlay
1261
+ className="pointer-events-none absolute inset-0 z-20"
1262
+ />
918
1263
  </div>
919
1264
  )}
920
1265
  </LayoutGroup>
@@ -945,53 +1290,42 @@ export function SlideDeck({
945
1290
  )}
946
1291
  </div>
947
1292
 
948
- {/* Annotation Panel — beside the slide */}
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
967
- }}
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>
994
- )}
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
+ }
1328
+ />
995
1329
  </div>
996
1330
  )}
997
1331