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.
- package/CHANGELOG.md +7 -0
- package/README.md +76 -0
- package/dist/index.d.ts +29 -4
- package/dist/index.js +1336 -339
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/build.mjs +4 -1
- package/src/commands/clone.mjs +5 -2
- package/src/commands/create.mjs +45 -14
- package/src/commands/pull.mjs +6 -3
- package/src/core/annotations/annotation-panel.tsx +18 -12
- package/src/core/index.ts +11 -5
- package/src/core/inspector.tsx +155 -0
- package/src/core/loop/config.d.mts +13 -0
- package/src/core/loop/config.mjs +65 -0
- package/src/core/loop/context.tsx +20 -0
- package/src/core/loop/loop-settings.tsx +292 -0
- package/src/core/loop/use-loop-playback.ts +115 -0
- package/src/core/slide-deck.tsx +398 -146
- package/src/core/slide-renderer.tsx +7 -1
- package/src/core/toolbar-action.tsx +38 -0
- package/src/core/types.ts +14 -0
- package/src/core/use-slide-navigation.ts +27 -0
- package/src/core/view-menu.tsx +130 -0
- package/src/editor/README.md +11 -1
- package/src/editor/plugin.mjs +1 -1
- package/src/editor/studio.css +1 -27
- package/src/editor/studio.tsx +45 -29
- package/src/utils/deck-config.mjs +6 -0
- package/src/utils/loop-config.mjs +70 -0
- package/src/vite/plugin.mjs +18 -2
- package/templates/default/src/deck-config.ts +2 -2
package/src/core/slide-deck.tsx
CHANGED
|
@@ -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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
|
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
|
-
|
|
662
|
-
? { right:
|
|
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 &&
|
|
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
|
-
!
|
|
925
|
+
!inspectorVisible && "right-4"
|
|
672
926
|
)}
|
|
673
927
|
>
|
|
674
|
-
<
|
|
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
|
-
<
|
|
932
|
+
<ToolbarAction
|
|
711
933
|
onClick={toggleCommentMode}
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
744
|
-
<
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
|
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 ?
|
|
1174
|
+
isPresentationMode ? `bg-black p-0 ${isLooping ? "cursor-none" : ""}` : "p-4 md:p-8"
|
|
911
1175
|
)}
|
|
912
|
-
onClick={
|
|
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"
|
|
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={() =>
|
|
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
|
-
<
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
|