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/package.json
CHANGED
package/src/commands/build.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { resolve, join } from "node:path"
|
|
|
4
4
|
import { build as viteBuild } from "vite"
|
|
5
5
|
|
|
6
6
|
import { bold, green, dim } from "../utils/ansi.mjs"
|
|
7
|
+
import { readLoopConfig, DEFAULT_LOOP_CONFIG } from "../utils/loop-config.mjs"
|
|
7
8
|
import { ensureTsConfig } from "../utils/tsconfig.mjs"
|
|
8
9
|
import { createViteConfig } from "../vite/config.mjs"
|
|
9
10
|
|
|
@@ -36,9 +37,11 @@ import { StrictMode, createElement } from "react"
|
|
|
36
37
|
import { createRoot } from "react-dom/client"
|
|
37
38
|
import "${globals}"
|
|
38
39
|
import App from "${app}"
|
|
40
|
+
import { LoopConfigProvider } from "promptslide"
|
|
41
|
+
const loopConfig = ${JSON.stringify(readLoopConfig(cwd) ?? DEFAULT_LOOP_CONFIG)}
|
|
39
42
|
|
|
40
43
|
createRoot(document.getElementById("root")).render(
|
|
41
|
-
createElement(StrictMode, null, createElement(App))
|
|
44
|
+
createElement(StrictMode, null, createElement(LoopConfigProvider, { config: loopConfig }, createElement(App)))
|
|
42
45
|
)
|
|
43
46
|
`
|
|
44
47
|
}
|
package/src/commands/clone.mjs
CHANGED
|
@@ -179,7 +179,9 @@ export async function clone(args) {
|
|
|
179
179
|
const registrySlideSlugs = collectRegistrySlideSlugs(resolved.items)
|
|
180
180
|
const { available, missing } = partitionDeckSlides(item.meta.slides, registrySlideSlugs)
|
|
181
181
|
if (missing.length > 0) {
|
|
182
|
-
console.log(
|
|
182
|
+
console.log(
|
|
183
|
+
` ${yellow("!")} Skipped ${missing.length} slide(s) listed in the deck but not included in this version:`
|
|
184
|
+
)
|
|
183
185
|
console.log(` ${dim(missing.map(s => s.slug).join(", "))}`)
|
|
184
186
|
}
|
|
185
187
|
const slides = available.map(s => ({
|
|
@@ -191,7 +193,8 @@ export async function clone(args) {
|
|
|
191
193
|
}))
|
|
192
194
|
replaceDeckConfig(targetDir, slides, {
|
|
193
195
|
transition: item.meta.transition,
|
|
194
|
-
directionalTransition: item.meta.directionalTransition
|
|
196
|
+
directionalTransition: item.meta.directionalTransition,
|
|
197
|
+
loopConfig: item.meta.loopConfig
|
|
195
198
|
})
|
|
196
199
|
console.log(
|
|
197
200
|
` ${green("✓")} Generated ${cyan("deck-config.ts")} ${dim(`(${slides.length} slides)`)}`
|
package/src/commands/create.mjs
CHANGED
|
@@ -6,10 +6,14 @@ import { fileURLToPath } from "node:url"
|
|
|
6
6
|
import { bold, green, cyan, red, dim, yellow } from "../utils/ansi.mjs"
|
|
7
7
|
import { requireAuth } from "../utils/auth.mjs"
|
|
8
8
|
import { hexToOklch, isValidHex } from "../utils/colors.mjs"
|
|
9
|
-
import { prompt, confirm, closePrompts } from "../utils/prompts.mjs"
|
|
10
|
-
import { fetchRegistryItem, resolveRegistryDependencies, writeLockfile } from "../utils/registry.mjs"
|
|
11
9
|
import { toPascalCase, replaceDeckConfig } from "../utils/deck-config.mjs"
|
|
12
10
|
import { collectRegistrySlideSlugs, partitionDeckSlides } from "../utils/deck-sync.mjs"
|
|
11
|
+
import { prompt, confirm, closePrompts } from "../utils/prompts.mjs"
|
|
12
|
+
import {
|
|
13
|
+
fetchRegistryItem,
|
|
14
|
+
resolveRegistryDependencies,
|
|
15
|
+
writeLockfile
|
|
16
|
+
} from "../utils/registry.mjs"
|
|
13
17
|
import { ensureTsConfig } from "../utils/tsconfig.mjs"
|
|
14
18
|
|
|
15
19
|
const __filename = fileURLToPath(import.meta.url)
|
|
@@ -43,13 +47,17 @@ export async function create(args) {
|
|
|
43
47
|
const useDefaults = args.includes("--yes") || args.includes("-y")
|
|
44
48
|
const fromIdx = args.findIndex(a => a === "--from")
|
|
45
49
|
const fromSlug = fromIdx >= 0 ? args[fromIdx + 1] : null
|
|
46
|
-
const filteredArgs = args.filter(
|
|
50
|
+
const filteredArgs = args.filter(
|
|
51
|
+
(a, i) => a !== "--yes" && a !== "-y" && a !== "--from" && !(fromIdx >= 0 && i === fromIdx + 1)
|
|
52
|
+
)
|
|
47
53
|
|
|
48
54
|
// 1. Parse directory name from args or prompt
|
|
49
55
|
let dirName = filteredArgs[0]
|
|
50
56
|
|
|
51
57
|
if (dirName === "--help" || dirName === "-h") {
|
|
52
|
-
console.log(
|
|
58
|
+
console.log(
|
|
59
|
+
` ${bold("Usage:")} promptslide create ${dim("[project-directory]")} ${dim("[options]")}`
|
|
60
|
+
)
|
|
53
61
|
console.log()
|
|
54
62
|
console.log(` Scaffolds a new PromptSlide slide deck project.`)
|
|
55
63
|
console.log()
|
|
@@ -80,7 +88,9 @@ export async function create(args) {
|
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
if (fromItem.type !== "deck") {
|
|
83
|
-
console.error(
|
|
91
|
+
console.error(
|
|
92
|
+
` ${red("Error:")} "${fromSlug}" is a ${fromItem.type}, not a deck. Use --from with a published deck.`
|
|
93
|
+
)
|
|
84
94
|
closePrompts()
|
|
85
95
|
process.exit(1)
|
|
86
96
|
}
|
|
@@ -93,8 +103,12 @@ export async function create(args) {
|
|
|
93
103
|
const localParts = CLI_VERSION.match(/^(\d+)\.(\d+)/)
|
|
94
104
|
if (pubParts && localParts && pubParts[2] !== localParts[2]) {
|
|
95
105
|
console.log()
|
|
96
|
-
console.log(
|
|
97
|
-
|
|
106
|
+
console.log(
|
|
107
|
+
` ${yellow("⚠")} This deck was published with promptslide ${bold(`v${fromItem.promptslideVersion}`)}`
|
|
108
|
+
)
|
|
109
|
+
console.log(
|
|
110
|
+
` You have ${bold(`v${CLI_VERSION}`)} installed — some slides may need updating.`
|
|
111
|
+
)
|
|
98
112
|
}
|
|
99
113
|
}
|
|
100
114
|
|
|
@@ -158,7 +172,11 @@ export async function create(args) {
|
|
|
158
172
|
const replacements = [
|
|
159
173
|
{
|
|
160
174
|
path: join(targetDir, "package.json"),
|
|
161
|
-
values: {
|
|
175
|
+
values: {
|
|
176
|
+
"{{PROJECT_SLUG}}": dirName,
|
|
177
|
+
"{{PROJECT_NAME}}": projectName,
|
|
178
|
+
"{{PROMPTSLIDE_VERSION}}": `^${CLI_VERSION}`
|
|
179
|
+
}
|
|
162
180
|
},
|
|
163
181
|
{
|
|
164
182
|
path: join(targetDir, "src", "theme.ts"),
|
|
@@ -212,7 +230,10 @@ export async function create(args) {
|
|
|
212
230
|
|
|
213
231
|
const dataUriPrefix = file.content.match(/^data:[^;]+;base64,/)
|
|
214
232
|
if (dataUriPrefix) {
|
|
215
|
-
writeFileSync(
|
|
233
|
+
writeFileSync(
|
|
234
|
+
targetPath,
|
|
235
|
+
Buffer.from(file.content.slice(dataUriPrefix[0].length), "base64")
|
|
236
|
+
)
|
|
216
237
|
} else {
|
|
217
238
|
writeFileSync(targetPath, file.content, "utf-8")
|
|
218
239
|
}
|
|
@@ -225,20 +246,26 @@ export async function create(args) {
|
|
|
225
246
|
const registrySlideSlugs = collectRegistrySlideSlugs(resolved.items)
|
|
226
247
|
const { available, missing } = partitionDeckSlides(fromItem.meta.slides, registrySlideSlugs)
|
|
227
248
|
if (missing.length > 0) {
|
|
228
|
-
console.log(
|
|
249
|
+
console.log(
|
|
250
|
+
` ${yellow("⚠")} Skipped ${missing.length} slide(s) listed in the deck but not included in this version:`
|
|
251
|
+
)
|
|
229
252
|
console.log(` ${dim(missing.map(s => s.slug).join(", "))}`)
|
|
230
253
|
}
|
|
231
254
|
const slides = available.map(s => ({
|
|
232
255
|
componentName: s.componentName || toPascalCase(s.slug),
|
|
233
256
|
importPath: `@/slides/${s.slug}`,
|
|
257
|
+
id: s.id,
|
|
234
258
|
steps: s.steps,
|
|
235
259
|
section: s.section
|
|
236
260
|
}))
|
|
237
261
|
replaceDeckConfig(targetDir, slides, {
|
|
238
262
|
transition: fromItem.meta.transition,
|
|
239
|
-
directionalTransition: fromItem.meta.directionalTransition
|
|
263
|
+
directionalTransition: fromItem.meta.directionalTransition,
|
|
264
|
+
loopConfig: fromItem.meta.loopConfig
|
|
240
265
|
})
|
|
241
|
-
console.log(
|
|
266
|
+
console.log(
|
|
267
|
+
` ${green("✓")} Generated ${cyan("deck-config.ts")} ${dim(`(${slides.length} slides)`)}`
|
|
268
|
+
)
|
|
242
269
|
}
|
|
243
270
|
|
|
244
271
|
// Add npm dependencies from the deck to package.json
|
|
@@ -255,7 +282,9 @@ export async function create(args) {
|
|
|
255
282
|
}
|
|
256
283
|
|
|
257
284
|
// New deck identity — deckSlug stays as dirName (set in initial lockfile scaffold)
|
|
258
|
-
console.log(
|
|
285
|
+
console.log(
|
|
286
|
+
` ${dim("Deck slug set to")} ${bold(dirName)} ${dim("(publish will create a new deck)")}`
|
|
287
|
+
)
|
|
259
288
|
|
|
260
289
|
console.log()
|
|
261
290
|
}
|
|
@@ -265,7 +294,9 @@ export async function create(args) {
|
|
|
265
294
|
|
|
266
295
|
// 9. Install PromptSlide agent skill (defaults to yes; skipped with --yes since skills CLI is interactive)
|
|
267
296
|
if (useDefaults) {
|
|
268
|
-
console.log(
|
|
297
|
+
console.log(
|
|
298
|
+
` ${dim("Tip: Run")} npx skills add prompticeu/promptslide ${dim("to install the agent skill")}`
|
|
299
|
+
)
|
|
269
300
|
} else {
|
|
270
301
|
const installSkill = await confirm("Install PromptSlide agent skill?")
|
|
271
302
|
|
package/src/commands/pull.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { join, dirname, resolve, sep } from "node:path"
|
|
|
5
5
|
import { bold, green, cyan, red, yellow, dim } from "../utils/ansi.mjs"
|
|
6
6
|
import { requireAuth } from "../utils/auth.mjs"
|
|
7
7
|
import { toPascalCase, addSlideToDeckConfig, replaceDeckConfig } from "../utils/deck-config.mjs"
|
|
8
|
+
import { collectRegistrySlideSlugs, partitionDeckSlides } from "../utils/deck-sync.mjs"
|
|
8
9
|
import { confirm, closePrompts } from "../utils/prompts.mjs"
|
|
9
10
|
import {
|
|
10
11
|
fetchRegistryItem,
|
|
@@ -17,7 +18,6 @@ import {
|
|
|
17
18
|
hashFile,
|
|
18
19
|
isFileDirty
|
|
19
20
|
} from "../utils/registry.mjs"
|
|
20
|
-
import { collectRegistrySlideSlugs, partitionDeckSlides } from "../utils/deck-sync.mjs"
|
|
21
21
|
|
|
22
22
|
export async function pull(args) {
|
|
23
23
|
const cwd = process.cwd()
|
|
@@ -184,7 +184,9 @@ export async function pull(args) {
|
|
|
184
184
|
const registrySlideSlugs = collectRegistrySlideSlugs(resolved.items)
|
|
185
185
|
const { available, missing } = partitionDeckSlides(deckItem.meta.slides, registrySlideSlugs)
|
|
186
186
|
if (missing.length > 0) {
|
|
187
|
-
console.log(
|
|
187
|
+
console.log(
|
|
188
|
+
` ${yellow("!")} Skipped ${missing.length} slide(s) listed in the deck but not included in this version:`
|
|
189
|
+
)
|
|
188
190
|
console.log(` ${dim(missing.map(s => s.slug).join(", "))}`)
|
|
189
191
|
}
|
|
190
192
|
const shouldReplace = await confirm(" Replace deck-config.ts with pulled deck config?", true)
|
|
@@ -198,7 +200,8 @@ export async function pull(args) {
|
|
|
198
200
|
}))
|
|
199
201
|
replaceDeckConfig(cwd, slides, {
|
|
200
202
|
transition: deckItem.meta.transition,
|
|
201
|
-
directionalTransition: deckItem.meta.directionalTransition
|
|
203
|
+
directionalTransition: deckItem.meta.directionalTransition,
|
|
204
|
+
loopConfig: deckItem.meta.loopConfig
|
|
202
205
|
})
|
|
203
206
|
console.log(
|
|
204
207
|
` ${green("+")} Replaced ${cyan("deck-config.ts")} ${dim(`(${slides.length} slides)`)}`
|
|
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"
|
|
|
4
4
|
import type { Annotation } from "./types"
|
|
5
5
|
|
|
6
6
|
interface AnnotationPanelProps {
|
|
7
|
+
embedded?: boolean
|
|
7
8
|
annotations: Annotation[]
|
|
8
9
|
unlinkedAnnotations?: Annotation[]
|
|
9
10
|
selectedId: string | null
|
|
@@ -15,6 +16,7 @@ interface AnnotationPanelProps {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export function AnnotationPanel({
|
|
19
|
+
embedded = false,
|
|
18
20
|
annotations,
|
|
19
21
|
unlinkedAnnotations = [],
|
|
20
22
|
selectedId,
|
|
@@ -31,18 +33,22 @@ export function AnnotationPanel({
|
|
|
31
33
|
annotations.findIndex(a => a.id === annotation.id) + 1
|
|
32
34
|
|
|
33
35
|
return (
|
|
34
|
-
<div
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
36
|
+
<div
|
|
37
|
+
className={`flex h-full max-w-[90vw] flex-shrink-0 flex-col bg-neutral-950 text-white ${embedded ? "w-full" : "w-80 border-l border-neutral-800"}`}
|
|
38
|
+
>
|
|
39
|
+
{!embedded && (
|
|
40
|
+
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
|
|
41
|
+
<button
|
|
42
|
+
onClick={onClose}
|
|
43
|
+
className="rounded p-2 text-neutral-400 hover:bg-neutral-800 hover:text-white"
|
|
44
|
+
aria-label="Hide comments"
|
|
45
|
+
title="Hide comments (C)"
|
|
46
|
+
>
|
|
47
|
+
<PanelRightClose className="h-4 w-4" />
|
|
48
|
+
</button>
|
|
49
|
+
<span className="text-right text-sm font-semibold">Comments ({total})</span>
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
46
52
|
|
|
47
53
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
48
54
|
{annotations.length === 0 && unlinkedAnnotations.length === 0 && (
|
package/src/core/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ export type {
|
|
|
3
3
|
SlideProps,
|
|
4
4
|
SlideComponent,
|
|
5
5
|
SlideConfig,
|
|
6
|
+
LoopConfig,
|
|
7
|
+
LoopSlideConfig,
|
|
6
8
|
NavigationDirection
|
|
7
9
|
} from "./types"
|
|
8
10
|
|
|
@@ -51,10 +53,7 @@ export { Morph, MorphGroup, MorphItem, MorphText } from "./morph"
|
|
|
51
53
|
|
|
52
54
|
// Navigation Hook
|
|
53
55
|
export { useSlideNavigation } from "./use-slide-navigation"
|
|
54
|
-
export type {
|
|
55
|
-
UseSlideNavigationOptions,
|
|
56
|
-
UseSlideNavigationReturn
|
|
57
|
-
} from "./use-slide-navigation"
|
|
56
|
+
export type { UseSlideNavigationOptions, UseSlideNavigationReturn } from "./use-slide-navigation"
|
|
58
57
|
|
|
59
58
|
// Theme
|
|
60
59
|
export { SlideThemeProvider, useTheme } from "./theme-context"
|
|
@@ -75,7 +74,14 @@ export { SlideErrorBoundary } from "./slide-error-boundary"
|
|
|
75
74
|
|
|
76
75
|
// Annotations
|
|
77
76
|
export { AnnotationOverlay, useAnnotations, createHttpAdapter } from "./annotations"
|
|
78
|
-
export type {
|
|
77
|
+
export type {
|
|
78
|
+
Annotation,
|
|
79
|
+
AnnotationsFile,
|
|
80
|
+
AnnotationStorageAdapter,
|
|
81
|
+
AnnotationTarget
|
|
82
|
+
} from "./annotations"
|
|
79
83
|
|
|
80
84
|
// Utils
|
|
81
85
|
export { cn } from "./utils"
|
|
86
|
+
|
|
87
|
+
export { LoopConfigProvider } from "./loop/context"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import { X } from "lucide-react"
|
|
4
|
+
import { useId } from "react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "./utils"
|
|
7
|
+
|
|
8
|
+
export type InspectorMode = "slide" | "editor" | "comments"
|
|
9
|
+
|
|
10
|
+
/** Core-owned docking and navigation; development tools portal into the editor slot. */
|
|
11
|
+
export function Inspector({
|
|
12
|
+
mode,
|
|
13
|
+
visible,
|
|
14
|
+
editorAvailable,
|
|
15
|
+
width,
|
|
16
|
+
overlay,
|
|
17
|
+
onSelect,
|
|
18
|
+
onClose,
|
|
19
|
+
slideSettings,
|
|
20
|
+
comments
|
|
21
|
+
}: {
|
|
22
|
+
mode: InspectorMode | null
|
|
23
|
+
visible: boolean
|
|
24
|
+
editorAvailable: boolean
|
|
25
|
+
width: number
|
|
26
|
+
overlay: boolean
|
|
27
|
+
onSelect: (mode: InspectorMode) => void
|
|
28
|
+
onClose: () => void
|
|
29
|
+
slideSettings: ReactNode
|
|
30
|
+
comments: ReactNode
|
|
31
|
+
}) {
|
|
32
|
+
const prefix = useId()
|
|
33
|
+
const tabs: { id: InspectorMode; label: string }[] = [
|
|
34
|
+
{ id: "slide", label: "Slide" },
|
|
35
|
+
...(editorAvailable ? [{ id: "editor" as const, label: "Elements" }] : []),
|
|
36
|
+
{ id: "comments", label: "Comments" }
|
|
37
|
+
]
|
|
38
|
+
const closeLabel =
|
|
39
|
+
mode === "editor"
|
|
40
|
+
? "Done editing"
|
|
41
|
+
: mode === "comments"
|
|
42
|
+
? "Hide comments"
|
|
43
|
+
: "Close slide settings"
|
|
44
|
+
return (
|
|
45
|
+
<aside
|
|
46
|
+
data-ps-inspector
|
|
47
|
+
aria-label="Inspector"
|
|
48
|
+
aria-hidden={!visible}
|
|
49
|
+
inert={!visible}
|
|
50
|
+
className={cn(
|
|
51
|
+
"h-full shrink-0 overflow-hidden bg-neutral-950 text-white transition-[width,transform] duration-200 ease-out motion-reduce:transition-none print:hidden",
|
|
52
|
+
overlay && "fixed inset-y-0 right-0 z-[60]",
|
|
53
|
+
overlay && !visible && "translate-x-full"
|
|
54
|
+
)}
|
|
55
|
+
style={{ width: visible ? width : 0, maxWidth: overlay ? "90vw" : undefined }}
|
|
56
|
+
onKeyDown={event => {
|
|
57
|
+
// Native button activation must not also advance the presentation.
|
|
58
|
+
if (
|
|
59
|
+
(event.key === " " || event.key === "Enter") &&
|
|
60
|
+
(event.target as HTMLElement).closest("button")
|
|
61
|
+
)
|
|
62
|
+
event.stopPropagation()
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<div className="flex h-full min-h-0 w-full flex-col border-l border-neutral-800">
|
|
66
|
+
<div
|
|
67
|
+
data-ps-inspector-header
|
|
68
|
+
className="flex shrink-0 items-center gap-1 border-b border-neutral-800 px-2 py-2"
|
|
69
|
+
>
|
|
70
|
+
<div
|
|
71
|
+
role="tablist"
|
|
72
|
+
aria-label="Inspector sections"
|
|
73
|
+
className="flex min-w-0 flex-1 gap-1"
|
|
74
|
+
onKeyDown={event => {
|
|
75
|
+
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return
|
|
76
|
+
event.preventDefault()
|
|
77
|
+
event.stopPropagation()
|
|
78
|
+
const buttons = Array.from(
|
|
79
|
+
event.currentTarget.querySelectorAll<HTMLButtonElement>("button")
|
|
80
|
+
)
|
|
81
|
+
const index = buttons.indexOf(event.target as HTMLButtonElement)
|
|
82
|
+
const next =
|
|
83
|
+
event.key === "Home"
|
|
84
|
+
? 0
|
|
85
|
+
: event.key === "End"
|
|
86
|
+
? buttons.length - 1
|
|
87
|
+
: (index + (event.key === "ArrowRight" ? 1 : -1) + buttons.length) %
|
|
88
|
+
buttons.length
|
|
89
|
+
buttons[next]?.focus()
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
{tabs.map(tab => (
|
|
93
|
+
<button
|
|
94
|
+
key={tab.id}
|
|
95
|
+
id={`${prefix}-tab-${tab.id}`}
|
|
96
|
+
role="tab"
|
|
97
|
+
aria-selected={mode === tab.id}
|
|
98
|
+
aria-controls={`${prefix}-panel-${tab.id}`}
|
|
99
|
+
tabIndex={mode === tab.id ? 0 : -1}
|
|
100
|
+
className={cn(
|
|
101
|
+
"rounded-md px-2 py-1.5 text-xs font-medium hover:bg-neutral-800 focus-visible:outline-2 focus-visible:outline-orange-500",
|
|
102
|
+
mode === tab.id ? "bg-neutral-800 text-white" : "text-neutral-400"
|
|
103
|
+
)}
|
|
104
|
+
onClick={() => onSelect(tab.id)}
|
|
105
|
+
>
|
|
106
|
+
{tab.label}
|
|
107
|
+
</button>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
<button
|
|
111
|
+
onClick={onClose}
|
|
112
|
+
aria-label={closeLabel}
|
|
113
|
+
title={closeLabel}
|
|
114
|
+
className="shrink-0 rounded-md p-1.5 text-neutral-400 hover:bg-neutral-800 hover:text-white focus-visible:outline-2 focus-visible:outline-orange-500"
|
|
115
|
+
>
|
|
116
|
+
<X size={16} />
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
<div
|
|
120
|
+
id={`${prefix}-panel-slide`}
|
|
121
|
+
role="tabpanel"
|
|
122
|
+
aria-labelledby={`${prefix}-tab-slide`}
|
|
123
|
+
hidden={mode !== "slide"}
|
|
124
|
+
className="min-h-0 flex-1"
|
|
125
|
+
>
|
|
126
|
+
{slideSettings}
|
|
127
|
+
</div>
|
|
128
|
+
<div
|
|
129
|
+
id={`${prefix}-panel-editor`}
|
|
130
|
+
role="tabpanel"
|
|
131
|
+
aria-labelledby={`${prefix}-tab-editor`}
|
|
132
|
+
hidden={mode !== "editor"}
|
|
133
|
+
className="min-h-0 flex-1"
|
|
134
|
+
>
|
|
135
|
+
<div
|
|
136
|
+
data-ps-editor-panel
|
|
137
|
+
data-open={mode === "editor" && visible}
|
|
138
|
+
aria-hidden={mode !== "editor" || !visible}
|
|
139
|
+
inert={mode !== "editor" || !visible}
|
|
140
|
+
className="h-full w-full"
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
<div
|
|
144
|
+
id={`${prefix}-panel-comments`}
|
|
145
|
+
role="tabpanel"
|
|
146
|
+
aria-labelledby={`${prefix}-tab-comments`}
|
|
147
|
+
hidden={mode !== "comments"}
|
|
148
|
+
className="min-h-0 flex-1"
|
|
149
|
+
>
|
|
150
|
+
{comments}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</aside>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { LoopConfig, SlideConfig } from "../types"
|
|
2
|
+
export const DEFAULT_LOOP_CONFIG: LoopConfig
|
|
3
|
+
export function validateLoopConfig(value: unknown): LoopConfig
|
|
4
|
+
export function validDuration(value: unknown): boolean
|
|
5
|
+
export function loopSlideKey(slide: Pick<SlideConfig, "id">, index: number): string
|
|
6
|
+
export function stateDuration(
|
|
7
|
+
config: LoopConfig,
|
|
8
|
+
slide: Pick<SlideConfig, "id">,
|
|
9
|
+
index: number,
|
|
10
|
+
step: number
|
|
11
|
+
): number
|
|
12
|
+
export function includedSlides(config: LoopConfig, slides: SlideConfig[]): number[]
|
|
13
|
+
export function loopDuration(config: LoopConfig, slides: SlideConfig[]): number
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export const DEFAULT_LOOP_CONFIG = { version: 1, defaultDuration: 5, slides: {} }
|
|
2
|
+
|
|
3
|
+
export function validateLoopConfig(value) {
|
|
4
|
+
if (
|
|
5
|
+
!value ||
|
|
6
|
+
value.version !== 1 ||
|
|
7
|
+
!validDuration(value.defaultDuration) ||
|
|
8
|
+
!value.slides ||
|
|
9
|
+
typeof value.slides !== "object" ||
|
|
10
|
+
Array.isArray(value.slides)
|
|
11
|
+
) {
|
|
12
|
+
throw new Error("Invalid loop settings")
|
|
13
|
+
}
|
|
14
|
+
const slides = Object.create(null)
|
|
15
|
+
if (Object.keys(value.slides).length > 1000) throw new Error("Too many slides")
|
|
16
|
+
for (const [id, item] of Object.entries(value.slides)) {
|
|
17
|
+
if (
|
|
18
|
+
!id ||
|
|
19
|
+
id.length > 256 ||
|
|
20
|
+
!item ||
|
|
21
|
+
typeof item !== "object" ||
|
|
22
|
+
(item.excluded !== undefined && typeof item.excluded !== "boolean") ||
|
|
23
|
+
(item.steps !== undefined &&
|
|
24
|
+
(!Number.isInteger(item.steps) || item.steps < 0 || item.steps > 1000)) ||
|
|
25
|
+
(item.dwell !== undefined &&
|
|
26
|
+
(!Array.isArray(item.dwell) ||
|
|
27
|
+
item.dwell.length > 1001 ||
|
|
28
|
+
!item.dwell.every(duration => duration === null || validDuration(duration))))
|
|
29
|
+
)
|
|
30
|
+
throw new Error(`Invalid timing for ${id}`)
|
|
31
|
+
slides[id] = {
|
|
32
|
+
...(item.excluded !== undefined ? { excluded: item.excluded } : {}),
|
|
33
|
+
...(item.steps !== undefined ? { steps: item.steps } : {}),
|
|
34
|
+
...(item.dwell !== undefined ? { dwell: [...item.dwell] } : {})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { version: 1, defaultDuration: value.defaultDuration, slides }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function validDuration(value) {
|
|
41
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0.1 && value <= 3600
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function loopSlideKey(slide, index) {
|
|
45
|
+
return slide.id || String(index + 1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function stateDuration(config, slide, index, step) {
|
|
49
|
+
return config.slides[loopSlideKey(slide, index)]?.dwell?.[step] ?? config.defaultDuration
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function includedSlides(config, slides) {
|
|
53
|
+
return slides.flatMap((slide, index) =>
|
|
54
|
+
config.slides[loopSlideKey(slide, index)]?.excluded ? [] : [index]
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function loopDuration(config, slides) {
|
|
59
|
+
return includedSlides(config, slides).reduce((total, index) => {
|
|
60
|
+
const slide = slides[index]
|
|
61
|
+
for (let step = 0; step <= slide.steps; step++)
|
|
62
|
+
total += stateDuration(config, slide, index, step)
|
|
63
|
+
return total
|
|
64
|
+
}, 0)
|
|
65
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import type { LoopConfig } from "../types"
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_LOOP_CONFIG } from "./config.mjs"
|
|
6
|
+
|
|
7
|
+
const LoopContext = createContext<LoopConfig>(DEFAULT_LOOP_CONFIG)
|
|
8
|
+
|
|
9
|
+
/** The CLI supplies the deck's loop-config.json in development and production. */
|
|
10
|
+
export function LoopConfigProvider({
|
|
11
|
+
config,
|
|
12
|
+
children
|
|
13
|
+
}: {
|
|
14
|
+
config: LoopConfig
|
|
15
|
+
children: ReactNode
|
|
16
|
+
}) {
|
|
17
|
+
return <LoopContext.Provider value={config}>{children}</LoopContext.Provider>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const useDefaultLoopConfig = () => useContext(LoopContext)
|