promptslide 0.3.16 → 0.3.18

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.
@@ -58,6 +58,7 @@ export function SlideRenderer({
58
58
  <AnimatePresence initial={false}>
59
59
  <motion.div
60
60
  key={currentSlide}
61
+ data-slide-render-index={currentSlide}
61
62
  variants={slideVariants}
62
63
  initial="enter"
63
64
  animate="center"
@@ -75,14 +76,8 @@ export function SlideRenderer({
75
76
  totalSteps={totalSteps}
76
77
  showAllAnimations={showAllAnimations}
77
78
  >
78
- <SlideErrorBoundary
79
- slideIndex={currentSlide}
80
- slideTitle={slides[currentSlide]?.title}
81
- >
82
- <CurrentSlideComponent
83
- slideNumber={currentSlide + 1}
84
- totalSlides={slides.length}
85
- />
79
+ <SlideErrorBoundary slideIndex={currentSlide} slideTitle={slides[currentSlide]?.title}>
80
+ <CurrentSlideComponent slideNumber={currentSlide + 1} totalSlides={slides.length} />
86
81
  </SlideErrorBoundary>
87
82
  </AnimationProvider>
88
83
  </motion.div>
@@ -42,9 +42,11 @@ export interface SlideTransitionConfig {
42
42
  // =============================================================================
43
43
 
44
44
  const fadeVariants: Variants = {
45
- enter: { opacity: 0 },
46
- center: { opacity: 1 },
47
- exit: { opacity: 0 }
45
+ // Render the next slide fully beneath the current one, then fade the
46
+ // outgoing slide away. This keeps the frame opaque throughout the change.
47
+ enter: { opacity: 1, zIndex: 0 },
48
+ center: { opacity: 1, zIndex: 0 },
49
+ exit: { opacity: 0, zIndex: 1 }
48
50
  }
49
51
 
50
52
  const slideLeftVariants: Variants = {
package/src/core/types.ts CHANGED
@@ -27,6 +27,8 @@ export type SlideComponent = React.ComponentType<SlideProps>
27
27
  * eliminating the need for runtime step discovery.
28
28
  */
29
29
  export interface SlideConfig {
30
+ /** Stable identifier for links and annotations. Keep unchanged when reordering slides. */
31
+ id?: string
30
32
  /** The slide component to render */
31
33
  component: SlideComponent
32
34
  /** Number of animation steps (0 = no animations). This is the max step number used in <Animated step={N}> */
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState } from "react"
1
+ import { useCallback, useEffect, useRef, useState } from "react"
2
2
 
3
3
  import type { NavigationDirection, SlideConfig } from "./types"
4
4
 
@@ -15,6 +15,42 @@ interface NavigationState {
15
15
  direction: NavigationDirection
16
16
  }
17
17
 
18
+ const POSITION_KEY = "promptslide:position"
19
+
20
+ function slideKey(slides: SlideConfig[], index: number): string {
21
+ return slides[index]?.id || String(index + 1)
22
+ }
23
+
24
+ function slideFromHash(slides: SlideConfig[]): number | null {
25
+ if (typeof window === "undefined" || !window.location.hash) return null
26
+ let hash: string
27
+ try {
28
+ hash = decodeURIComponent(window.location.hash.slice(1))
29
+ } catch {
30
+ return null
31
+ }
32
+ const byId = slides.findIndex(slide => slide.id === hash)
33
+ if (byId >= 0) return byId
34
+ if (/^[1-9]\d*$/.test(hash)) {
35
+ const index = Number(hash) - 1
36
+ if (index < slides.length) return index
37
+ }
38
+ return null
39
+ }
40
+
41
+ function savedStep(slides: SlideConfig[], index: number): number {
42
+ if (typeof window === "undefined") return 0
43
+ try {
44
+ const saved = JSON.parse(window.sessionStorage.getItem(POSITION_KEY) || "null")
45
+ if (saved?.slide === slideKey(slides, index) && Number.isInteger(saved.step)) {
46
+ return Math.max(0, Math.min(saved.step, slides[index]?.steps ?? 0))
47
+ }
48
+ } catch {
49
+ /* Storage may be unavailable. */
50
+ }
51
+ return 0
52
+ }
53
+
18
54
  export interface UseSlideNavigationOptions {
19
55
  slides: SlideConfig[]
20
56
  initialSlide?: number
@@ -43,8 +79,12 @@ export function useSlideNavigation({
43
79
  initialSlide = 0,
44
80
  onSlideChange
45
81
  }: UseSlideNavigationOptions): UseSlideNavigationReturn {
46
- const [currentSlide, setCurrentSlide] = useState(initialSlide)
47
- const [animationStep, setAnimationStep] = useState(0)
82
+ const [currentSlide, setCurrentSlide] = useState(() => slideFromHash(slides) ?? initialSlide)
83
+ const [animationStep, setAnimationStep] = useState(() =>
84
+ savedStep(slides, slideFromHash(slides) ?? initialSlide)
85
+ )
86
+ const slidesRef = useRef(slides)
87
+ const activeKeyRef = useRef(slideKey(slides, slideFromHash(slides) ?? initialSlide))
48
88
 
49
89
  const [navState, setNavState] = useState<NavigationState>({
50
90
  status: "idle",
@@ -55,6 +95,56 @@ export function useSlideNavigation({
55
95
 
56
96
  const totalSteps = slides[currentSlide]?.steps ?? 0
57
97
 
98
+ useEffect(() => {
99
+ if (typeof window === "undefined" || slides.length === 0) return
100
+ if (slidesRef.current !== slides) {
101
+ slidesRef.current = slides
102
+ const previousKey = activeKeyRef.current
103
+ const movedIndex = slides.findIndex(slide => slide.id && slide.id === previousKey)
104
+ if (movedIndex >= 0 && movedIndex !== currentSlide) {
105
+ setCurrentSlide(movedIndex)
106
+ return
107
+ }
108
+ if (movedIndex < 0 && !/^[1-9]\d*$/.test(previousKey) && currentSlide !== 0) {
109
+ setAnimationStep(0)
110
+ setCurrentSlide(0)
111
+ return
112
+ }
113
+ }
114
+ if (!slides[currentSlide]) {
115
+ setCurrentSlide(0)
116
+ setAnimationStep(0)
117
+ return
118
+ }
119
+ const key = slideKey(slides, currentSlide)
120
+ activeKeyRef.current = key
121
+ const hash = `#${encodeURIComponent(key)}`
122
+ if (window.location.hash !== hash) {
123
+ window.history.replaceState(window.history.state, "", hash)
124
+ }
125
+ try {
126
+ window.sessionStorage.setItem(
127
+ POSITION_KEY,
128
+ JSON.stringify({ slide: key, step: animationStep })
129
+ )
130
+ } catch {
131
+ /* Storage may be unavailable. */
132
+ }
133
+ }, [slides, currentSlide, animationStep])
134
+
135
+ useEffect(() => {
136
+ const handleHashChange = () => {
137
+ const index = slideFromHash(slides) ?? 0
138
+ if (index === currentSlide) return
139
+ setNavState({ status: "transitioning", direction: index > currentSlide ? 1 : -1 })
140
+ setAnimationStep(0)
141
+ setCurrentSlide(index)
142
+ onSlideChange?.(index)
143
+ }
144
+ window.addEventListener("hashchange", handleHashChange)
145
+ return () => window.removeEventListener("hashchange", handleHashChange)
146
+ }, [slides, currentSlide, onSlideChange])
147
+
58
148
  const onTransitionComplete = useCallback(() => {
59
149
  setNavState(prev => {
60
150
  if (prev.status === "transitioning") {
@@ -0,0 +1,35 @@
1
+ # Development source editor
2
+
3
+ Run `promptslide studio` and choose **Edit** in the existing top-right toolbar. 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
+
5
+ ## Workflow
6
+
7
+ - Double-click literal text to edit it in place. Select an existing element, or use Slide elements. Shift-click selects several elements. Double-click a selected group to reach the element under the pointer; click outside the slide to deselect.
8
+ - Edit literal text and title/subtitle/eyebrow properties, appearance, image URLs, or public asset paths in Design. Arrange contains positioning and alignment controls.
9
+ - Drag selection outlines to move. The lower handle resizes; the upper handle rotates. Arrow keys nudge by one design pixel, Shift by ten. Alt disables snapping. All geometry is measured in the 1280 × 720 design canvas.
10
+ - Align selected elements along either axis or distribute three or more with equal gaps. Bring forward / Send back set stacking order.
11
+ - For images, adjust the frame size, objectFit, objectPosition, and inset crop.
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
+ - 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
+ - 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
+ - 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
+ ## Source semantics
18
+
19
+ Instrumentation adds source coordinates and a SHA-256 file revision only to the transformed development module. The original TSX is unchanged until Save. Intrinsic DOM elements and `motion.*` elements are supported. Source files must resolve inside the project's `src/` directory; symlinks outside it are rejected.
20
+
21
+ A style change belongs to a JSX definition, so it affects all instances of that definition, including shared layouts. Movement uses the individual CSS `translate` property, preserving normal flex/grid flow and existing Motion transforms; rotation uses `rotate`. Resizing sets explicit frame dimensions. It does not convert the deck into an absolute-positioned document.
22
+
23
+ For direct local `array.map(item => <p>{item.title}</p>)` expressions over literal object arrays, text edits address the selected array item. Imported arrays, spreads, ambiguous bindings, computed expressions, and mixed children are intentionally read-only. Edit those expressions in code. Simple local component text props are resolved automatically when their rendered text is selected, without requiring the component to forward DOM metadata. Ambiguous or imported prop relationships remain unsupported. Shared appearance controls require opting in when multiple matching instances are present on the current slide.
24
+
25
+ Every batch checks all file revisions and validates all resulting TSX syntax before writing. Stale edits receive HTTP 409. Export or discard the draft and reselect from the current source; there is no force-overwrite button. Deleting an element discards pending edits on that element and its descendants. Other parent/child structural edits that overlap must be saved separately. This is optimistic concurrency protection, not a filesystem transaction across external processes. A save failure rolls back files still matching this operation's output.
26
+
27
+ The endpoint uses a per-server random token, checks cross-site fetches, limits payload size and operation counts, and only runs in Vite serve mode. Studio enables editing on its default loopback interface. Passing `--host` (including `--host=...`) disables the editor, source instrumentation, and editor endpoint so shared previews cannot edit source files. The client token is a request guard, not authentication for network clients.
28
+
29
+ ## Verification
30
+
31
+ - `node --test packages/cli/test/editor.test.mjs`: source edits, escaping, conflicts, batch validation, path restrictions, and array instances.
32
+ - `node packages/cli/test/editor.browser.mjs`: isolated temporary deck; text/history, save/reload, concurrent edits, scaled dragging, and production bundle exclusion. Requires the package's Playwright Chromium installation.
33
+ - `tsc -p packages/cli/tsconfig.json --noEmit`: editor and engine types.
34
+
35
+ Implementation uses TypeScript's JSX parser and original-range patches. No OpenSlide source code is vendored.
@@ -0,0 +1,76 @@
1
+ export interface Rect {
2
+ x: number
3
+ y: number
4
+ width: number
5
+ height: number
6
+ }
7
+ export function translated(value: string, dx: number, dy: number) {
8
+ // Computed translate may contain percentages or calc() with spaces. Keep
9
+ // those units so movement does not remove centering or break after resizing.
10
+ const axes: string[] = []
11
+ let depth = 0,
12
+ start = 0
13
+ const input = value.trim()
14
+ for (let i = 0; i <= input.length; i++) {
15
+ const char = input[i]
16
+ if (char === "(") depth++
17
+ if (char === ")") depth--
18
+ if (i === input.length || (depth === 0 && /\s/.test(char!))) {
19
+ if (i > start) axes.push(input.slice(start, i))
20
+ start = i + 1
21
+ }
22
+ }
23
+ if (axes[0] === "none") axes.length = 0
24
+ const offset = (axis: string, delta: number) => {
25
+ if (!delta) return axis
26
+ if (/^-?(?:\d*\.)?\d+px$/.test(axis)) return `${parseFloat(axis) + delta}px`
27
+ return `calc(${axis} ${delta < 0 ? "-" : "+"} ${Math.abs(delta)}px)`
28
+ }
29
+ return [offset(axes[0] || "0px", dx), offset(axes[1] || "0px", dy), ...axes.slice(2)].join(" ")
30
+ }
31
+ export function snap(rect: Rect, others: Rect[], tolerance = 6) {
32
+ const xs = [0, 640, 1280, ...others.flatMap(r => [r.x, r.x + r.width / 2, r.x + r.width])]
33
+ const ys = [0, 360, 720, ...others.flatMap(r => [r.y, r.y + r.height / 2, r.y + r.height])]
34
+ const closest = (values: number[], targets: number[]) => {
35
+ let delta = tolerance + 1
36
+ let guide: number | undefined
37
+ for (const v of values)
38
+ for (const t of targets)
39
+ if (Math.abs(t - v) < Math.abs(delta)) {
40
+ delta = t - v
41
+ guide = t
42
+ }
43
+ return Math.abs(delta) <= tolerance ? { delta, guide } : { delta: 0, guide: undefined }
44
+ }
45
+ const x = closest([rect.x, rect.x + rect.width / 2, rect.x + rect.width], xs)
46
+ const y = closest([rect.y, rect.y + rect.height / 2, rect.y + rect.height], ys)
47
+ return { x: rect.x + x.delta, y: rect.y + y.delta, guideX: x.guide, guideY: y.guide }
48
+ }
49
+ export function aligned(
50
+ rects: Rect[],
51
+ axis: "x" | "y",
52
+ mode: "start" | "center" | "end" | "distribute"
53
+ ) {
54
+ const size = axis === "x" ? "width" : "height"
55
+ if (mode === "distribute") {
56
+ if (rects.length < 3) return rects.map(r => r[axis])
57
+ const sorted = rects.map((r, i) => ({ ...r, i })).sort((a, b) => a[axis] - b[axis])
58
+ const first = sorted[0]!,
59
+ last = sorted[sorted.length - 1]!
60
+ const gap =
61
+ (last[axis] + last[size] - first[axis] - sorted.reduce((s, r) => s + r[size], 0)) /
62
+ (rects.length - 1)
63
+ const result: number[] = []
64
+ let pos = first[axis]
65
+ for (const r of sorted) {
66
+ result[r.i] = pos
67
+ pos += r[size] + gap
68
+ }
69
+ return result
70
+ }
71
+ const min = Math.min(...rects.map(r => r[axis])),
72
+ max = Math.max(...rects.map(r => r[axis] + r[size]))
73
+ return rects.map(r =>
74
+ mode === "start" ? min : mode === "end" ? max - r[size] : (min + max - r[size]) / 2
75
+ )
76
+ }
@@ -0,0 +1,24 @@
1
+ import { createElement } from "react"
2
+ import { createRoot } from "react-dom/client"
3
+
4
+ import { EditorLauncher } from "./studio"
5
+
6
+ // Keep mounting outside the component module so React Fast Refresh can retain
7
+ // the open editor, its draft, and its history when the sidebar code changes.
8
+ export function mountEditor(
9
+ _slides: unknown,
10
+ _theme: unknown,
11
+ token: string,
12
+ initiallyOpen = false
13
+ ) {
14
+ if (document.getElementById("promptslide-editor-root")) return
15
+ const host = document.createElement("div")
16
+ host.id = "promptslide-editor-root"
17
+ document.body.appendChild(host)
18
+ const root = createRoot(host)
19
+ root.render(createElement(EditorLauncher, { token, initiallyOpen }))
20
+ return () => {
21
+ root.unmount()
22
+ host.remove()
23
+ }
24
+ }
@@ -0,0 +1,189 @@
1
+ import { randomBytes } from "node:crypto"
2
+ import {
3
+ readFileSync,
4
+ realpathSync,
5
+ writeFileSync,
6
+ renameSync,
7
+ statSync,
8
+ readdirSync
9
+ } from "node:fs"
10
+ import nodePath, { resolve, relative, sep, dirname } from "node:path"
11
+ import { fileURLToPath } from "node:url"
12
+
13
+ import { describe, editSource, instrument, revision } from "./source.mjs"
14
+
15
+ export function sourceModulePath(base, id, paths = nodePath) {
16
+ const path = paths.resolve(id.split("?")[0])
17
+ const rel = paths.relative(base, path)
18
+ if (
19
+ !rel ||
20
+ rel === ".." ||
21
+ rel.startsWith(".." + paths.sep) ||
22
+ paths.isAbsolute(rel) ||
23
+ !/\.[jt]sx$/.test(path)
24
+ )
25
+ return
26
+ return path
27
+ }
28
+
29
+ export function editorPlugin({ root }) {
30
+ root = realpathSync(root)
31
+ const token = randomBytes(24).toString("hex")
32
+ const base = resolve(root, "src")
33
+ const client = resolve(dirname(fileURLToPath(import.meta.url)), "mount.ts")
34
+ const clientUrl = "/@fs/" + client
35
+ function sourcePath(path) {
36
+ if (typeof path !== "string" || !/\.[jt]sx$/.test(path)) throw new Error("Invalid source path")
37
+ const resolved = realpathSync(resolve(root, path))
38
+ const realBase = realpathSync(base)
39
+ if (!resolved.startsWith(realBase + sep)) throw new Error("Source must be inside project src/")
40
+ return resolved
41
+ }
42
+ return {
43
+ name: "promptslide-editor",
44
+ apply: "serve",
45
+ enforce: "pre",
46
+ transform(code, id) {
47
+ const path = sourceModulePath(base, id)
48
+ if (!path) return
49
+ return { code: instrument(code, relative(root, path)), map: null }
50
+ },
51
+ resolveId(id) {
52
+ if (id === "virtual:promptslide-editor") return "\0" + id
53
+ },
54
+ load(id) {
55
+ if (id !== "\0virtual:promptslide-editor") return
56
+ return `import { mountEditor } from ${JSON.stringify(clientUrl)};
57
+ import { slides } from ${JSON.stringify(resolve(root, "src/deck-config.ts"))};
58
+ import { theme } from ${JSON.stringify(resolve(root, "src/theme.ts"))};
59
+ const dispose = mountEditor(slides, theme, ${JSON.stringify(token)}, import.meta.hot?.data.open ?? false);
60
+ if (import.meta.hot) {
61
+ import.meta.hot.accept();
62
+ import.meta.hot.dispose((data) => {
63
+ data.open = document.querySelector('.ps-edit-action[aria-pressed="true"]') !== null;
64
+ dispose?.();
65
+ window.dispatchEvent(new Event("promptslide:editor-refresh"));
66
+ });
67
+ }`
68
+ },
69
+ transformIndexHtml(html, ctx) {
70
+ const url = new URL(ctx.originalUrl ?? ctx.path, "http://localhost")
71
+ if (url.searchParams.get("pdf") === "true" || url.searchParams.get("export") === "true")
72
+ return
73
+ if (ctx.path !== "/" && ctx.path !== "/index.html") return
74
+ return {
75
+ html,
76
+ tags: [
77
+ {
78
+ tag: "script",
79
+ attrs: { type: "module", src: "/@id/virtual:promptslide-editor" },
80
+ injectTo: "body"
81
+ }
82
+ ]
83
+ }
84
+ },
85
+ configureServer(server) {
86
+ server.middlewares.use("/__promptslide_editor", (req, res) => {
87
+ const send = (status, data) => {
88
+ res.statusCode = status
89
+ res.setHeader("Content-Type", "application/json")
90
+ res.end(JSON.stringify(data))
91
+ }
92
+ if (
93
+ req.method !== "POST" ||
94
+ req.headers["x-promptslide-editor"] !== token ||
95
+ req.headers["sec-fetch-site"] === "cross-site"
96
+ )
97
+ return send(403, { error: "Editor access denied" })
98
+ let body = ""
99
+ req.on("data", chunk => {
100
+ body += chunk
101
+ if (body.length > 2_000_000) req.destroy()
102
+ })
103
+ req.on("end", () => {
104
+ try {
105
+ const data = JSON.parse(body)
106
+ if (data.action === "sources") {
107
+ const paths = []
108
+ function walk(directory) {
109
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
110
+ if (
111
+ entry.name.startsWith(".") ||
112
+ entry.name === "node_modules" ||
113
+ entry.isSymbolicLink()
114
+ )
115
+ continue
116
+ const path = resolve(directory, entry.name)
117
+ if (entry.isDirectory()) walk(path)
118
+ else if (entry.isFile() && /\.[jt]sx$/.test(path))
119
+ paths.push(relative(root, path))
120
+ }
121
+ }
122
+ walk(base)
123
+ return send(200, { paths: paths.sort() })
124
+ }
125
+ if (data.action === "inspect") {
126
+ const text = readFileSync(sourcePath(data.path), "utf8")
127
+ return send(200, { revision: revision(text), nodes: describe(text) })
128
+ }
129
+ if (
130
+ data.action !== "save" ||
131
+ !Array.isArray(data.files) ||
132
+ !data.files.length ||
133
+ data.files.length > 100
134
+ )
135
+ throw new Error("Invalid edit batch")
136
+ const seen = new Set()
137
+ const prepared = data.files.map(file => {
138
+ const path = sourcePath(file.path)
139
+ if (seen.has(path)) throw new Error("Duplicate file")
140
+ seen.add(path)
141
+ const before = readFileSync(path, "utf8")
142
+ if (revision(before) !== file.revision) {
143
+ const error = new Error(
144
+ `Conflict in ${file.path}. Source changed; discard or export your draft before reloading.`
145
+ )
146
+ error.status = 409
147
+ throw error
148
+ }
149
+ if (!Array.isArray(file.edits) || file.edits.length > 1000)
150
+ throw new Error("Invalid edits")
151
+ return {
152
+ path,
153
+ before,
154
+ after: editSource(before, file.edits),
155
+ mode: statSync(path).mode
156
+ }
157
+ })
158
+ // No asynchronous gap between revision checks and commits. Roll back our writes if a commit fails.
159
+ const written = []
160
+ try {
161
+ for (const file of prepared) {
162
+ if (readFileSync(file.path, "utf8") !== file.before)
163
+ throw new Error("Source changed during save")
164
+ const temp = file.path + ".promptslide-" + randomBytes(6).toString("hex")
165
+ writeFileSync(temp, file.after, { flag: "wx", mode: file.mode })
166
+ renameSync(temp, file.path)
167
+ written.push(file)
168
+ }
169
+ } catch (error) {
170
+ for (const file of written.reverse())
171
+ if (readFileSync(file.path, "utf8") === file.after)
172
+ writeFileSync(file.path, file.before)
173
+ throw error
174
+ }
175
+ // Invalidate synchronously so a reload immediately after Save cannot receive
176
+ // the old transform while the filesystem watcher is still debouncing.
177
+ for (const file of prepared) {
178
+ for (const module of server.moduleGraph?.getModulesByFile(file.path) || [])
179
+ server.moduleGraph.invalidateModule(module)
180
+ }
181
+ send(200, { saved: prepared.length })
182
+ } catch (error) {
183
+ send(error.status || 400, { error: error.message })
184
+ }
185
+ })
186
+ })
187
+ }
188
+ }
189
+ }