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.
@@ -0,0 +1,292 @@
1
+ import { Check, Download, Play, RotateCcw } from "lucide-react"
2
+ import { useId, useState } from "react"
3
+
4
+ import type { LoopConfig, SlideConfig } from "../types"
5
+
6
+ import {
7
+ includedSlides,
8
+ loopDuration,
9
+ loopSlideKey,
10
+ stateDuration,
11
+ validateLoopConfig
12
+ } from "./config.mjs"
13
+
14
+ const button =
15
+ "rounded-md border border-neutral-700 px-2.5 py-2 text-xs hover:bg-neutral-800 disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-orange-500"
16
+
17
+ function DurationInput({
18
+ value,
19
+ label,
20
+ onCommit,
21
+ onFocus,
22
+ id
23
+ }: {
24
+ value: number
25
+ label: string
26
+ onCommit: (value: number) => void
27
+ onFocus?: () => void
28
+ id?: string
29
+ }) {
30
+ return (
31
+ <span className="inline-flex w-20 shrink-0 items-center gap-1 rounded-md border border-neutral-700 bg-neutral-950 px-2.5 focus-within:border-orange-500 focus-within:outline-2 focus-within:outline-orange-500/30">
32
+ <input
33
+ key={value}
34
+ id={id}
35
+ aria-label={label}
36
+ className="min-w-0 w-full bg-transparent py-2 text-right text-sm tabular-nums text-white outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
37
+ style={{ colorScheme: "dark" }}
38
+ type="number"
39
+ min="0.1"
40
+ max="3600"
41
+ step="0.1"
42
+ defaultValue={value}
43
+ onFocus={onFocus}
44
+ onBlur={event => {
45
+ const next = event.target.valueAsNumber
46
+ if (Number.isFinite(next) && next >= 0.1 && next <= 3600) {
47
+ if (next !== value) onCommit(next)
48
+ } else event.target.value = String(value)
49
+ }}
50
+ />
51
+ <span aria-hidden="true" className="text-xs text-neutral-500">
52
+ s
53
+ </span>
54
+ </span>
55
+ )
56
+ }
57
+
58
+ /** Slide-specific settings follow the viewer's current slide and animation step. */
59
+ export function LoopSettings({
60
+ slides,
61
+ currentSlide,
62
+ animationStep,
63
+ config,
64
+ onChange,
65
+ onStepChange,
66
+ onSave,
67
+ onStart,
68
+ onRevert,
69
+ dirty
70
+ }: {
71
+ slides: SlideConfig[]
72
+ currentSlide: number
73
+ animationStep: number
74
+ config: LoopConfig
75
+ onChange: (config: LoopConfig) => void
76
+ onStepChange: (step: number) => void
77
+ onSave: (config: LoopConfig) => Promise<string>
78
+ onStart: (config: LoopConfig) => void
79
+ onRevert: () => void
80
+ dirty: boolean
81
+ }) {
82
+ const defaultDurationId = useId()
83
+ const [message, setMessage] = useState("")
84
+ const [saving, setSaving] = useState(false)
85
+ const slide = slides[currentSlide]
86
+ const key = slide ? loopSlideKey(slide, currentSlide) : ""
87
+ const settings = config.slides[key]
88
+ const changedSteps = settings?.steps !== undefined && settings.steps !== slide?.steps
89
+ const updateDuration = (step: number, value: number | null) => {
90
+ if (!slide) return
91
+ const dwell = Array.from({ length: slide.steps + 1 }, (_, i) => settings?.dwell?.[i] ?? null)
92
+ dwell[step] = value
93
+ onChange({
94
+ ...config,
95
+ slides: { ...config.slides, [key]: { ...settings, steps: slide.steps, dwell } }
96
+ })
97
+ }
98
+ const save = async () => {
99
+ setSaving(true)
100
+ try {
101
+ setMessage(await onSave(validateLoopConfig(config)))
102
+ } catch (error) {
103
+ setMessage(error instanceof Error ? error.message : "Could not save loop settings")
104
+ } finally {
105
+ setSaving(false)
106
+ }
107
+ }
108
+ const download = () => {
109
+ const url = URL.createObjectURL(
110
+ new Blob([JSON.stringify(config, null, 2) + "\n"], { type: "application/json" })
111
+ )
112
+ const a = document.createElement("a")
113
+ a.href = url
114
+ a.download = "loop-config.json"
115
+ a.click()
116
+ URL.revokeObjectURL(url)
117
+ setMessage(
118
+ "Place loop-config.json in the project folder to include these settings in builds and publishing."
119
+ )
120
+ }
121
+ return (
122
+ <section
123
+ aria-label="Slide settings"
124
+ className="flex h-full min-h-0 flex-col text-sm text-white"
125
+ >
126
+ <div className="min-h-0 flex-1 overflow-y-auto p-4">
127
+ {slide && (
128
+ <>
129
+ <p className="text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
130
+ Slide {currentSlide + 1}
131
+ </p>
132
+ <h2 className="mt-1 truncate text-base font-semibold">
133
+ {slide.title || slide.id || `Slide ${currentSlide + 1}`}
134
+ </h2>
135
+ <p className="mt-1 text-xs text-neutral-400">
136
+ Select slides in the thumbnail rail. Select a state below to preview it on the canvas.
137
+ </p>
138
+ <label className="mt-5 flex items-center justify-between gap-3 border-b border-neutral-800 pb-4">
139
+ Include in loop
140
+ <span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
141
+ <input
142
+ aria-label="Include current slide in loop"
143
+ className="peer h-5 w-5 cursor-pointer appearance-none rounded border border-neutral-600 bg-neutral-950 transition-colors checked:border-orange-500 checked:bg-orange-500 hover:border-neutral-400 checked:hover:border-orange-400 checked:hover:bg-orange-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-500"
144
+ type="checkbox"
145
+ checked={!settings?.excluded}
146
+ onChange={event =>
147
+ onChange({
148
+ ...config,
149
+ slides: {
150
+ ...config.slides,
151
+ [key]: { ...settings, excluded: !event.target.checked }
152
+ }
153
+ })
154
+ }
155
+ />
156
+ <Check
157
+ aria-hidden="true"
158
+ size={14}
159
+ strokeWidth={3}
160
+ className="pointer-events-none absolute text-white opacity-0 peer-checked:opacity-100"
161
+ />
162
+ </span>
163
+ </label>
164
+ <h3 className="mb-2 mt-4 text-xs font-semibold text-neutral-300">
165
+ State timings <span className="font-normal text-neutral-500">· seconds</span>
166
+ </h3>
167
+ {changedSteps && (
168
+ <p role="status" className="mb-3 text-xs text-amber-300">
169
+ This slide's step count changed. Review its timings; new states use the default.
170
+ </p>
171
+ )}
172
+ <div className="space-y-1.5" aria-label="Slide states">
173
+ {Array.from({ length: slide.steps + 1 }, (_, step) => {
174
+ const label = step === 0 ? "Initial view" : `Step ${step}`
175
+ const inherited = settings?.dwell?.[step] == null
176
+ return (
177
+ <div
178
+ key={`${key}:${step}`}
179
+ className={`flex items-center gap-1 rounded-md border p-1 ${animationStep === step ? "border-orange-500/50 bg-orange-500/5" : "border-transparent"}`}
180
+ >
181
+ <button
182
+ aria-label={label}
183
+ aria-pressed={animationStep === step}
184
+ className="min-w-0 flex-1 rounded px-1 py-1.5 text-left text-xs hover:bg-neutral-800"
185
+ onClick={() => onStepChange(step)}
186
+ >
187
+ {label}
188
+ <span className="mt-0.5 block text-[10px] text-neutral-500">
189
+ {inherited ? "Default duration" : "Custom duration"}
190
+ </span>
191
+ </button>
192
+ <DurationInput
193
+ value={stateDuration(config, slide, currentSlide, step)}
194
+ label={`${label} duration in seconds`}
195
+ onFocus={() => onStepChange(step)}
196
+ onCommit={value => updateDuration(step, value)}
197
+ />
198
+ <button
199
+ className="rounded p-1 text-neutral-500 hover:text-white disabled:opacity-30"
200
+ disabled={inherited}
201
+ aria-label={`Use default for ${label.toLowerCase()}`}
202
+ title="Use default duration"
203
+ onClick={() => updateDuration(step, null)}
204
+ >
205
+ <RotateCcw size={13} />
206
+ </button>
207
+ </div>
208
+ )
209
+ })}
210
+ </div>
211
+ <p className="mt-3 text-xs leading-relaxed text-neutral-500">
212
+ Durations include reveal animations. Allow enough time for videos and animations that
213
+ play by themselves.
214
+ </p>
215
+ <button
216
+ className={`${button} mt-3 w-full`}
217
+ onClick={() => {
218
+ const rest = { ...settings }
219
+ delete rest.dwell
220
+ delete rest.steps
221
+ onChange({ ...config, slides: { ...config.slides, [key]: rest } })
222
+ }}
223
+ >
224
+ Use default for this slide
225
+ </button>
226
+ </>
227
+ )}
228
+ <details className="mt-5 border-t border-neutral-800 pt-4" open>
229
+ <summary className="cursor-pointer text-xs font-semibold text-neutral-300">
230
+ Loop defaults
231
+ </summary>
232
+ <label
233
+ htmlFor={defaultDurationId}
234
+ className="mt-3 flex items-center justify-between gap-2 text-xs text-neutral-400"
235
+ >
236
+ Seconds per state
237
+ <DurationInput
238
+ id={defaultDurationId}
239
+ value={config.defaultDuration}
240
+ label="Default duration in seconds"
241
+ onCommit={value => onChange({ ...config, defaultDuration: value })}
242
+ />
243
+ </label>
244
+ <p className="mt-3 text-xs text-neutral-500">
245
+ {includedSlides(config, slides).length} slides ·{" "}
246
+ {loopDuration(config, slides).toFixed(1)}s + slide transitions
247
+ </p>
248
+ </details>
249
+ </div>
250
+ <footer className="shrink-0 space-y-2 border-t border-neutral-800 p-3">
251
+ <p role="status" className="text-xs text-neutral-400">
252
+ {dirty ? "Unsaved loop settings" : message || "Loop settings saved"}
253
+ </p>
254
+ <div className="flex gap-2">
255
+ <button
256
+ className={`${button} flex-1`}
257
+ disabled={!dirty || saving}
258
+ onClick={() => {
259
+ onRevert()
260
+ setMessage("")
261
+ }}
262
+ >
263
+ Revert
264
+ </button>
265
+ <button
266
+ className={`${button} flex-1 border-orange-500/50 text-orange-400`}
267
+ disabled={saving}
268
+ onClick={() => void save()}
269
+ >
270
+ {saving ? "Saving…" : "Save settings"}
271
+ </button>
272
+ </div>
273
+ <div className="flex gap-2">
274
+ <button
275
+ className={`${button} flex items-center justify-center gap-1.5`}
276
+ aria-label="Download loop settings"
277
+ onClick={download}
278
+ >
279
+ <Download size={14} />
280
+ </button>
281
+ <button
282
+ className={`${button} flex flex-1 items-center justify-center gap-1.5`}
283
+ disabled={!includedSlides(config, slides).length || saving}
284
+ onClick={() => onStart(validateLoopConfig(config))}
285
+ >
286
+ <Play size={14} /> Start loop
287
+ </button>
288
+ </div>
289
+ </footer>
290
+ </section>
291
+ )
292
+ }
@@ -0,0 +1,115 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+
3
+ import type { LoopConfig, SlideConfig } from "../types"
4
+
5
+ import { includedSlides, stateDuration } from "./config.mjs"
6
+
7
+ type Options = {
8
+ config: LoopConfig
9
+ slides: SlideConfig[]
10
+ currentSlide: number
11
+ animationStep: number
12
+ isTransitioning: boolean
13
+ enabled: boolean
14
+ advance: () => void
15
+ goBack: () => void
16
+ restartAtSlide: (index: number) => void
17
+ }
18
+
19
+ export function useLoopPlayback({
20
+ config,
21
+ slides,
22
+ currentSlide,
23
+ animationStep,
24
+ isTransitioning,
25
+ enabled,
26
+ advance,
27
+ goBack,
28
+ restartAtSlide
29
+ }: Options) {
30
+ const [paused, setPaused] = useState(false)
31
+ const [hidden, setHidden] = useState(() => typeof document !== "undefined" && document.hidden)
32
+ const [epoch, setEpoch] = useState(0)
33
+ const [revision, setRevision] = useState(0)
34
+ const remaining = useRef<number | undefined>(undefined)
35
+ const identity = useRef("")
36
+ const included = useMemo(() => includedSlides(config, slides), [config, slides])
37
+
38
+ useEffect(() => {
39
+ const onVisibility = () => setHidden(document.hidden)
40
+ document.addEventListener("visibilitychange", onVisibility)
41
+ return () => document.removeEventListener("visibilitychange", onVisibility)
42
+ }, [])
43
+
44
+ const restart = useCallback(
45
+ (index: number) => {
46
+ setEpoch(value => value + 1)
47
+ remaining.current = undefined
48
+ restartAtSlide(index)
49
+ },
50
+ [restartAtSlide]
51
+ )
52
+
53
+ const resetTimer = useCallback(() => {
54
+ remaining.current = undefined
55
+ setRevision(value => value + 1)
56
+ }, [])
57
+
58
+ const move = (direction: 1 | -1) => {
59
+ resetTimer()
60
+ if (isTransitioning || included.length === 0) return
61
+ if (direction === 1 && animationStep < (slides[currentSlide]?.steps ?? 0)) advance()
62
+ else if (direction === -1 && animationStep > 0) goBack()
63
+ else {
64
+ const position = included.indexOf(currentSlide)
65
+ restart(included[(position + direction + included.length) % included.length]!)
66
+ }
67
+ }
68
+
69
+ useEffect(() => {
70
+ if (!enabled) {
71
+ remaining.current = undefined
72
+ setPaused(false)
73
+ return
74
+ }
75
+ const key = `${currentSlide}:${animationStep}:${epoch}:${revision}`
76
+ if (identity.current !== key) {
77
+ identity.current = key
78
+ remaining.current = undefined
79
+ }
80
+ if (paused || hidden || isTransitioning || included.length === 0) return
81
+ const slide = slides[currentSlide]
82
+ if (!slide) return
83
+ const duration =
84
+ remaining.current ?? stateDuration(config, slide, currentSlide, animationStep) * 1000
85
+ const deadline = performance.now() + duration
86
+ const timer = window.setTimeout(() => {
87
+ remaining.current = undefined
88
+ if (animationStep < slide.steps) advance()
89
+ else {
90
+ const position = included.indexOf(currentSlide)
91
+ restart(included[(position + 1) % included.length]!)
92
+ }
93
+ }, duration)
94
+ return () => {
95
+ window.clearTimeout(timer)
96
+ remaining.current = Math.max(0, deadline - performance.now())
97
+ }
98
+ }, [
99
+ enabled,
100
+ paused,
101
+ hidden,
102
+ isTransitioning,
103
+ currentSlide,
104
+ animationStep,
105
+ epoch,
106
+ revision,
107
+ config,
108
+ slides,
109
+ advance,
110
+ restart,
111
+ included
112
+ ])
113
+
114
+ return { paused, setPaused, epoch, restart, move }
115
+ }