promptslide 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ import type { Annotation, AnnotationsFile } from "./types"
2
+
3
+ const ENDPOINT = "/__promptslide_annotations"
4
+
5
+ export async function fetchAnnotations(): Promise<Annotation[]> {
6
+ try {
7
+ const res = await fetch(ENDPOINT)
8
+ if (!res.ok) return []
9
+ const data: AnnotationsFile = await res.json()
10
+ return data.annotations || []
11
+ } catch {
12
+ return []
13
+ }
14
+ }
15
+
16
+ export async function saveAnnotations(annotations: Annotation[]): Promise<void> {
17
+ const data: AnnotationsFile = { version: 1, annotations }
18
+ await fetch(ENDPOINT, {
19
+ method: "POST",
20
+ headers: { "Content-Type": "application/json" },
21
+ body: JSON.stringify(data)
22
+ })
23
+ }
@@ -0,0 +1,5 @@
1
+ export { AnnotationOverlay } from "./annotation-overlay"
2
+ export { AnnotationPanel } from "./annotation-panel"
3
+ export { useAnnotations } from "./use-annotations"
4
+ export { createHttpAdapter } from "./adapters/http"
5
+ export type { Annotation, AnnotationsFile, AnnotationStorageAdapter, AnnotationTarget } from "./types"
@@ -0,0 +1,76 @@
1
+ import type { AnnotationTarget } from "./types"
2
+
3
+ /**
4
+ * Build a composite target descriptor from a clicked DOM element.
5
+ */
6
+ export function buildElementTarget(
7
+ element: HTMLElement,
8
+ slideRoot: HTMLElement
9
+ ): AnnotationTarget {
10
+ const rect = element.getBoundingClientRect()
11
+ const rootRect = slideRoot.getBoundingClientRect()
12
+
13
+ const xPercent = ((rect.left + rect.width / 2 - rootRect.left) / rootRect.width) * 100
14
+ const yPercent = ((rect.top + rect.height / 2 - rootRect.top) / rootRect.height) * 100
15
+
16
+ return {
17
+ dataAnnotate: element.getAttribute("data-annotate") || undefined,
18
+ contentNearPin: getTextFingerprint(element),
19
+ position: { xPercent, yPercent }
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Resolve a stored target back to a DOM element, trying strategies in priority order.
25
+ */
26
+ export function resolveTarget(
27
+ target: AnnotationTarget,
28
+ slideRoot: HTMLElement
29
+ ): { element: HTMLElement | null; method: "dataAnnotate" | "contentNearPin" | "position" } {
30
+ // 1. Try data-annotate attribute
31
+ if (target.dataAnnotate) {
32
+ const el = slideRoot.querySelector<HTMLElement>(`[data-annotate="${target.dataAnnotate}"]`)
33
+ if (el) return { element: el, method: "dataAnnotate" }
34
+ }
35
+
36
+ // 2. Try text content match
37
+ if (target.contentNearPin) {
38
+ const match = findByTextContent(slideRoot, target.contentNearPin)
39
+ if (match) return { element: match, method: "contentNearPin" }
40
+ }
41
+
42
+ // 3. Fallback to coordinates (no element found)
43
+ return { element: null, method: "position" }
44
+ }
45
+
46
+ /**
47
+ * Extract a text fingerprint from an element (first 100 chars, trimmed).
48
+ */
49
+ function getTextFingerprint(element: HTMLElement): string | undefined {
50
+ const text = element.textContent?.trim()
51
+ if (!text) return undefined
52
+ return text.slice(0, 100)
53
+ }
54
+
55
+ /**
56
+ * Find an element by matching its text content.
57
+ */
58
+ function findByTextContent(root: HTMLElement, text: string): HTMLElement | null {
59
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
60
+ let best: HTMLElement | null = null
61
+ let bestLength = Infinity
62
+
63
+ while (walker.nextNode()) {
64
+ const el = walker.currentNode as HTMLElement
65
+ const elText = el.textContent?.trim()
66
+ if (!elText) continue
67
+
68
+ const elFingerprint = elText.slice(0, 100)
69
+ if (elFingerprint === text && elText.length < bestLength) {
70
+ best = el
71
+ bestLength = elText.length
72
+ }
73
+ }
74
+
75
+ return best
76
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Types for the slide annotation system.
3
+ * Annotations are stored as a JSON file in the project root
4
+ * and read by coding agents to act on user feedback.
5
+ */
6
+
7
+ export interface AnnotationTarget {
8
+ /** User-assigned data-annotate value, if present on the element */
9
+ dataAnnotate?: string
10
+ /** Nearby visible text to help locate the annotated area (first 100 chars, not meant to be edited) */
11
+ contentNearPin?: string
12
+ /** Coordinates as percentage of slide dimensions */
13
+ position: { xPercent: number; yPercent: number }
14
+ }
15
+
16
+ export interface Annotation {
17
+ /** Unique identifier */
18
+ id: string
19
+ /** Slide index (0-based, matching deck-config order) */
20
+ slideIndex: number
21
+ /** Slide title from SlideConfig (informational, for agent readability) */
22
+ slideTitle: string
23
+ /** Target element identification */
24
+ target: AnnotationTarget
25
+ /** The user's feedback text */
26
+ body: string
27
+ /** ISO 8601 timestamp */
28
+ createdAt: string
29
+ /** open = needs attention, resolved = agent addressed it */
30
+ status: "open" | "resolved"
31
+ /** Agent's note when resolving */
32
+ resolution?: string
33
+ }
34
+
35
+ export interface AnnotationsFile {
36
+ version: 1
37
+ annotations: Annotation[]
38
+ }
39
+
40
+ /** Storage adapter for annotation persistence */
41
+ export interface AnnotationStorageAdapter {
42
+ load(): Promise<Annotation[]>
43
+ add(annotation: Annotation): Promise<void>
44
+ remove(id: string): Promise<void>
45
+ /** Optional: subscribe to external state updates (e.g. postMessage from parent) */
46
+ subscribe?(onUpdate: (annotations: Annotation[]) => void): () => void
47
+ }
@@ -0,0 +1,50 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import { createHttpAdapter } from "./adapters/http"
3
+ import type { Annotation, AnnotationStorageAdapter, AnnotationTarget } from "./types"
4
+
5
+ export function useAnnotations(adapter?: AnnotationStorageAdapter) {
6
+ const adapterRef = useRef(adapter ?? createHttpAdapter())
7
+ const [annotations, setAnnotations] = useState<Annotation[]>([])
8
+
9
+ // Load on mount and subscribe to external updates
10
+ useEffect(() => {
11
+ adapterRef.current.load().then(setAnnotations)
12
+ return adapterRef.current.subscribe?.(setAnnotations)
13
+ }, [])
14
+
15
+ const addAnnotation = useCallback(
16
+ (slideIndex: number, slideTitle: string, target: AnnotationTarget, body: string) => {
17
+ const annotation: Annotation = {
18
+ id: crypto.randomUUID(),
19
+ slideIndex,
20
+ slideTitle,
21
+ target,
22
+ body,
23
+ createdAt: new Date().toISOString(),
24
+ status: "open"
25
+ }
26
+ setAnnotations(prev => [...prev, annotation])
27
+ adapterRef.current.add(annotation)
28
+ },
29
+ []
30
+ )
31
+
32
+ const deleteAnnotation = useCallback((id: string) => {
33
+ setAnnotations(prev => prev.filter(a => a.id !== id))
34
+ adapterRef.current.remove(id)
35
+ }, [])
36
+
37
+ const getSlideAnnotations = useCallback(
38
+ (slideIndex: number) => annotations.filter(a => a.slideIndex === slideIndex),
39
+ [annotations]
40
+ )
41
+
42
+ const openCount = useMemo(() => annotations.filter(a => a.status === "open").length, [annotations])
43
+
44
+ // Allow external state updates (e.g. from postMessage adapter)
45
+ const updateAnnotations = useCallback((updated: Annotation[]) => {
46
+ setAnnotations(updated)
47
+ }, [])
48
+
49
+ return { annotations, addAnnotation, deleteAnnotation, getSlideAnnotations, openCount, updateAnnotations }
50
+ }
package/src/core/index.ts CHANGED
@@ -73,5 +73,9 @@ export { SlideDeck } from "./slide-deck"
73
73
  // Error Boundary
74
74
  export { SlideErrorBoundary } from "./slide-error-boundary"
75
75
 
76
+ // Annotations
77
+ export { AnnotationOverlay, useAnnotations, createHttpAdapter } from "./annotations"
78
+ export type { Annotation, AnnotationsFile, AnnotationStorageAdapter, AnnotationTarget } from "./annotations"
79
+
76
80
  // Utils
77
81
  export { cn } from "./utils"
@@ -1,12 +1,14 @@
1
1
  import { LayoutGroup } from "framer-motion"
2
- import { ChevronLeft, ChevronRight, Download, Grid3X3, List, Maximize, Monitor } from "lucide-react"
3
- import { useCallback, useEffect, useRef, useState } from "react"
2
+ import { ChevronLeft, ChevronRight, Download, Grid3X3, List, Maximize, MessageCircle, Monitor } from "lucide-react"
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
4
4
 
5
5
  import type { SlideTransitionType } from "./transitions"
6
6
  import type { SlideConfig } from "./types"
7
7
 
8
8
  import { SLIDE_DIMENSIONS } from "./animation-config"
9
9
  import { AnimationProvider } from "./animation-context"
10
+ import { AnnotationOverlay, AnnotationPanel, useAnnotations } from "./annotations"
11
+ import type { Annotation, AnnotationTarget } from "./annotations"
10
12
  import { SlideErrorBoundary } from "./slide-error-boundary"
11
13
  import { SlideRenderer } from "./slide-renderer"
12
14
  import { useSlideNavigation } from "./use-slide-navigation"
@@ -22,6 +24,12 @@ interface SlideDeckProps {
22
24
  slides: SlideConfig[]
23
25
  transition?: SlideTransitionType
24
26
  directionalTransition?: boolean
27
+ /** Annotation data to display. When provided (even empty array), annotation UI is enabled. When undefined, annotation UI is hidden. */
28
+ annotations?: Annotation[]
29
+ /** Called when the user creates an annotation */
30
+ onAnnotationAdd?: (slideIndex: number, slideTitle: string, target: AnnotationTarget, body: string) => void
31
+ /** Called when the user deletes an annotation */
32
+ onAnnotationDelete?: (id: string) => void
25
33
  }
26
34
 
27
35
  // =============================================================================
@@ -66,7 +74,7 @@ function SlideExportView({ slides, slideIndex }: { slides: SlideConfig[]; slideI
66
74
  // COMPONENT
67
75
  // =============================================================================
68
76
 
69
- export function SlideDeck({ slides, transition, directionalTransition }: SlideDeckProps) {
77
+ export function SlideDeck({ slides, transition, directionalTransition, annotations, onAnnotationAdd, onAnnotationDelete }: SlideDeckProps) {
70
78
  // Check for export mode via URL params
71
79
  const [exportParams] = useState(() => {
72
80
  if (typeof window === "undefined") return null
@@ -79,10 +87,27 @@ export function SlideDeck({ slides, transition, directionalTransition }: SlideDe
79
87
  return <SlideExportView slides={slides} slideIndex={exportParams.slideIndex} />
80
88
  }
81
89
 
90
+ // Use internal useAnnotations as fallback when no external annotations prop is provided
91
+ const internal = useAnnotations()
92
+ const isExternallyManaged = annotations !== undefined
93
+ const effectiveAnnotations = isExternallyManaged ? annotations : internal.annotations
94
+ const effectiveAdd = isExternallyManaged ? onAnnotationAdd : internal.addAnnotation
95
+ const effectiveDelete = isExternallyManaged ? onAnnotationDelete : internal.deleteAnnotation
96
+
97
+ const openCount = useMemo(() => effectiveAnnotations.filter(a => a.status === "open").length, [effectiveAnnotations])
98
+ const getSlideAnnotations = useCallback(
99
+ (slideIndex: number) => effectiveAnnotations.filter(a => a.slideIndex === slideIndex),
100
+ [effectiveAnnotations]
101
+ )
102
+
82
103
  const [viewMode, setViewMode] = useState<ViewMode>("slide")
83
104
  const [isPresentationMode, setIsPresentationMode] = useState(false)
105
+ const [isAnnotationMode, setIsAnnotationMode] = useState(false)
106
+ const [showAnnotationPanel, setShowAnnotationPanel] = useState(false)
107
+ const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null)
84
108
  const [scale, setScale] = useState(1)
85
109
  const containerRef = useRef<HTMLDivElement>(null)
110
+ const slideContainerRef = useRef<HTMLDivElement>(null)
86
111
 
87
112
  const {
88
113
  currentSlide,
@@ -162,7 +187,7 @@ export function SlideDeck({ slides, transition, directionalTransition }: SlideDe
162
187
  return
163
188
  }
164
189
 
165
- if (viewMode !== "slide") return
190
+ if (viewMode !== "slide" || isAnnotationMode) return
166
191
 
167
192
  if (e.key === "ArrowRight" || e.key === " ") {
168
193
  e.preventDefault()
@@ -175,7 +200,7 @@ export function SlideDeck({ slides, transition, directionalTransition }: SlideDe
175
200
 
176
201
  window.addEventListener("keydown", handleKeyDown)
177
202
  return () => window.removeEventListener("keydown", handleKeyDown)
178
- }, [advance, goBack, viewMode, togglePresentationMode])
203
+ }, [advance, goBack, viewMode, togglePresentationMode, isAnnotationMode])
179
204
 
180
205
  return (
181
206
  <div className="min-h-screen w-full bg-neutral-950 text-foreground">
@@ -204,8 +229,9 @@ export function SlideDeck({ slides, transition, directionalTransition }: SlideDe
204
229
  {/* Toolbar */}
205
230
  <div
206
231
  className={cn(
207
- "fixed top-4 right-4 z-50 flex gap-1 rounded-lg border border-neutral-800 bg-neutral-950/90 p-1 backdrop-blur-sm print:hidden",
208
- isPresentationMode && "hidden"
232
+ "fixed top-4 z-50 flex gap-1 rounded-lg border border-neutral-800 bg-neutral-950/90 p-1 backdrop-blur-sm transition-[right] print:hidden",
233
+ isPresentationMode && "hidden",
234
+ isAnnotationMode && showAnnotationPanel ? "right-[19.5rem]" : "right-4"
209
235
  )}
210
236
  >
211
237
  <button
@@ -241,6 +267,31 @@ export function SlideDeck({ slides, transition, directionalTransition }: SlideDe
241
267
 
242
268
  <div className="mx-1 w-px bg-neutral-800" />
243
269
 
270
+ <button
271
+ onClick={() => {
272
+ setIsAnnotationMode(prev => {
273
+ const next = !prev
274
+ setShowAnnotationPanel(next)
275
+ if (!next) setSelectedAnnotationId(null)
276
+ return next
277
+ })
278
+ }}
279
+ className={cn(
280
+ "relative rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white",
281
+ isAnnotationMode && "bg-[#FF6B35] text-white hover:bg-[#FF7A4A]"
282
+ )}
283
+ title="Annotate slides"
284
+ >
285
+ <MessageCircle className="h-4 w-4" />
286
+ {openCount > 0 && (
287
+ <span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-[#FF6B35] text-[10px] font-bold text-white">
288
+ {openCount}
289
+ </span>
290
+ )}
291
+ </button>
292
+
293
+ <div className="mx-1 w-px bg-neutral-800" />
294
+
244
295
  <button
245
296
  onClick={handleExportPdf}
246
297
  className="rounded-md p-2 text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white"
@@ -260,88 +311,122 @@ export function SlideDeck({ slides, transition, directionalTransition }: SlideDe
260
311
  {/* Slide View */}
261
312
  {viewMode === "slide" && (
262
313
  <div
263
- ref={containerRef}
264
- role="presentation"
265
- tabIndex={isPresentationMode ? 0 : undefined}
266
314
  className={cn(
267
- "flex h-screen w-full flex-col items-center justify-center overflow-hidden print:hidden",
268
- isPresentationMode ? "bg-black p-0" : "p-4 md:p-8"
315
+ "flex h-screen w-full print:hidden",
316
+ isPresentationMode ? "bg-black" : ""
269
317
  )}
270
- onClick={isPresentationMode ? advance : undefined}
271
- onKeyDown={
272
- isPresentationMode
273
- ? e => {
274
- if (e.key === "Enter" || e.key === " ") advance()
275
- }
276
- : undefined
277
- }
278
318
  >
279
- <LayoutGroup id="slide-deck">
280
- {isPresentationMode ? (
281
- <div
282
- className="pointer-events-none relative overflow-hidden bg-black"
283
- style={{
284
- width: SLIDE_DIMENSIONS.width,
285
- height: SLIDE_DIMENSIONS.height,
286
- transform: `scale(${scale})`,
287
- transformOrigin: "center center"
288
- }}
289
- >
290
- <SlideRenderer
291
- slides={slides}
292
- currentSlide={currentSlide}
293
- animationStep={animationStep}
294
- totalSteps={totalSteps}
295
- direction={direction}
296
- showAllAnimations={showAllAnimations}
297
- transition={transition}
298
- directionalTransition={directionalTransition}
299
- onTransitionComplete={onTransitionComplete}
300
- />
301
- </div>
302
- ) : (
303
- <div className="relative aspect-video w-full max-w-7xl overflow-hidden rounded-xl border border-neutral-800 bg-black shadow-2xl">
304
- <SlideRenderer
305
- slides={slides}
306
- currentSlide={currentSlide}
307
- animationStep={animationStep}
308
- totalSteps={totalSteps}
309
- direction={direction}
310
- showAllAnimations={showAllAnimations}
311
- transition={transition}
312
- directionalTransition={directionalTransition}
313
- onTransitionComplete={onTransitionComplete}
314
- />
315
- </div>
319
+ <div
320
+ ref={containerRef}
321
+ role="presentation"
322
+ tabIndex={isPresentationMode ? 0 : undefined}
323
+ className={cn(
324
+ "flex flex-1 flex-col items-center justify-center overflow-hidden",
325
+ isPresentationMode ? "bg-black p-0" : "p-4 md:p-8"
316
326
  )}
317
- </LayoutGroup>
318
-
319
- {/* Navigation Controls */}
320
- {!isPresentationMode && (
321
- <div className="mt-6 flex items-center gap-4">
322
- <button
323
- onClick={goBack}
324
- className="rounded-full border border-neutral-800 bg-black/50 p-2 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-neutral-900 hover:text-white"
325
- >
326
- <ChevronLeft className="h-5 w-5" />
327
- </button>
328
- <div className="flex min-w-[4rem] flex-col items-center">
329
- <span className="font-mono text-sm text-neutral-500">
330
- {currentSlide + 1} / {slides.length}
331
- </span>
332
- {slides[currentSlide]?.title && (
333
- <span className="mt-0.5 text-xs text-neutral-600">
334
- {slides[currentSlide].title}
327
+ onClick={isPresentationMode ? advance : undefined}
328
+ onKeyDown={
329
+ isPresentationMode
330
+ ? e => {
331
+ if (e.key === "Enter" || e.key === " ") advance()
332
+ }
333
+ : undefined
334
+ }
335
+ >
336
+ <LayoutGroup id="slide-deck">
337
+ {isPresentationMode ? (
338
+ <div
339
+ className="pointer-events-none relative overflow-hidden bg-black"
340
+ style={{
341
+ width: SLIDE_DIMENSIONS.width,
342
+ height: SLIDE_DIMENSIONS.height,
343
+ transform: `scale(${scale})`,
344
+ transformOrigin: "center center"
345
+ }}
346
+ >
347
+ <SlideRenderer
348
+ slides={slides}
349
+ currentSlide={currentSlide}
350
+ animationStep={animationStep}
351
+ totalSteps={totalSteps}
352
+ direction={direction}
353
+ showAllAnimations={showAllAnimations}
354
+ transition={transition}
355
+ directionalTransition={directionalTransition}
356
+ onTransitionComplete={onTransitionComplete}
357
+ />
358
+ </div>
359
+ ) : (
360
+ <div ref={slideContainerRef} className="relative aspect-video w-full max-w-7xl overflow-hidden rounded-xl border border-neutral-800 bg-black shadow-2xl">
361
+ <SlideRenderer
362
+ slides={slides}
363
+ currentSlide={currentSlide}
364
+ animationStep={animationStep}
365
+ totalSteps={totalSteps}
366
+ direction={direction}
367
+ showAllAnimations={showAllAnimations}
368
+ transition={transition}
369
+ directionalTransition={directionalTransition}
370
+ onTransitionComplete={onTransitionComplete}
371
+ />
372
+ {isAnnotationMode && (
373
+ <AnnotationOverlay
374
+ slides={slides}
375
+ currentSlide={currentSlide}
376
+ slideContainerRef={slideContainerRef}
377
+ selectedId={selectedAnnotationId}
378
+ onSelectId={setSelectedAnnotationId}
379
+ onShowPanel={() => setShowAnnotationPanel(true)}
380
+ slideAnnotations={getSlideAnnotations(currentSlide)}
381
+ addAnnotation={effectiveAdd ?? (() => {})}
382
+
383
+ />
384
+ )}
385
+ </div>
386
+ )}
387
+ </LayoutGroup>
388
+
389
+ {/* Navigation Controls */}
390
+ {!isPresentationMode && (
391
+ <div className="mt-6 flex items-center gap-4">
392
+ <button
393
+ onClick={goBack}
394
+ className="rounded-full border border-neutral-800 bg-black/50 p-2 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-neutral-900 hover:text-white"
395
+ >
396
+ <ChevronLeft className="h-5 w-5" />
397
+ </button>
398
+ <div className="flex min-w-[4rem] flex-col items-center">
399
+ <span className="font-mono text-sm text-neutral-500">
400
+ {currentSlide + 1} / {slides.length}
335
401
  </span>
336
- )}
402
+ {slides[currentSlide]?.title && (
403
+ <span className="mt-0.5 text-xs text-neutral-600">
404
+ {slides[currentSlide].title}
405
+ </span>
406
+ )}
407
+ </div>
408
+ <button
409
+ onClick={advance}
410
+ className="rounded-full border border-neutral-800 bg-black/50 p-2 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-neutral-900 hover:text-white"
411
+ >
412
+ <ChevronRight className="h-5 w-5" />
413
+ </button>
337
414
  </div>
338
- <button
339
- onClick={advance}
340
- className="rounded-full border border-neutral-800 bg-black/50 p-2 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-neutral-900 hover:text-white"
341
- >
342
- <ChevronRight className="h-5 w-5" />
343
- </button>
344
- </div>
415
+ )}
416
+ </div>
417
+
418
+ {/* Annotation Panel — beside the slide */}
419
+ {isAnnotationMode && showAnnotationPanel && !isPresentationMode && (
420
+ <AnnotationPanel
421
+ annotations={getSlideAnnotations(currentSlide)}
422
+ selectedId={selectedAnnotationId}
423
+ onSelect={setSelectedAnnotationId}
424
+ onDelete={effectiveDelete ?? (() => {})}
425
+ onClose={() => {
426
+ setShowAnnotationPanel(false)
427
+ setSelectedAnnotationId(null)
428
+ }}
429
+ />
345
430
  )}
346
431
  </div>
347
432
  )}
@@ -1,3 +1,5 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs"
2
+ import { join } from "node:path"
1
3
  import { bold, dim } from "../utils/ansi.mjs"
2
4
 
3
5
  const VIRTUAL_ENTRY_ID = "virtual:promptslide-entry"
@@ -156,6 +158,16 @@ export function promptslidePlugin({ root: initialRoot } = {}) {
156
158
  name: "promptslide",
157
159
  enforce: "pre",
158
160
 
161
+ config() {
162
+ return {
163
+ server: {
164
+ watch: {
165
+ ignored: ["**/annotations.json"]
166
+ }
167
+ }
168
+ }
169
+ },
170
+
159
171
  configResolved(config) {
160
172
  if (!root) root = config.root
161
173
  },
@@ -190,6 +202,48 @@ export function promptslidePlugin({ root: initialRoot } = {}) {
190
202
  })
191
203
  })
192
204
 
205
+ // Pre-middleware: read annotations
206
+ server.middlewares.use((req, res, next) => {
207
+ if (req.method !== "GET" || req.url !== "/__promptslide_annotations") return next()
208
+
209
+ const filePath = join(root, "annotations.json")
210
+ try {
211
+ if (existsSync(filePath)) {
212
+ const content = readFileSync(filePath, "utf-8")
213
+ res.setHeader("Content-Type", "application/json")
214
+ res.statusCode = 200
215
+ res.end(content)
216
+ } else {
217
+ res.setHeader("Content-Type", "application/json")
218
+ res.statusCode = 200
219
+ res.end(JSON.stringify({ version: 1, annotations: [] }))
220
+ }
221
+ } catch {
222
+ res.statusCode = 500
223
+ res.end()
224
+ }
225
+ })
226
+
227
+ // Pre-middleware: write annotations
228
+ server.middlewares.use((req, res, next) => {
229
+ if (req.method !== "POST" || req.url !== "/__promptslide_annotations") return next()
230
+
231
+ let body = ""
232
+ req.on("data", chunk => { body += chunk })
233
+ req.on("end", () => {
234
+ try {
235
+ const data = JSON.parse(body)
236
+ const filePath = join(root, "annotations.json")
237
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8")
238
+ res.statusCode = 204
239
+ res.end()
240
+ } catch {
241
+ res.statusCode = 400
242
+ res.end()
243
+ }
244
+ })
245
+ })
246
+
193
247
  // Pre-middleware: serve /embed route
194
248
  server.middlewares.use(async (req, res, next) => {
195
249
  const url = new URL(req.url, "http://localhost")