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.
- package/CHANGELOG.md +14 -0
- package/README.md +76 -0
- package/dist/index.d.ts +29 -4
- package/dist/index.js +1427 -359
- package/dist/index.js.map +1 -1
- package/package.json +31 -31
- 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/commands/studio.mjs +4 -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 +459 -125
- package/src/core/slide-renderer.tsx +5 -4
- 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 +45 -0
- package/src/editor/geometry.ts +76 -0
- package/src/editor/mount.ts +24 -0
- package/src/editor/plugin.mjs +189 -0
- package/src/editor/source.mjs +363 -0
- package/src/editor/studio.css +978 -0
- package/src/editor/studio.tsx +1964 -0
- package/src/utils/deck-config.mjs +6 -0
- package/src/utils/loop-config.mjs +70 -0
- package/src/vite/config.mjs +23 -2
- package/src/vite/plugin.mjs +21 -4
- 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,
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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 &&
|
|
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
|
-
|
|
924
|
+
isEditorOpen && viewportWidth <= 850 && "[&_button:disabled]:hidden [&>div]:hidden",
|
|
925
|
+
!inspectorVisible && "right-4"
|
|
618
926
|
)}
|
|
619
927
|
>
|
|
620
|
-
<
|
|
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
|
-
<
|
|
932
|
+
<ToolbarAction
|
|
654
933
|
onClick={toggleCommentMode}
|
|
655
|
-
|
|
656
|
-
|
|
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-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
959
|
+
<ToolbarAction
|
|
674
960
|
onClick={handleExportPdf}
|
|
675
|
-
|
|
961
|
+
disabled={isEditorOpen}
|
|
962
|
+
label="Download PDF"
|
|
676
963
|
title="Download PDF (D)"
|
|
677
964
|
>
|
|
678
965
|
<Download className="h-4 w-4" />
|
|
679
|
-
</
|
|
680
|
-
<
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
|
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 ?
|
|
1174
|
+
isPresentationMode ? `bg-black p-0 ${isLooping ? "cursor-none" : ""}` : "p-4 md:p-8"
|
|
846
1175
|
)}
|
|
847
|
-
onClick={
|
|
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"
|
|
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
|
-
|
|
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={() =>
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
|