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
|
@@ -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
|
+
}
|