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.
- package/CHANGELOG.md +14 -0
- package/README.md +16 -12
- package/dist/index.d.ts +17 -7
- package/dist/index.js +987 -211
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/commands/add.mjs +9 -2
- package/src/commands/clone.mjs +62 -22
- package/src/commands/create.mjs +8 -1
- package/src/commands/publish.mjs +34 -8
- package/src/commands/pull.mjs +66 -29
- package/src/commands/studio.mjs +3 -0
- package/src/commands/to-image.mjs +18 -5
- package/src/commands/to-pdf.mjs +27 -0
- package/src/core/animated.tsx +3 -3
- package/src/core/annotations/adapters/http.ts +8 -0
- package/src/core/annotations/annotation-overlay.tsx +77 -32
- package/src/core/annotations/annotation-panel.tsx +184 -51
- package/src/core/annotations/types.ts +5 -0
- package/src/core/annotations/use-annotations.ts +57 -7
- package/src/core/slide-deck.tsx +657 -115
- package/src/core/transitions.ts +5 -3
- package/src/core/types.ts +2 -0
- package/src/core/use-slide-navigation.ts +93 -3
- package/src/index.mjs +6 -0
- package/src/utils/deck-config.mjs +30 -11
- package/src/utils/deck-sync.mjs +78 -0
- package/src/utils/export.mjs +151 -52
- package/src/utils/registry.mjs +23 -0
- package/src/utils/studio-discovery.mjs +37 -0
- package/src/vite/plugin.mjs +146 -16
- package/templates/default/AGENTS.md +3 -2
- package/templates/default/src/deck-config.ts +2 -2
|
@@ -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: (
|
|
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({
|
|
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 {
|
|
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 =
|
|
45
|
-
|
|
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 {
|
|
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 (
|
|
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={{
|
|
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 => {
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 {
|
|
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({
|
|
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-
|
|
18
|
-
<div className="flex items-center justify-between px-
|
|
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
|
|
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
|
-
<
|
|
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-
|
|
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(
|
|
59
|
+
{open.map(a => (
|
|
53
60
|
<AnnotationItem
|
|
54
61
|
key={a.id}
|
|
55
62
|
annotation={a}
|
|
56
|
-
number={
|
|
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(
|
|
79
|
+
{resolved.map(a => (
|
|
71
80
|
<AnnotationItem
|
|
72
81
|
key={a.id}
|
|
73
82
|
annotation={a}
|
|
74
|
-
number={
|
|
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="
|
|
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]">
|
|
102
|
-
|
|
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]">
|
|
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]">
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2
|
+
|
|
3
|
+
import type { SlideConfig } from "../types"
|
|
3
4
|
import type { Annotation, AnnotationStorageAdapter, AnnotationTarget } from "./types"
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
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(
|
|
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
|
-
(
|
|
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(
|
|
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 {
|
|
91
|
+
return {
|
|
92
|
+
annotations,
|
|
93
|
+
addAnnotation,
|
|
94
|
+
deleteAnnotation,
|
|
95
|
+
updateAnnotation,
|
|
96
|
+
getSlideAnnotations,
|
|
97
|
+
openCount,
|
|
98
|
+
updateAnnotations
|
|
99
|
+
}
|
|
50
100
|
}
|