promptslide 0.3.15 → 0.3.17

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.
@@ -1,9 +1,11 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react"
2
+
2
3
  import type { SlideConfig } from "../types"
4
+ import type { Annotation, AnnotationTarget } from "./types"
5
+
3
6
  import { AnnotationForm } from "./annotation-form"
4
7
  import { AnnotationPin } from "./annotation-pin"
5
8
  import { buildElementTarget, resolveTarget } from "./selectors"
6
- import type { Annotation, AnnotationTarget } from "./types"
7
9
 
8
10
  interface AnnotationOverlayProps {
9
11
  slides: SlideConfig[]
@@ -11,10 +13,17 @@ interface AnnotationOverlayProps {
11
13
  /** Ref to the slide container element (the div wrapping SlideRenderer) */
12
14
  slideContainerRef: React.RefObject<HTMLDivElement | null>
13
15
  selectedId: string | null
16
+ hoveredId: string | null
14
17
  onSelectId: (id: string | null) => void
15
18
  onShowPanel: () => void
16
19
  slideAnnotations: Annotation[]
17
- addAnnotation: (slideIndex: number, slideTitle: string, target: AnnotationTarget, body: string) => void
20
+ addAnnotation: (
21
+ slideIndex: number,
22
+ slideTitle: string,
23
+ target: AnnotationTarget,
24
+ body: string,
25
+ slideId?: string
26
+ ) => void
18
27
  }
19
28
 
20
29
  interface PendingAnnotation {
@@ -23,7 +32,17 @@ interface PendingAnnotation {
23
32
  yPercent: number
24
33
  }
25
34
 
26
- export function AnnotationOverlay({ slides, currentSlide, slideContainerRef, selectedId, onSelectId, onShowPanel, slideAnnotations, addAnnotation }: AnnotationOverlayProps) {
35
+ export function AnnotationOverlay({
36
+ slides,
37
+ currentSlide,
38
+ slideContainerRef,
39
+ selectedId,
40
+ hoveredId,
41
+ onSelectId,
42
+ onShowPanel,
43
+ slideAnnotations,
44
+ addAnnotation
45
+ }: AnnotationOverlayProps) {
27
46
  const [pending, setPending] = useState<PendingAnnotation | null>(null)
28
47
  const [hoveredElement, setHoveredElement] = useState<DOMRect | null>(null)
29
48
  const overlayRef = useRef<HTMLDivElement>(null)
@@ -34,19 +53,31 @@ export function AnnotationOverlay({ slides, currentSlide, slideContainerRef, sel
34
53
  const resolvedAnnotations = slideAnnotations.map((a, i) => {
35
54
  const container = slideContainerRef.current
36
55
  if (!container) {
37
- return { annotation: a, xPercent: a.target.position.xPercent, yPercent: a.target.position.yPercent, number: i + 1 }
56
+ return {
57
+ annotation: a,
58
+ xPercent: a.target.position.xPercent,
59
+ yPercent: a.target.position.yPercent,
60
+ number: i + 1
61
+ }
38
62
  }
39
63
 
40
64
  const { element } = resolveTarget(a.target, container)
41
65
  if (element) {
42
66
  const rect = element.getBoundingClientRect()
43
67
  const containerRect = container.getBoundingClientRect()
44
- const xPercent = ((rect.left + rect.width / 2 - containerRect.left) / containerRect.width) * 100
45
- const yPercent = ((rect.top + rect.height / 2 - containerRect.top) / containerRect.height) * 100
68
+ const xPercent =
69
+ ((rect.left + rect.width / 2 - containerRect.left) / containerRect.width) * 100
70
+ const yPercent =
71
+ ((rect.top + rect.height / 2 - containerRect.top) / containerRect.height) * 100
46
72
  return { annotation: a, xPercent, yPercent, number: i + 1 }
47
73
  }
48
74
 
49
- return { annotation: a, xPercent: a.target.position.xPercent, yPercent: a.target.position.yPercent, number: i + 1 }
75
+ return {
76
+ annotation: a,
77
+ xPercent: a.target.position.xPercent,
78
+ yPercent: a.target.position.yPercent,
79
+ number: i + 1
80
+ }
50
81
  })
51
82
 
52
83
  const handleOverlayClick = useCallback(
@@ -68,7 +99,11 @@ export function AnnotationOverlay({ slides, currentSlide, slideContainerRef, sel
68
99
 
69
100
  // Pick the most specific meaningful element (skip tiny text nodes, pick their parent)
70
101
  let target = elementUnder
71
- if (target.tagName === "SPAN" && target.parentElement && container.contains(target.parentElement)) {
102
+ if (
103
+ target.tagName === "SPAN" &&
104
+ target.parentElement &&
105
+ container.contains(target.parentElement)
106
+ ) {
72
107
  // For inline spans, prefer their parent for a more meaningful target
73
108
  const parent = target.parentElement
74
109
  if (parent.tagName !== "DIV" || parent.children.length <= 3) {
@@ -90,11 +125,11 @@ export function AnnotationOverlay({ slides, currentSlide, slideContainerRef, sel
90
125
  const handleSubmit = useCallback(
91
126
  (text: string) => {
92
127
  if (!pending) return
93
- addAnnotation(currentSlide, slideTitle, pending.target, text)
128
+ addAnnotation(currentSlide, slideTitle, pending.target, text, slides[currentSlide]?.id)
94
129
  setPending(null)
95
130
  onShowPanel()
96
131
  },
97
- [pending, currentSlide, slideTitle, addAnnotation, onShowPanel]
132
+ [pending, currentSlide, slideTitle, slides, addAnnotation, onShowPanel]
98
133
  )
99
134
 
100
135
  // Track hover for element highlighting
@@ -116,12 +151,14 @@ export function AnnotationOverlay({ slides, currentSlide, slideContainerRef, sel
116
151
  if (elementUnder && container.contains(elementUnder) && elementUnder !== container) {
117
152
  const containerRect = container.getBoundingClientRect()
118
153
  const elRect = elementUnder.getBoundingClientRect()
154
+ const scaleX = containerRect.width / container.offsetWidth
155
+ const scaleY = containerRect.height / container.offsetHeight
119
156
  setHoveredElement(
120
157
  new DOMRect(
121
- elRect.left - containerRect.left,
122
- elRect.top - containerRect.top,
123
- elRect.width,
124
- elRect.height
158
+ (elRect.left - containerRect.left) / scaleX,
159
+ (elRect.top - containerRect.top) / scaleY,
160
+ elRect.width / scaleX,
161
+ elRect.height / scaleY
125
162
  )
126
163
  )
127
164
  } else {
@@ -159,29 +196,38 @@ export function AnnotationOverlay({ slides, currentSlide, slideContainerRef, sel
159
196
  role="button"
160
197
  tabIndex={0}
161
198
  className="absolute inset-0 z-20"
162
- style={{ cursor: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Ccircle cx='12' cy='12' r='8' stroke='%23FF6B35' stroke-width='2' opacity='0.8'/%3E%3Ccircle cx='12' cy='12' r='2' fill='%23FF6B35'/%3E%3C/svg%3E") 12 12, crosshair` }}
199
+ style={{
200
+ cursor: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Ccircle cx='12' cy='12' r='8' stroke='%23FF6B35' stroke-width='2' opacity='0.8'/%3E%3Ccircle cx='12' cy='12' r='2' fill='%23FF6B35'/%3E%3C/svg%3E") 12 12, crosshair`
201
+ }}
163
202
  onClick={handleOverlayClick}
164
- onKeyDown={e => { if (e.key === "Escape") { setPending(null); onSelectId(null) } }}
203
+ onKeyDown={e => {
204
+ if (e.key === "Escape") {
205
+ setPending(null)
206
+ onSelectId(null)
207
+ }
208
+ }}
165
209
  onMouseMove={handleMouseMove}
166
210
  onMouseLeave={() => setHoveredElement(null)}
167
211
  />
168
212
 
169
213
  {/* Annotation pins */}
170
- {resolvedAnnotations.map(({ annotation, xPercent, yPercent, number }) => (
171
- <AnnotationPin
172
- key={annotation.id}
173
- number={number}
174
- status={annotation.status}
175
- xPercent={xPercent}
176
- yPercent={yPercent}
177
- isSelected={annotation.id === selectedId}
178
- onClick={() => {
179
- onSelectId(annotation.id === selectedId ? null : annotation.id)
180
- onShowPanel()
181
- setPending(null)
182
- }}
183
- />
184
- ))}
214
+ {resolvedAnnotations
215
+ .filter(({ annotation }) => annotation.status === "open" || annotation.id === hoveredId)
216
+ .map(({ annotation, xPercent, yPercent, number }) => (
217
+ <AnnotationPin
218
+ key={annotation.id}
219
+ number={number}
220
+ status={annotation.status}
221
+ xPercent={xPercent}
222
+ yPercent={yPercent}
223
+ isSelected={annotation.id === selectedId}
224
+ onClick={() => {
225
+ onSelectId(annotation.id === selectedId ? null : annotation.id)
226
+ onShowPanel()
227
+ setPending(null)
228
+ }}
229
+ />
230
+ ))}
185
231
 
186
232
  {/* New annotation form */}
187
233
  {pending && (
@@ -192,7 +238,6 @@ export function AnnotationOverlay({ slides, currentSlide, slideContainerRef, sel
192
238
  onCancel={() => setPending(null)}
193
239
  />
194
240
  )}
195
-
196
241
  </>
197
242
  )
198
243
  }
@@ -1,44 +1,51 @@
1
- import { MessageCircle, Trash2, X } from "lucide-react"
1
+ import { Check, Pencil, PanelRightClose, RotateCcw, Trash2 } from "lucide-react"
2
+ import { useEffect, useRef, useState } from "react"
3
+
2
4
  import type { Annotation } from "./types"
3
5
 
4
6
  interface AnnotationPanelProps {
5
7
  annotations: Annotation[]
8
+ unlinkedAnnotations?: Annotation[]
6
9
  selectedId: string | null
7
10
  onSelect: (id: string) => void
11
+ onHover: (id: string | null) => void
8
12
  onDelete: (id: string) => void
13
+ onUpdate: (id: string, patch: Partial<Pick<Annotation, "body" | "status">>) => void
9
14
  onClose: () => void
10
15
  }
11
16
 
12
- export function AnnotationPanel({ annotations, selectedId, onSelect, onDelete, onClose }: AnnotationPanelProps) {
17
+ export function AnnotationPanel({
18
+ annotations,
19
+ unlinkedAnnotations = [],
20
+ selectedId,
21
+ onSelect,
22
+ onHover,
23
+ onDelete,
24
+ onUpdate,
25
+ onClose
26
+ }: AnnotationPanelProps) {
13
27
  const open = annotations.filter(a => a.status === "open")
14
28
  const resolved = annotations.filter(a => a.status === "resolved")
29
+ const total = annotations.length + unlinkedAnnotations.length
30
+ const numberFor = (annotation: Annotation) =>
31
+ annotations.findIndex(a => a.id === annotation.id) + 1
15
32
 
16
33
  return (
17
- <div className="flex h-full w-80 flex-shrink-0 flex-col border-l border-white/[0.06] bg-neutral-950/95 backdrop-blur-2xl">
18
- <div className="flex items-center justify-between px-4 py-3.5">
19
- <div className="flex items-center gap-2.5">
20
- <div className="flex h-6 w-6 items-center justify-center rounded-lg bg-[#FF6B35]/15">
21
- <MessageCircle className="h-3.5 w-3.5 text-[#FF6B35]" />
22
- </div>
23
- <span className="text-sm font-medium text-white">Annotations</span>
24
- {open.length > 0 && (
25
- <span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-[#FF6B35]/15 px-1.5 text-[11px] font-semibold text-[#FF6B35]">
26
- {open.length}
27
- </span>
28
- )}
29
- </div>
34
+ <div className="flex h-full w-80 max-w-[90vw] flex-shrink-0 flex-col border-l border-neutral-800 bg-neutral-950 text-white">
35
+ <div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
30
36
  <button
31
37
  onClick={onClose}
32
- className="rounded-lg p-1.5 text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-neutral-300"
38
+ className="rounded p-2 text-neutral-400 hover:bg-neutral-800 hover:text-white"
39
+ aria-label="Hide comments"
40
+ title="Hide comments (C)"
33
41
  >
34
- <X className="h-4 w-4" />
42
+ <PanelRightClose className="h-4 w-4" />
35
43
  </button>
44
+ <span className="text-right text-sm font-semibold">Comments ({total})</span>
36
45
  </div>
37
46
 
38
- <div className="h-px bg-gradient-to-r from-transparent via-white/[0.06] to-transparent" />
39
-
40
- <div className="flex-1 overflow-y-auto p-2">
41
- {annotations.length === 0 && (
47
+ <div className="min-h-0 flex-1 overflow-y-auto p-3">
48
+ {annotations.length === 0 && unlinkedAnnotations.length === 0 && (
42
49
  <div className="flex flex-col gap-4 px-3 py-6">
43
50
  <WorkflowHint />
44
51
  </div>
@@ -49,14 +56,16 @@ export function AnnotationPanel({ annotations, selectedId, onSelect, onDelete, o
49
56
  <div className="mb-1.5 px-2 pt-1 text-[11px] font-medium tracking-wider text-neutral-500 uppercase">
50
57
  Open
51
58
  </div>
52
- {open.map((a, i) => (
59
+ {open.map(a => (
53
60
  <AnnotationItem
54
61
  key={a.id}
55
62
  annotation={a}
56
- number={i + 1}
63
+ number={numberFor(a)}
57
64
  isSelected={a.id === selectedId}
58
65
  onSelect={() => onSelect(a.id)}
66
+ onHover={hovered => onHover(hovered ? a.id : null)}
59
67
  onDelete={() => onDelete(a.id)}
68
+ onUpdate={patch => onUpdate(a.id, patch)}
60
69
  />
61
70
  ))}
62
71
  </div>
@@ -67,23 +76,46 @@ export function AnnotationPanel({ annotations, selectedId, onSelect, onDelete, o
67
76
  <div className="mb-1.5 px-2 pt-1 text-[11px] font-medium tracking-wider text-neutral-500 uppercase">
68
77
  Resolved
69
78
  </div>
70
- {resolved.map((a, i) => (
79
+ {resolved.map(a => (
71
80
  <AnnotationItem
72
81
  key={a.id}
73
82
  annotation={a}
74
- number={open.length + i + 1}
83
+ number={numberFor(a)}
75
84
  isSelected={a.id === selectedId}
76
85
  onSelect={() => onSelect(a.id)}
86
+ onHover={hovered => onHover(hovered ? a.id : null)}
77
87
  onDelete={() => onDelete(a.id)}
88
+ onUpdate={patch => onUpdate(a.id, patch)}
78
89
  />
79
90
  ))}
80
91
  </div>
81
92
  )}
93
+ {unlinkedAnnotations.length > 0 && (
94
+ <div className="mt-3">
95
+ <div className="mb-1.5 px-2 pt-1 text-[11px] font-medium tracking-wider text-neutral-500 uppercase">
96
+ Removed slides
97
+ </div>
98
+ {unlinkedAnnotations.map((a, i) => (
99
+ <div key={a.id}>
100
+ <p className="px-2 text-[11px] text-neutral-500">{a.slideTitle || a.slideId}</p>
101
+ <AnnotationItem
102
+ annotation={a}
103
+ number={i + 1}
104
+ isSelected={a.id === selectedId}
105
+ onSelect={() => onSelect(a.id)}
106
+ onHover={hovered => onHover(hovered ? a.id : null)}
107
+ onDelete={() => onDelete(a.id)}
108
+ onUpdate={patch => onUpdate(a.id, patch)}
109
+ />
110
+ </div>
111
+ ))}
112
+ </div>
113
+ )}
82
114
  </div>
83
115
 
84
- {annotations.length > 0 && (
116
+ {annotations.length + unlinkedAnnotations.length > 0 && (
85
117
  <>
86
- <div className="h-px bg-gradient-to-r from-transparent via-white/[0.06] to-transparent" />
118
+ <div className="border-t border-neutral-800" />
87
119
  <div className="px-3 py-3">
88
120
  <WorkflowHint />
89
121
  </div>
@@ -98,16 +130,24 @@ function WorkflowHint() {
98
130
  <div className="flex flex-col gap-2">
99
131
  <p className="text-[11px] font-medium tracking-wider text-neutral-500 uppercase">Workflow</p>
100
132
  <div className="flex items-start gap-2.5">
101
- <div className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-[#FF6B35]/15 text-[10px] font-semibold text-[#FF6B35]">1</div>
102
- <p className="text-[12px] leading-snug text-neutral-400">Annotate everything you want to fix</p>
133
+ <div className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-[#FF6B35]/15 text-[10px] font-semibold text-[#FF6B35]">
134
+ 1
135
+ </div>
136
+ <p className="text-[12px] leading-snug text-neutral-400">
137
+ Comment on everything you want to fix
138
+ </p>
103
139
  </div>
104
140
  <div className="flex items-start gap-2.5">
105
- <div className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-[#FF6B35]/15 text-[10px] font-semibold text-[#FF6B35]">2</div>
141
+ <div className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-[#FF6B35]/15 text-[10px] font-semibold text-[#FF6B35]">
142
+ 2
143
+ </div>
106
144
  <p className="text-[12px] leading-snug text-neutral-400">Switch to your coding agent</p>
107
145
  </div>
108
146
  <div className="flex items-start gap-2.5">
109
- <div className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-[#FF6B35]/15 text-[10px] font-semibold text-[#FF6B35]">3</div>
110
- <p className="text-[12px] leading-snug text-neutral-400">"Fix open annotations"</p>
147
+ <div className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-[#FF6B35]/15 text-[10px] font-semibold text-[#FF6B35]">
148
+ 3
149
+ </div>
150
+ <p className="text-[12px] leading-snug text-neutral-400">"Fix open comments"</p>
111
151
  </div>
112
152
  </div>
113
153
  )
@@ -118,35 +158,89 @@ function AnnotationItem({
118
158
  number,
119
159
  isSelected,
120
160
  onSelect,
121
- onDelete
161
+ onHover,
162
+ onDelete,
163
+ onUpdate
122
164
  }: {
123
165
  annotation: Annotation
124
166
  number: number
125
167
  isSelected: boolean
126
168
  onSelect: () => void
169
+ onHover: (hovered: boolean) => void
127
170
  onDelete: () => void
171
+ onUpdate: (patch: Partial<Pick<Annotation, "body" | "status">>) => void
128
172
  }) {
173
+ const [isEditing, setIsEditing] = useState(false)
174
+ const [draft, setDraft] = useState(annotation.body)
175
+ const editRef = useRef<HTMLTextAreaElement>(null)
176
+ useEffect(() => {
177
+ if (isEditing) editRef.current?.focus()
178
+ }, [isEditing])
179
+ const saveEdit = () => {
180
+ const body = draft.trim()
181
+ if (!body) return
182
+ if (body !== annotation.body) onUpdate({ body })
183
+ setIsEditing(false)
184
+ }
185
+
129
186
  return (
130
187
  <div
131
188
  role="button"
132
189
  tabIndex={0}
133
190
  onClick={onSelect}
134
- onKeyDown={e => { if (e.key === "Enter" || e.key === " ") onSelect() }}
191
+ onMouseEnter={() => onHover(true)}
192
+ onMouseLeave={() => onHover(false)}
193
+ onKeyDown={e => {
194
+ if (e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) onSelect()
195
+ }}
135
196
  className={`group relative mb-0.5 cursor-pointer rounded-xl p-2.5 transition-all duration-150 ${
136
- isSelected
137
- ? "bg-[#FF6B35]/10 ring-1 ring-[#FF6B35]/20"
138
- : "hover:bg-white/[0.04]"
197
+ isSelected ? "bg-[#FF6B35]/10 ring-1 ring-[#FF6B35]/20" : "hover:bg-white/[0.04]"
139
198
  }`}
140
199
  >
141
- <button
142
- onClick={e => {
143
- e.stopPropagation()
144
- onDelete()
145
- }}
146
- className="absolute top-2 right-2 rounded-lg p-1 text-neutral-600 opacity-0 transition-all hover:bg-white/[0.08] hover:text-neutral-300 group-hover:opacity-100"
147
- >
148
- <Trash2 className="h-3 w-3" />
149
- </button>
200
+ <div className="absolute top-2 right-2 z-10 flex gap-0.5 rounded-full border border-neutral-700 bg-neutral-900/70 p-0.5 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
201
+ <button
202
+ type="button"
203
+ title="Edit comment"
204
+ aria-label="Edit comment"
205
+ onClick={e => {
206
+ e.stopPropagation()
207
+ setDraft(annotation.body)
208
+ setIsEditing(true)
209
+ }}
210
+ className="rounded-full p-1 text-neutral-400 hover:bg-white/[0.08] hover:text-white"
211
+ >
212
+ <Pencil className="h-3.5 w-3.5" />
213
+ </button>
214
+ <button
215
+ type="button"
216
+ title={annotation.status === "open" ? "Resolve comment" : "Reopen comment"}
217
+ aria-label={annotation.status === "open" ? "Resolve comment" : "Reopen comment"}
218
+ onClick={e => {
219
+ e.stopPropagation()
220
+ onHover(false)
221
+ onUpdate({ status: annotation.status === "open" ? "resolved" : "open" })
222
+ }}
223
+ className="rounded-full p-1 text-neutral-400 hover:bg-white/[0.08] hover:text-white"
224
+ >
225
+ {annotation.status === "open" ? (
226
+ <Check className="h-3.5 w-3.5" />
227
+ ) : (
228
+ <RotateCcw className="h-3.5 w-3.5" />
229
+ )}
230
+ </button>
231
+ <button
232
+ type="button"
233
+ title="Delete comment"
234
+ aria-label="Delete comment"
235
+ onClick={e => {
236
+ e.stopPropagation()
237
+ onDelete()
238
+ }}
239
+ className="rounded-full p-1 text-neutral-400 hover:bg-white/[0.08] hover:text-white"
240
+ >
241
+ <Trash2 className="h-3.5 w-3.5" />
242
+ </button>
243
+ </div>
150
244
  <div className="flex items-start gap-2.5">
151
245
  <div
152
246
  className={`mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-semibold ${
@@ -157,17 +251,56 @@ function AnnotationItem({
157
251
  >
158
252
  {number}
159
253
  </div>
160
- <div className="min-w-0 pr-4">
161
- <p className="text-[13px] leading-relaxed text-neutral-200">{annotation.body}</p>
254
+ <div className="min-w-0 flex-1 pr-4">
255
+ {isEditing ? (
256
+ <div>
257
+ <textarea
258
+ ref={editRef}
259
+ value={draft}
260
+ onClick={e => e.stopPropagation()}
261
+ onChange={e => setDraft(e.target.value)}
262
+ onKeyDown={e => {
263
+ if (e.key === "Escape") setIsEditing(false)
264
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") saveEdit()
265
+ }}
266
+ className="w-full resize-y rounded border border-neutral-700 bg-neutral-900 p-2 text-[13px] text-white outline-none focus:border-[#FF6B35]"
267
+ rows={3}
268
+ maxLength={4096}
269
+ />
270
+ <div className="mt-1 flex gap-2 text-xs">
271
+ <button
272
+ type="button"
273
+ onClick={e => {
274
+ e.stopPropagation()
275
+ saveEdit()
276
+ }}
277
+ disabled={!draft.trim()}
278
+ className="text-[#FF6B35] disabled:opacity-40"
279
+ >
280
+ Save
281
+ </button>
282
+ <button
283
+ type="button"
284
+ onClick={e => {
285
+ e.stopPropagation()
286
+ setIsEditing(false)
287
+ }}
288
+ className="text-neutral-400"
289
+ >
290
+ Cancel
291
+ </button>
292
+ </div>
293
+ </div>
294
+ ) : (
295
+ <p className="text-[13px] leading-relaxed text-neutral-200">{annotation.body}</p>
296
+ )}
162
297
  {annotation.target.contentNearPin && (
163
298
  <p className="mt-1 truncate text-[11px] text-neutral-600">
164
299
  {annotation.target.contentNearPin}
165
300
  </p>
166
301
  )}
167
302
  {annotation.resolution && (
168
- <p className="mt-1.5 text-[11px] text-emerald-400/80 italic">
169
- {annotation.resolution}
170
- </p>
303
+ <p className="mt-1.5 text-[11px] text-emerald-400/80 italic">{annotation.resolution}</p>
171
304
  )}
172
305
  </div>
173
306
  </div>
@@ -18,6 +18,8 @@ export interface Annotation {
18
18
  id: string
19
19
  /** Slide index (0-based, matching deck-config order) */
20
20
  slideIndex: number
21
+ /** Stable slide identity, when available. Takes precedence over slideIndex. */
22
+ slideId?: string
21
23
  /** Slide title from SlideConfig (informational, for agent readability) */
22
24
  slideTitle: string
23
25
  /** Target element identification */
@@ -42,6 +44,9 @@ export interface AnnotationStorageAdapter {
42
44
  load(): Promise<Annotation[]>
43
45
  add(annotation: Annotation): Promise<void>
44
46
  remove(id: string): Promise<void>
47
+ update?(id: string, patch: Pick<Annotation, "body" | "status">): Promise<void>
48
+ /** Persist a migration of legacy index-based annotations. */
49
+ replaceAll?(annotations: Annotation[]): Promise<void>
45
50
  /** Optional: subscribe to external state updates (e.g. postMessage from parent) */
46
51
  subscribe?(onUpdate: (annotations: Annotation[]) => void): () => void
47
52
  }
@@ -1,22 +1,50 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
- import { createHttpAdapter } from "./adapters/http"
2
+
3
+ import type { SlideConfig } from "../types"
3
4
  import type { Annotation, AnnotationStorageAdapter, AnnotationTarget } from "./types"
4
5
 
5
- export function useAnnotations(adapter?: AnnotationStorageAdapter) {
6
- const adapterRef = useRef(adapter ?? createHttpAdapter())
6
+ import { createHttpAdapter } from "./adapters/http"
7
+
8
+ export function useAnnotations(
9
+ slidesOrAdapter?: SlideConfig[] | AnnotationStorageAdapter,
10
+ adapter?: AnnotationStorageAdapter
11
+ ) {
12
+ const slides = Array.isArray(slidesOrAdapter) ? slidesOrAdapter : undefined
13
+ const storage = Array.isArray(slidesOrAdapter) ? adapter : (slidesOrAdapter ?? adapter)
14
+ const adapterRef = useRef(storage ?? createHttpAdapter())
15
+ const slidesRef = useRef(slides)
16
+ slidesRef.current = slides
7
17
  const [annotations, setAnnotations] = useState<Annotation[]>([])
8
18
 
9
19
  // Load on mount and subscribe to external updates
10
20
  useEffect(() => {
11
- adapterRef.current.load().then(setAnnotations)
21
+ adapterRef.current.load().then(loaded => {
22
+ const configuredSlides = slidesRef.current
23
+ const migrated = loaded.map(annotation => {
24
+ if (annotation.slideId || !configuredSlides) return annotation
25
+ const id = configuredSlides[annotation.slideIndex]?.id
26
+ return id ? { ...annotation, slideId: id } : annotation
27
+ })
28
+ setAnnotations(migrated)
29
+ if (migrated.some((annotation, index) => annotation !== loaded[index])) {
30
+ void adapterRef.current.replaceAll?.(migrated)
31
+ }
32
+ })
12
33
  return adapterRef.current.subscribe?.(setAnnotations)
13
34
  }, [])
14
35
 
15
36
  const addAnnotation = useCallback(
16
- (slideIndex: number, slideTitle: string, target: AnnotationTarget, body: string) => {
37
+ (
38
+ slideIndex: number,
39
+ slideTitle: string,
40
+ target: AnnotationTarget,
41
+ body: string,
42
+ slideId?: string
43
+ ) => {
17
44
  const annotation: Annotation = {
18
45
  id: crypto.randomUUID(),
19
46
  slideIndex,
47
+ ...(slideId && { slideId }),
20
48
  slideTitle,
21
49
  target,
22
50
  body,
@@ -34,17 +62,39 @@ export function useAnnotations(adapter?: AnnotationStorageAdapter) {
34
62
  adapterRef.current.remove(id)
35
63
  }, [])
36
64
 
65
+ const updateAnnotation = useCallback(
66
+ (id: string, patch: Partial<Pick<Annotation, "body" | "status">>) => {
67
+ const current = annotations.find(a => a.id === id)
68
+ if (!current) return
69
+ const updated = { body: patch.body ?? current.body, status: patch.status ?? current.status }
70
+ setAnnotations(prev => prev.map(a => (a.id === id ? { ...a, ...updated } : a)))
71
+ void adapterRef.current.update?.(id, updated)
72
+ },
73
+ [annotations]
74
+ )
75
+
37
76
  const getSlideAnnotations = useCallback(
38
77
  (slideIndex: number) => annotations.filter(a => a.slideIndex === slideIndex),
39
78
  [annotations]
40
79
  )
41
80
 
42
- const openCount = useMemo(() => annotations.filter(a => a.status === "open").length, [annotations])
81
+ const openCount = useMemo(
82
+ () => annotations.filter(a => a.status === "open").length,
83
+ [annotations]
84
+ )
43
85
 
44
86
  // Allow external state updates (e.g. from postMessage adapter)
45
87
  const updateAnnotations = useCallback((updated: Annotation[]) => {
46
88
  setAnnotations(updated)
47
89
  }, [])
48
90
 
49
- return { annotations, addAnnotation, deleteAnnotation, getSlideAnnotations, openCount, updateAnnotations }
91
+ return {
92
+ annotations,
93
+ addAnnotation,
94
+ deleteAnnotation,
95
+ updateAnnotation,
96
+ getSlideAnnotations,
97
+ openCount,
98
+ updateAnnotations
99
+ }
50
100
  }