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
|
@@ -13,6 +13,7 @@ import { DEFAULT_SLIDE_TRANSITION, getSlideVariants } from "./transitions"
|
|
|
13
13
|
// =============================================================================
|
|
14
14
|
|
|
15
15
|
export interface SlideRendererProps {
|
|
16
|
+
playbackKey?: number
|
|
16
17
|
slides: SlideConfig[]
|
|
17
18
|
currentSlide: number
|
|
18
19
|
animationStep: number
|
|
@@ -33,6 +34,7 @@ export interface SlideRendererProps {
|
|
|
33
34
|
* Extracted from SlideDeck so it can be reused in SlideEmbed and other contexts.
|
|
34
35
|
*/
|
|
35
36
|
export function SlideRenderer({
|
|
37
|
+
playbackKey,
|
|
36
38
|
slides,
|
|
37
39
|
currentSlide,
|
|
38
40
|
animationStep,
|
|
@@ -77,7 +79,11 @@ export function SlideRenderer({
|
|
|
77
79
|
showAllAnimations={showAllAnimations}
|
|
78
80
|
>
|
|
79
81
|
<SlideErrorBoundary slideIndex={currentSlide} slideTitle={slides[currentSlide]?.title}>
|
|
80
|
-
<CurrentSlideComponent
|
|
82
|
+
<CurrentSlideComponent
|
|
83
|
+
key={playbackKey}
|
|
84
|
+
slideNumber={currentSlide + 1}
|
|
85
|
+
totalSlides={slides.length}
|
|
86
|
+
/>
|
|
81
87
|
</SlideErrorBoundary>
|
|
82
88
|
</AnimationProvider>
|
|
83
89
|
</motion.div>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef } from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "./utils"
|
|
4
|
+
|
|
5
|
+
type ToolbarActionProps = Omit<
|
|
6
|
+
ComponentPropsWithoutRef<"button">,
|
|
7
|
+
"aria-label" | "aria-pressed" | "className"
|
|
8
|
+
> & {
|
|
9
|
+
label: string
|
|
10
|
+
selected?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Shared icon action for both the viewer toolbar and the editor's toolbar portal. */
|
|
14
|
+
export function ToolbarAction({
|
|
15
|
+
label,
|
|
16
|
+
selected,
|
|
17
|
+
title = label,
|
|
18
|
+
children,
|
|
19
|
+
...props
|
|
20
|
+
}: ToolbarActionProps) {
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
{...props}
|
|
24
|
+
type="button"
|
|
25
|
+
aria-label={label}
|
|
26
|
+
aria-pressed={selected}
|
|
27
|
+
title={title}
|
|
28
|
+
className={cn(
|
|
29
|
+
"relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-transparent transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#FF6B35] disabled:cursor-default disabled:opacity-35 [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0",
|
|
30
|
+
selected
|
|
31
|
+
? "border-[#FF6B35]/50 bg-[#FF6B35]/15 text-[#FF6B35] enabled:hover:border-[#FF6B35] enabled:hover:bg-[#FF6B35]/25"
|
|
32
|
+
: "text-neutral-400 enabled:hover:bg-neutral-800 enabled:hover:text-white"
|
|
33
|
+
)}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
</button>
|
|
37
|
+
)
|
|
38
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -136,3 +136,17 @@ export interface LayoutBaseProps extends SlideProps {
|
|
|
136
136
|
hideFooter?: boolean
|
|
137
137
|
className?: string
|
|
138
138
|
}
|
|
139
|
+
|
|
140
|
+
/** Durations in seconds, including the initial state at dwell[0]. */
|
|
141
|
+
export interface LoopSlideConfig {
|
|
142
|
+
excluded?: boolean
|
|
143
|
+
/** Step count when these timings were edited; used to flag changes. */
|
|
144
|
+
steps?: number
|
|
145
|
+
dwell?: (number | null)[]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface LoopConfig {
|
|
149
|
+
version: 1
|
|
150
|
+
defaultDuration: number
|
|
151
|
+
slides: Record<string, LoopSlideConfig>
|
|
152
|
+
}
|
|
@@ -67,6 +67,8 @@ export interface UseSlideNavigationReturn {
|
|
|
67
67
|
advance: () => void
|
|
68
68
|
goBack: () => void
|
|
69
69
|
goToSlide: (index: number) => void
|
|
70
|
+
goToStep: (step: number) => void
|
|
71
|
+
restartAtSlide: (index: number) => void
|
|
70
72
|
onTransitionComplete: () => void
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -206,6 +208,29 @@ export function useSlideNavigation({
|
|
|
206
208
|
[currentSlide, slides.length, onSlideChange]
|
|
207
209
|
)
|
|
208
210
|
|
|
211
|
+
const goToStep = useCallback(
|
|
212
|
+
(step: number) => {
|
|
213
|
+
if (!Number.isInteger(step)) return
|
|
214
|
+
setQueuedAction(null)
|
|
215
|
+
setAnimationStep(Math.max(0, Math.min(step, slides[currentSlide]?.steps ?? 0)))
|
|
216
|
+
setNavState(previous => ({ ...previous, direction: 0 }))
|
|
217
|
+
},
|
|
218
|
+
[slides, currentSlide]
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const restartAtSlide = useCallback(
|
|
222
|
+
(index: number) => {
|
|
223
|
+
if (index < 0 || index >= slides.length) return
|
|
224
|
+
setQueuedAction(null)
|
|
225
|
+
setAnimationStep(0)
|
|
226
|
+
setCurrentSlide(index)
|
|
227
|
+
// Same-slide restarts do not have a transition to complete.
|
|
228
|
+
setNavState({ status: index === currentSlide ? "idle" : "transitioning", direction: 1 })
|
|
229
|
+
onSlideChange?.(index)
|
|
230
|
+
},
|
|
231
|
+
[slides.length, currentSlide, onSlideChange]
|
|
232
|
+
)
|
|
233
|
+
|
|
209
234
|
useEffect(() => {
|
|
210
235
|
if (navState.status === "idle" && queuedAction !== null) {
|
|
211
236
|
setQueuedAction(null)
|
|
@@ -227,6 +252,8 @@ export function useSlideNavigation({
|
|
|
227
252
|
advance,
|
|
228
253
|
goBack,
|
|
229
254
|
goToSlide,
|
|
255
|
+
goToStep,
|
|
256
|
+
restartAtSlide,
|
|
230
257
|
onTransitionComplete
|
|
231
258
|
}
|
|
232
259
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Check, ChevronDown, Grid3X3, List, Monitor } from "lucide-react"
|
|
2
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"
|
|
3
|
+
|
|
4
|
+
import { cn } from "./utils"
|
|
5
|
+
|
|
6
|
+
export type ViewMode = "slide" | "list" | "grid"
|
|
7
|
+
|
|
8
|
+
const views = [
|
|
9
|
+
{ mode: "slide", label: "Presentation view", shortcut: "V", icon: Monitor },
|
|
10
|
+
{ mode: "list", label: "List view", shortcut: "L", icon: List },
|
|
11
|
+
{ mode: "grid", label: "Grid view", shortcut: "G", icon: Grid3X3 }
|
|
12
|
+
] as const
|
|
13
|
+
|
|
14
|
+
export function ViewMenu({
|
|
15
|
+
value,
|
|
16
|
+
onChange,
|
|
17
|
+
disabled
|
|
18
|
+
}: {
|
|
19
|
+
value: ViewMode
|
|
20
|
+
onChange: (mode: ViewMode) => void
|
|
21
|
+
disabled: boolean
|
|
22
|
+
}) {
|
|
23
|
+
const [open, setOpen] = useState(false)
|
|
24
|
+
const id = useId()
|
|
25
|
+
const root = useRef<HTMLDivElement>(null)
|
|
26
|
+
const trigger = useRef<HTMLButtonElement>(null)
|
|
27
|
+
const active = views.find(view => view.mode === value)!
|
|
28
|
+
const Icon = active.icon
|
|
29
|
+
|
|
30
|
+
useLayoutEffect(() => {
|
|
31
|
+
if (open) root.current?.querySelector<HTMLElement>('[aria-checked="true"]')?.focus()
|
|
32
|
+
}, [open])
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open) return
|
|
35
|
+
const close = (event: PointerEvent) => {
|
|
36
|
+
if (!root.current?.contains(event.target as Node)) setOpen(false)
|
|
37
|
+
}
|
|
38
|
+
document.addEventListener("pointerdown", close)
|
|
39
|
+
return () => document.removeEventListener("pointerdown", close)
|
|
40
|
+
}, [open])
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
role="group"
|
|
45
|
+
aria-label="View selector"
|
|
46
|
+
ref={root}
|
|
47
|
+
className="relative flex"
|
|
48
|
+
onBlur={event => {
|
|
49
|
+
const next = event.relatedTarget
|
|
50
|
+
if (next && !event.currentTarget.contains(next)) setOpen(false)
|
|
51
|
+
}}
|
|
52
|
+
onKeyDown={event => {
|
|
53
|
+
// Toolbar navigation must not trigger slide advancement or deck shortcuts.
|
|
54
|
+
event.stopPropagation()
|
|
55
|
+
if (event.key === "Escape") {
|
|
56
|
+
setOpen(false)
|
|
57
|
+
trigger.current?.focus()
|
|
58
|
+
} else if (open && event.key === "Tab") {
|
|
59
|
+
trigger.current?.focus()
|
|
60
|
+
setOpen(false)
|
|
61
|
+
} else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
|
62
|
+
event.preventDefault()
|
|
63
|
+
if (!open) {
|
|
64
|
+
setOpen(true)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
const items = Array.from(
|
|
68
|
+
root.current!.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
|
|
69
|
+
)
|
|
70
|
+
const index = items.indexOf(document.activeElement as HTMLButtonElement)
|
|
71
|
+
const next =
|
|
72
|
+
event.key === "Home"
|
|
73
|
+
? 0
|
|
74
|
+
: event.key === "End"
|
|
75
|
+
? items.length - 1
|
|
76
|
+
: (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length
|
|
77
|
+
items[next]?.focus()
|
|
78
|
+
}
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<button
|
|
82
|
+
ref={trigger}
|
|
83
|
+
disabled={disabled}
|
|
84
|
+
aria-label="View options"
|
|
85
|
+
title={`View options · ${active.label}`}
|
|
86
|
+
aria-haspopup="menu"
|
|
87
|
+
aria-expanded={open}
|
|
88
|
+
aria-controls={open ? id : undefined}
|
|
89
|
+
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-neutral-800 px-2 text-white hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-orange-500"
|
|
90
|
+
onClick={() => setOpen(previous => !previous)}
|
|
91
|
+
>
|
|
92
|
+
<Icon size={16} />
|
|
93
|
+
<ChevronDown size={12} />
|
|
94
|
+
</button>
|
|
95
|
+
{open && (
|
|
96
|
+
<div
|
|
97
|
+
id={id}
|
|
98
|
+
role="menu"
|
|
99
|
+
aria-label="View options"
|
|
100
|
+
className="absolute left-0 top-10 w-56 rounded-lg border border-neutral-700 bg-neutral-950 p-1 text-sm text-white"
|
|
101
|
+
>
|
|
102
|
+
{views.map(({ mode, label, shortcut, icon: OptionIcon }) => (
|
|
103
|
+
<button
|
|
104
|
+
key={mode}
|
|
105
|
+
role="menuitemradio"
|
|
106
|
+
aria-checked={mode === value}
|
|
107
|
+
tabIndex={-1}
|
|
108
|
+
className={cn(
|
|
109
|
+
"flex w-full items-center gap-2 rounded px-2 py-2 text-left text-neutral-300 hover:bg-neutral-800 focus:bg-neutral-800 focus:outline-none",
|
|
110
|
+
mode === value && "text-white"
|
|
111
|
+
)}
|
|
112
|
+
onClick={() => {
|
|
113
|
+
onChange(mode)
|
|
114
|
+
setOpen(false)
|
|
115
|
+
trigger.current?.focus()
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<OptionIcon size={16} />
|
|
119
|
+
<span className="flex-1">{label}</span>
|
|
120
|
+
<span aria-hidden="true" className="text-xs text-neutral-500">
|
|
121
|
+
{shortcut}
|
|
122
|
+
</span>
|
|
123
|
+
<Check size={14} className={mode === value ? "text-orange-400" : "invisible"} />
|
|
124
|
+
</button>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|
package/src/editor/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Development source editor
|
|
2
2
|
|
|
3
|
-
Run `promptslide studio` and choose **Edit** in the
|
|
3
|
+
Run `promptslide studio` and choose **Edit** in the top-right toolbar, or **Elements** in the shared inspector. The core viewer owns the inspector's tabs, docking, and close control; the editor portals its tools into the Elements slot. The editor is injected by the development Vite plugin. Production builds, registry previews, embeds, and export pages do not load it. No editable-component migration is needed.
|
|
4
4
|
|
|
5
5
|
## Workflow
|
|
6
6
|
|
|
@@ -11,6 +11,7 @@ Run `promptslide studio` and choose **Edit** in the existing top-right toolbar.
|
|
|
11
11
|
- For images, adjust the frame size, objectFit, objectPosition, and inset crop.
|
|
12
12
|
- Select a range in Text and apply bold, italic, or underline. Save existing text changes first. Range formatting creates ordinary styled JSX spans, which can subsequently be selected individually.
|
|
13
13
|
- Structure supports inserting text, shapes, and images into JSX containers and duplicating/deleting direct JSX children. Root components and expressions cannot be deleted. Duplicates preview immediately and support Undo and Discard; save to edit copies independently. Insertions render after Save; range formatting previews immediately.
|
|
14
|
+
- Switching from Elements to Slide settings or Comments uses the same Save / Discard / Keep editing guard as closing the editor. Keeping editing (or cancelling the dialog) cancels the pending panel switch. Loop timing drafts stay in the viewer across panel changes and source saves.
|
|
14
15
|
- Undo/redo affects the buffered draft. Save batches edits to source; Discard restores the source rendering. Close requires resolving unsaved changes. The browser tab retains its draft across hot reloads. Export draft downloads the pending operations for recovery.
|
|
15
16
|
- Editing uses the original viewer canvas, styling, and animation wrappers. Leave Edit to reveal steps normally; opening Edit preserves the currently visible content.
|
|
16
17
|
|
|
@@ -33,3 +34,12 @@ The endpoint uses a per-server random token, checks cross-site fetches, limits p
|
|
|
33
34
|
- `tsc -p packages/cli/tsconfig.json --noEmit`: editor and engine types.
|
|
34
35
|
|
|
35
36
|
Implementation uses TypeScript's JSX parser and original-range patches. No OpenSlide source code is vendored.
|
|
37
|
+
|
|
38
|
+
## Inspector protocol
|
|
39
|
+
|
|
40
|
+
The viewer's inspector is part of the production core; the source editor remains development-only.
|
|
41
|
+
`EditorLauncher` discovers the existing toolbar, panel, canvas, and overlay slots, announces
|
|
42
|
+
`promptslide:editor-ready`, and handles `promptslide:editor-request` to open or request a guarded
|
|
43
|
+
close. `promptslide:editor-state` acknowledges actual open/closed state. The viewer commits a
|
|
44
|
+
pending tab change only after a closed acknowledgement; `promptslide:editor-close-cancelled`
|
|
45
|
+
clears that pending change. Source edits and their history stay in the editor's own React root.
|
package/src/editor/plugin.mjs
CHANGED
|
@@ -60,7 +60,7 @@ const dispose = mountEditor(slides, theme, ${JSON.stringify(token)}, import.meta
|
|
|
60
60
|
if (import.meta.hot) {
|
|
61
61
|
import.meta.hot.accept();
|
|
62
62
|
import.meta.hot.dispose((data) => {
|
|
63
|
-
data.open = document.querySelector('
|
|
63
|
+
data.open = document.querySelector('[data-ps-edit-action][aria-pressed="true"]') !== null;
|
|
64
64
|
dispose?.();
|
|
65
65
|
window.dispatchEvent(new Event("promptslide:editor-refresh"));
|
|
66
66
|
});
|
package/src/editor/studio.css
CHANGED
|
@@ -1,29 +1,5 @@
|
|
|
1
1
|
/* Editor chrome lives in sibling portals. No selector reaches slide content.
|
|
2
2
|
CSS updates must remain independent of the editor's buffered source draft. */
|
|
3
|
-
.ps-edit-action {
|
|
4
|
-
display: inline-flex;
|
|
5
|
-
align-items: center;
|
|
6
|
-
justify-content: center;
|
|
7
|
-
gap: 6px;
|
|
8
|
-
height: 32px;
|
|
9
|
-
padding: 0 10px;
|
|
10
|
-
border: 1px solid transparent;
|
|
11
|
-
border-radius: 6px;
|
|
12
|
-
font: 500 13px/1 system-ui;
|
|
13
|
-
color: #a3a3a3;
|
|
14
|
-
background: transparent;
|
|
15
|
-
cursor: pointer;
|
|
16
|
-
}
|
|
17
|
-
.ps-edit-action:hover {
|
|
18
|
-
color: #fafafa;
|
|
19
|
-
background: #262626;
|
|
20
|
-
}
|
|
21
|
-
.ps-edit-action.active {
|
|
22
|
-
padding: 0 8px;
|
|
23
|
-
border-color: #ff6b3560;
|
|
24
|
-
background: #ff6b351a;
|
|
25
|
-
color: #ff8b60;
|
|
26
|
-
}
|
|
27
3
|
[data-ps-viewer-toolbar] button:disabled {
|
|
28
4
|
opacity: 0.35;
|
|
29
5
|
cursor: default;
|
|
@@ -34,7 +10,6 @@
|
|
|
34
10
|
display: flex;
|
|
35
11
|
flex-direction: column;
|
|
36
12
|
background: #0a0a0a;
|
|
37
|
-
border-left: 1px solid #262626;
|
|
38
13
|
color: #e5e5e5;
|
|
39
14
|
font-family: inherit;
|
|
40
15
|
font-size: 12px;
|
|
@@ -92,8 +67,7 @@
|
|
|
92
67
|
.ps-panel button:focus-visible,
|
|
93
68
|
.ps-panel input:focus-visible,
|
|
94
69
|
.ps-panel select:focus-visible,
|
|
95
|
-
.ps-panel textarea:focus-visible
|
|
96
|
-
.ps-edit-action:focus-visible {
|
|
70
|
+
.ps-panel textarea:focus-visible {
|
|
97
71
|
outline: 2px solid #ff6b35;
|
|
98
72
|
outline-offset: 2px;
|
|
99
73
|
}
|
package/src/editor/studio.tsx
CHANGED
|
@@ -31,9 +31,10 @@ import {
|
|
|
31
31
|
MoveVertical,
|
|
32
32
|
RotateCw
|
|
33
33
|
} from "lucide-react"
|
|
34
|
-
import { useEffect, useLayoutEffect, useRef, useState } from "react"
|
|
34
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
|
|
35
35
|
import { createPortal } from "react-dom"
|
|
36
36
|
|
|
37
|
+
import { ToolbarAction } from "../core/toolbar-action"
|
|
37
38
|
import { aligned, snap, translated, type Rect } from "./geometry"
|
|
38
39
|
import "./studio.css"
|
|
39
40
|
|
|
@@ -525,6 +526,8 @@ function Studio({
|
|
|
525
526
|
useEffect(() => {
|
|
526
527
|
// Prevent the presentation's global keyboard handler from advancing behind the editor.
|
|
527
528
|
const handler = (event: KeyboardEvent) => {
|
|
529
|
+
// Inspector navigation belongs to the core viewer, including its keyboard controls.
|
|
530
|
+
if ((event.target as HTMLElement).closest("[data-ps-inspector-header]")) return
|
|
528
531
|
event.stopImmediatePropagation()
|
|
529
532
|
if (confirmClose) return
|
|
530
533
|
if (busy) return
|
|
@@ -751,7 +754,8 @@ function Studio({
|
|
|
751
754
|
surface.contains(target) ||
|
|
752
755
|
overlay.contains(target) ||
|
|
753
756
|
panel.contains(target) ||
|
|
754
|
-
(target instanceof Element &&
|
|
757
|
+
(target instanceof Element &&
|
|
758
|
+
target.closest("[data-ps-viewer-toolbar], [data-ps-inspector-header]"))
|
|
755
759
|
)
|
|
756
760
|
return
|
|
757
761
|
if (inlineText !== null && current && inlineText !== (currentEdit?.text ?? currentText))
|
|
@@ -803,7 +807,15 @@ function Studio({
|
|
|
803
807
|
}
|
|
804
808
|
close()
|
|
805
809
|
}
|
|
810
|
+
function cancelClosing() {
|
|
811
|
+
setConfirmClose(false)
|
|
812
|
+
window.dispatchEvent(new Event("promptslide:editor-close-cancelled"))
|
|
813
|
+
}
|
|
806
814
|
function finishEditing() {
|
|
815
|
+
if (busy) {
|
|
816
|
+
window.dispatchEvent(new Event("promptslide:editor-close-cancelled"))
|
|
817
|
+
return
|
|
818
|
+
}
|
|
807
819
|
if (dirty) {
|
|
808
820
|
setCloseError("")
|
|
809
821
|
setConfirmClose(true)
|
|
@@ -887,7 +899,7 @@ function Studio({
|
|
|
887
899
|
aria-labelledby="ps-close-title"
|
|
888
900
|
onCancel={event => {
|
|
889
901
|
event.preventDefault()
|
|
890
|
-
if (!busy)
|
|
902
|
+
if (!busy) cancelClosing()
|
|
891
903
|
}}
|
|
892
904
|
>
|
|
893
905
|
<h2 id="ps-close-title">Save your changes?</h2>
|
|
@@ -901,7 +913,7 @@ function Studio({
|
|
|
901
913
|
</p>
|
|
902
914
|
)}
|
|
903
915
|
<div className="ps-close-actions">
|
|
904
|
-
<button disabled={busy} onClick={
|
|
916
|
+
<button disabled={busy} onClick={cancelClosing}>
|
|
905
917
|
Keep editing
|
|
906
918
|
</button>
|
|
907
919
|
<button
|
|
@@ -935,9 +947,6 @@ function Studio({
|
|
|
935
947
|
<span className="ps-overline">SLIDE EDITOR</span>
|
|
936
948
|
<h2>{current ? selectedName : "Make it yours"}</h2>
|
|
937
949
|
</div>
|
|
938
|
-
<IconButton label="Done editing" disabled={busy} onClick={finishEditing}>
|
|
939
|
-
<X size={18} />
|
|
940
|
-
</IconButton>
|
|
941
950
|
</div>
|
|
942
951
|
<div className="ps-panel-tools">
|
|
943
952
|
<div className="ps-tabs" role="tablist" aria-label="Editing tools">
|
|
@@ -1880,18 +1889,6 @@ export function EditorLauncher({
|
|
|
1880
1889
|
initiallyOpen?: boolean
|
|
1881
1890
|
}) {
|
|
1882
1891
|
const [open, setOpen] = useState(initiallyOpen)
|
|
1883
|
-
const [mounted, setMounted] = useState(initiallyOpen)
|
|
1884
|
-
useEffect(() => {
|
|
1885
|
-
if (open) {
|
|
1886
|
-
setMounted(true)
|
|
1887
|
-
return
|
|
1888
|
-
}
|
|
1889
|
-
const timeout = window.setTimeout(
|
|
1890
|
-
() => setMounted(false),
|
|
1891
|
-
window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 200
|
|
1892
|
-
)
|
|
1893
|
-
return () => window.clearTimeout(timeout)
|
|
1894
|
-
}, [open])
|
|
1895
1892
|
const [slots, setSlots] = useState<{
|
|
1896
1893
|
toolbar: HTMLElement
|
|
1897
1894
|
panel: HTMLElement
|
|
@@ -1919,30 +1916,49 @@ export function EditorLauncher({
|
|
|
1919
1916
|
observer.observe(document.getElementById("root")!, { childList: true, subtree: true })
|
|
1920
1917
|
return () => observer.disconnect()
|
|
1921
1918
|
}, [])
|
|
1922
|
-
const toggle = (value: boolean) => {
|
|
1919
|
+
const toggle = useCallback((value: boolean) => {
|
|
1923
1920
|
setOpen(value)
|
|
1924
1921
|
window.dispatchEvent(new CustomEvent("promptslide:editor-state", { detail: { open: value } }))
|
|
1925
|
-
}
|
|
1922
|
+
}, [])
|
|
1923
|
+
const openRef = useRef(open)
|
|
1924
|
+
openRef.current = open
|
|
1925
|
+
useEffect(() => {
|
|
1926
|
+
if (!slots) return
|
|
1927
|
+
window.dispatchEvent(
|
|
1928
|
+
new CustomEvent("promptslide:editor-ready", { detail: { available: true } })
|
|
1929
|
+
)
|
|
1930
|
+
if (openRef.current)
|
|
1931
|
+
window.dispatchEvent(new CustomEvent("promptslide:editor-state", { detail: { open: true } }))
|
|
1932
|
+
const onRequest = (event: Event) => {
|
|
1933
|
+
if ((event as CustomEvent).detail?.open) toggle(true)
|
|
1934
|
+
else window.dispatchEvent(new Event("promptslide:editor-done"))
|
|
1935
|
+
}
|
|
1936
|
+
window.addEventListener("promptslide:editor-request", onRequest)
|
|
1937
|
+
return () => {
|
|
1938
|
+
window.removeEventListener("promptslide:editor-request", onRequest)
|
|
1939
|
+
window.dispatchEvent(
|
|
1940
|
+
new CustomEvent("promptslide:editor-ready", { detail: { available: false } })
|
|
1941
|
+
)
|
|
1942
|
+
}
|
|
1943
|
+
}, [slots, toggle])
|
|
1926
1944
|
if (!slots) return null
|
|
1927
1945
|
return (
|
|
1928
1946
|
<>
|
|
1929
1947
|
{createPortal(
|
|
1930
|
-
<
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
aria-pressed={open}
|
|
1948
|
+
<ToolbarAction
|
|
1949
|
+
data-ps-edit-action
|
|
1950
|
+
label="Edit slide"
|
|
1951
|
+
selected={open}
|
|
1935
1952
|
onClick={() => {
|
|
1936
1953
|
if (!open) toggle(true)
|
|
1937
1954
|
else window.dispatchEvent(new Event("promptslide:editor-done"))
|
|
1938
1955
|
}}
|
|
1939
1956
|
>
|
|
1940
1957
|
<Pencil size={15} />
|
|
1941
|
-
|
|
1942
|
-
</button>,
|
|
1958
|
+
</ToolbarAction>,
|
|
1943
1959
|
slots.toolbar
|
|
1944
1960
|
)}
|
|
1945
|
-
{
|
|
1961
|
+
{open && <Studio token={token} close={() => toggle(false)} {...slots} />}
|
|
1946
1962
|
</>
|
|
1947
1963
|
)
|
|
1948
1964
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync } from "node:fs"
|
|
2
2
|
import { join } from "node:path"
|
|
3
3
|
|
|
4
|
+
import { DEFAULT_LOOP_CONFIG, readLoopConfig, writeLoopConfig } from "./loop-config.mjs"
|
|
5
|
+
|
|
4
6
|
const DECK_CONFIG_PATH = "src/deck-config.ts"
|
|
5
7
|
|
|
6
8
|
/**
|
|
@@ -224,6 +226,8 @@ export function replaceDeckConfig(cwd, slides, opts = {}) {
|
|
|
224
226
|
].join("\n")
|
|
225
227
|
|
|
226
228
|
writeFileSync(configPath, content, "utf-8")
|
|
229
|
+
if (opts.loopConfig !== undefined) writeLoopConfig(cwd, opts.loopConfig)
|
|
230
|
+
else if (!existsSync(join(cwd, "loop-config.json"))) writeLoopConfig(cwd, DEFAULT_LOOP_CONFIG)
|
|
227
231
|
return true
|
|
228
232
|
}
|
|
229
233
|
|
|
@@ -310,6 +314,8 @@ export function parseDeckConfig(cwd) {
|
|
|
310
314
|
if (slides.length === 0) return null
|
|
311
315
|
|
|
312
316
|
const result = { slides }
|
|
317
|
+
const loopConfig = readLoopConfig(cwd)
|
|
318
|
+
if (loopConfig) result.loopConfig = loopConfig
|
|
313
319
|
if (transition) result.transition = transition
|
|
314
320
|
if (directionalTransition !== undefined) result.directionalTransition = directionalTransition
|
|
315
321
|
return result
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync, lstatSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_LOOP_CONFIG, validateLoopConfig } from "../core/loop/config.mjs"
|
|
6
|
+
|
|
7
|
+
export function readLoopConfig(root) {
|
|
8
|
+
const path = join(root, "loop-config.json")
|
|
9
|
+
if (!existsSync(path)) return undefined
|
|
10
|
+
if (lstatSync(path).isSymbolicLink()) throw new Error("loop-config.json must be a regular file")
|
|
11
|
+
return validateLoopConfig(JSON.parse(readFileSync(path, "utf8")))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function writeLoopConfig(root, config) {
|
|
15
|
+
const value = validateLoopConfig(config)
|
|
16
|
+
const path = join(root, "loop-config.json")
|
|
17
|
+
if (existsSync(path) && lstatSync(path).isSymbolicLink())
|
|
18
|
+
throw new Error("loop-config.json must be a regular file")
|
|
19
|
+
const temporary = join(root, `.loop-config-${randomUUID()}.tmp`)
|
|
20
|
+
try {
|
|
21
|
+
writeFileSync(temporary, JSON.stringify(value, null, 2) + "\n", { flag: "wx" })
|
|
22
|
+
renameSync(temporary, path)
|
|
23
|
+
} finally {
|
|
24
|
+
if (existsSync(temporary)) unlinkSync(temporary)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loopConfigMiddleware(root, onSaved = () => {}) {
|
|
29
|
+
return (req, res, next) => {
|
|
30
|
+
if (req.url !== "/__promptslide_loop") return next()
|
|
31
|
+
if (req.method !== "POST") {
|
|
32
|
+
res.statusCode = 405
|
|
33
|
+
return res.end()
|
|
34
|
+
}
|
|
35
|
+
const host = req.headers.host || ""
|
|
36
|
+
const remote = req.socket.remoteAddress
|
|
37
|
+
if (
|
|
38
|
+
!/^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/.test(host) ||
|
|
39
|
+
!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(remote) ||
|
|
40
|
+
req.headers.origin !== `http://${host}` ||
|
|
41
|
+
!req.headers["content-type"]?.startsWith("application/json")
|
|
42
|
+
) {
|
|
43
|
+
res.statusCode = 403
|
|
44
|
+
return res.end("Loop settings can only be saved from local Studio.")
|
|
45
|
+
}
|
|
46
|
+
let body = ""
|
|
47
|
+
req.on("data", chunk => {
|
|
48
|
+
body += chunk
|
|
49
|
+
if (Buffer.byteLength(body) > 65536) {
|
|
50
|
+
res.statusCode = 413
|
|
51
|
+
res.end()
|
|
52
|
+
req.destroy()
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
req.on("end", () => {
|
|
56
|
+
if (res.writableEnded) return
|
|
57
|
+
try {
|
|
58
|
+
writeLoopConfig(root, JSON.parse(body))
|
|
59
|
+
onSaved()
|
|
60
|
+
res.statusCode = 204
|
|
61
|
+
res.end()
|
|
62
|
+
} catch {
|
|
63
|
+
res.statusCode = 400
|
|
64
|
+
res.end("Invalid loop settings or project file could not be written.")
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { DEFAULT_LOOP_CONFIG }
|
package/src/vite/plugin.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs"
|
|
|
2
2
|
import { join, resolve, sep, extname } from "node:path"
|
|
3
3
|
|
|
4
4
|
import { bold, dim } from "../utils/ansi.mjs"
|
|
5
|
+
import { readLoopConfig, DEFAULT_LOOP_CONFIG, loopConfigMiddleware } from "../utils/loop-config.mjs"
|
|
5
6
|
import { projectIdentity } from "../utils/studio-discovery.mjs"
|
|
6
7
|
|
|
7
8
|
/** Normalize a path to forward slashes so it can be used in ES module import strings on Windows. */
|
|
@@ -84,9 +85,11 @@ import { StrictMode, createElement } from "react"
|
|
|
84
85
|
import { createRoot } from "react-dom/client"
|
|
85
86
|
import "${r}/src/globals.css"
|
|
86
87
|
import App from "${r}/src/App"
|
|
88
|
+
import { LoopConfigProvider } from "promptslide"
|
|
89
|
+
const loopConfig = ${JSON.stringify(readLoopConfig(root) ?? DEFAULT_LOOP_CONFIG)}
|
|
87
90
|
|
|
88
91
|
createRoot(document.getElementById("root")).render(
|
|
89
|
-
createElement(StrictMode, null, createElement(App))
|
|
92
|
+
createElement(StrictMode, null, createElement(LoopConfigProvider, { config: loopConfig }, createElement(App)))
|
|
90
93
|
)
|
|
91
94
|
`
|
|
92
95
|
}
|
|
@@ -256,7 +259,7 @@ export function promptslidePlugin({ root: initialRoot } = {}) {
|
|
|
256
259
|
return {
|
|
257
260
|
server: {
|
|
258
261
|
watch: {
|
|
259
|
-
ignored: ["**/annotations.json"]
|
|
262
|
+
ignored: ["**/annotations.json", "**/.loop-config-*.tmp"]
|
|
260
263
|
}
|
|
261
264
|
}
|
|
262
265
|
}
|
|
@@ -284,6 +287,19 @@ export function promptslidePlugin({ root: initialRoot } = {}) {
|
|
|
284
287
|
},
|
|
285
288
|
|
|
286
289
|
configureServer(server) {
|
|
290
|
+
const invalidateLoopConfig = () => {
|
|
291
|
+
const entry = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ENTRY_ID)
|
|
292
|
+
if (entry) server.moduleGraph.invalidateModule(entry)
|
|
293
|
+
}
|
|
294
|
+
const loopFileChanged = path => {
|
|
295
|
+
if (resolve(path) === resolve(root, "loop-config.json")) invalidateLoopConfig()
|
|
296
|
+
}
|
|
297
|
+
// New page loads see the saved file without interrupting the current presentation.
|
|
298
|
+
server.watcher
|
|
299
|
+
.on("change", loopFileChanged)
|
|
300
|
+
.on("add", loopFileChanged)
|
|
301
|
+
.on("unlink", loopFileChanged)
|
|
302
|
+
server.middlewares.use(loopConfigMiddleware(root, invalidateLoopConfig))
|
|
287
303
|
server.middlewares.use(async (req, res, next) => {
|
|
288
304
|
if (req.method !== "GET" || req.url !== "/__promptslide_pdf") return next()
|
|
289
305
|
try {
|
|
@@ -3,6 +3,6 @@ import { SlideTitle } from "@/slides/slide-title";
|
|
|
3
3
|
import { SlideExample } from "@/slides/slide-example";
|
|
4
4
|
|
|
5
5
|
export const slides: SlideConfig[] = [
|
|
6
|
-
{ id: "title", component: SlideTitle, steps: 0 },
|
|
7
|
-
{ id: "example", component: SlideExample, steps: 2 },
|
|
6
|
+
{ id: "title", title: "Introduction", component: SlideTitle, steps: 0 },
|
|
7
|
+
{ id: "example", title: "Example", component: SlideExample, steps: 2 },
|
|
8
8
|
];
|