@fabrika/annotations 0.2.0

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,190 @@
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+ import { useAnnotations } from './AnnotationProvider'
3
+ import { AnnotationCreatePopover } from './Popover'
4
+ import {
5
+ applyRegionStyle,
6
+ createRegionOverlay,
7
+ describeRegion,
8
+ isDrawableRegion,
9
+ REGION_HOST_ATTR,
10
+ regionBetween,
11
+ type RegionPoint,
12
+ regionPointIn,
13
+ type RegionShape,
14
+ regionTarget,
15
+ } from './regionTarget'
16
+ import { generateAnnotationId } from './utils'
17
+
18
+ interface UseRegionAnnotationOptions {
19
+ /** Container holding the image blocks a region can be drawn on. */
20
+ containerRef: React.RefObject<HTMLElement | null>
21
+ /** Re-registers the drawing listener when the canvas remounts. */
22
+ restoreKey: unknown
23
+ /** Shape a new region takes. */
24
+ shape?: RegionShape
25
+ /** Canvas file the created annotation belongs to. */
26
+ canvasFile?: string
27
+ /** Scroll container for popover positioning. */
28
+ scrollContainer?: HTMLElement | null
29
+ }
30
+
31
+ /** One region being drawn out, held outside React: the box follows the pointer
32
+ * at pointer rate, which is no reason to re-render the canvas. */
33
+ interface RegionDrag {
34
+ pointerId: number
35
+ host: HTMLElement
36
+ src: string
37
+ start: RegionPoint
38
+ shape: RegionShape
39
+ overlay: HTMLElement
40
+ id: string
41
+ }
42
+
43
+ /** A region drawn but not yet given a note. */
44
+ interface PendingRegion {
45
+ overlay: HTMLElement
46
+ id: string
47
+ snippet: string
48
+ label: string
49
+ }
50
+
51
+ /**
52
+ * Drag on an image block to annotate one part of it.
53
+ *
54
+ * An image carries no text, so a plain drag over one is unambiguous and takes
55
+ * no modifier: the selection this would compete with cannot start there. It
56
+ * still has to stay off the two things that already live on an image block —
57
+ * the whole-image comment button and any region already drawn.
58
+ */
59
+ export function useRegionAnnotation(options: UseRegionAnnotationOptions) {
60
+ const { containerRef, restoreKey, shape = 'rect', canvasFile, scrollContainer } = options
61
+ const { addAnnotationWithId } = useAnnotations()
62
+
63
+ const [pending, setPending] = useState<PendingRegion | null>(null)
64
+ const [isDrawing, setIsDrawing] = useState(false)
65
+
66
+ const addAnnotationRef = useRef(addAnnotationWithId)
67
+ addAnnotationRef.current = addAnnotationWithId
68
+ const canvasFileRef = useRef(canvasFile)
69
+ canvasFileRef.current = canvasFile
70
+ const shapeRef = useRef(shape)
71
+ shapeRef.current = shape
72
+
73
+ useEffect(() => {
74
+ const container = containerRef.current
75
+ if (!container) return
76
+
77
+ let drag: RegionDrag | null = null
78
+
79
+ /** Where the pointer is, on the region grid of `host`. */
80
+ function pointIn(host: HTMLElement, e: PointerEvent): RegionPoint | null {
81
+ const rect = host.getBoundingClientRect()
82
+ return regionPointIn(rect, e.clientX - rect.left, e.clientY - rect.top)
83
+ }
84
+
85
+ function endDrag() {
86
+ if (!drag) return
87
+ const { host, pointerId } = drag
88
+ if (host.hasPointerCapture(pointerId)) host.releasePointerCapture(pointerId)
89
+ host.removeEventListener('pointermove', onMove)
90
+ host.removeEventListener('pointerup', onUp)
91
+ host.removeEventListener('pointercancel', onCancel)
92
+ drag = null
93
+ setIsDrawing(false)
94
+ }
95
+
96
+ function onDown(e: PointerEvent) {
97
+ if (drag || e.button !== 0) return
98
+ const target = e.target
99
+ if (!(target instanceof Element)) return
100
+ // The comment button mints the whole-image annotation, and a region
101
+ // already drawn is its own click target; neither starts a new one.
102
+ if (target.closest('[data-block-comment-btn]')) return
103
+ if (target.closest('[data-annotation-id]')) return
104
+
105
+ const host = target.closest(`[${REGION_HOST_ATTR}]`)
106
+ if (!(host instanceof HTMLElement) || !container?.contains(host)) return
107
+ const src = host.closest("[data-md='image']")?.getAttribute('data-md-src')
108
+ if (!src) return
109
+ const start = pointIn(host, e)
110
+ if (!start) return
111
+
112
+ // Without this the browser starts its own image drag instead.
113
+ e.preventDefault()
114
+ host.setPointerCapture(e.pointerId)
115
+
116
+ const id = generateAnnotationId()
117
+ const overlay = createRegionOverlay({ ...start, w: 0, h: 0 }, shapeRef.current, id)
118
+ overlay.classList.add('ann-region-draft')
119
+ host.appendChild(overlay)
120
+
121
+ drag = { pointerId: e.pointerId, host, src, start, shape: shapeRef.current, overlay, id }
122
+ host.addEventListener('pointermove', onMove)
123
+ host.addEventListener('pointerup', onUp)
124
+ host.addEventListener('pointercancel', onCancel)
125
+ setIsDrawing(true)
126
+ }
127
+
128
+ function onMove(e: PointerEvent) {
129
+ if (!drag || drag.pointerId !== e.pointerId) return
130
+ const point = pointIn(drag.host, e)
131
+ if (!point) return
132
+ applyRegionStyle(drag.overlay, regionBetween(drag.start, point))
133
+ }
134
+
135
+ function onUp(e: PointerEvent) {
136
+ if (!drag || drag.pointerId !== e.pointerId) return
137
+ const { overlay, id, src, shape: drawnShape } = drag
138
+ const point = pointIn(drag.host, e) ?? drag.start
139
+ const box = regionBetween(drag.start, point)
140
+ endDrag()
141
+
142
+ if (!isDrawableRegion(box)) {
143
+ overlay.remove()
144
+ return
145
+ }
146
+ applyRegionStyle(overlay, box)
147
+ const locator = { src, shape: drawnShape, ...box }
148
+ setPending({ overlay, id, snippet: regionTarget.format(locator), label: describeRegion(locator) })
149
+ }
150
+
151
+ function onCancel(e: PointerEvent) {
152
+ if (!drag || drag.pointerId !== e.pointerId) return
153
+ drag.overlay.remove()
154
+ endDrag()
155
+ }
156
+
157
+ container.addEventListener('pointerdown', onDown)
158
+ return () => {
159
+ container.removeEventListener('pointerdown', onDown)
160
+ if (drag) {
161
+ drag.overlay.remove()
162
+ endDrag()
163
+ }
164
+ }
165
+ }, [restoreKey])
166
+
167
+ const popovers = pending
168
+ ? (
169
+ <AnnotationCreatePopover
170
+ anchorEl={pending.overlay}
171
+ scrollContainer={scrollContainer}
172
+ snippet={pending.label}
173
+ truncateAt={80}
174
+ onAdd={(note, images) => {
175
+ // The overlay already carries the annotation's real id, so committing
176
+ // is only dropping the draft look — no redraw, no flicker.
177
+ pending.overlay.classList.remove('ann-region-draft')
178
+ addAnnotationRef.current(pending.id, pending.snippet, note, undefined, undefined, images, canvasFileRef.current)
179
+ setPending(null)
180
+ }}
181
+ onCancel={() => {
182
+ pending.overlay.remove()
183
+ setPending(null)
184
+ }}
185
+ />
186
+ )
187
+ : null
188
+
189
+ return { popovers, isDrawing, isPopoverOpen: !!pending }
190
+ }
@@ -0,0 +1,208 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { restoreAnnotationTargets } from './annotationDom'
3
+ import type { Annotation, AnnotationContext } from './AnnotationProvider'
4
+ import { useAnnotations } from './AnnotationProvider'
5
+ import { rangeIndexText, renameMarkId, unwrapMarks, updateAllMarkStates, wrapRangeWithMark } from './highlightRange'
6
+ import { AnnotationCreatePopover, AnnotationEditPopover } from './Popover'
7
+ import { generateAnnotationId } from './utils'
8
+
9
+ interface UseTextAnnotationOptions {
10
+ /** Ref to the container element where text can be selected and marks are placed */
11
+ containerRef: React.RefObject<HTMLElement | null>
12
+ /** Trigger for mark restoration — when this value changes, marks are restored */
13
+ restoreKey: unknown
14
+ /** Subset of annotations to restore in this container */
15
+ restoreAnnotations: Annotation[]
16
+ /** Build AnnotationContext from a selection Range */
17
+ extractContext: (range: Range) => AnnotationContext | undefined
18
+ /** Optional filePath to associate with created annotations */
19
+ filePath?: string
20
+ /** Optional canvasFile to associate with created annotations */
21
+ canvasFile?: string
22
+ /** Scroll container for popover positioning */
23
+ scrollContainer?: HTMLElement | null
24
+ }
25
+
26
+ export function useTextAnnotation(options: UseTextAnnotationOptions) {
27
+ const { containerRef, restoreKey, restoreAnnotations, extractContext, filePath, canvasFile, scrollContainer } = options
28
+ const {
29
+ annotations: allAnnotations,
30
+ addAnnotationWithId,
31
+ removeAnnotation,
32
+ updateAnnotation,
33
+ addAnnotationImage,
34
+ removeAnnotationImage,
35
+ activeAnnotationId,
36
+ setActiveAnnotationId,
37
+ } = useAnnotations()
38
+
39
+ const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null)
40
+ const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx?: AnnotationContext } | null>(null)
41
+
42
+ // Refs for stable handlers (registered with empty or minimal deps)
43
+ const extractContextRef = useRef(extractContext)
44
+ extractContextRef.current = extractContext
45
+ const filePathRef = useRef(filePath)
46
+ filePathRef.current = filePath
47
+ const canvasFileRef = useRef(canvasFile)
48
+ canvasFileRef.current = canvasFile
49
+ const addAnnotationRef = useRef(addAnnotationWithId)
50
+ addAnnotationRef.current = addAnnotationWithId
51
+ const activeAnnotationIdRef = useRef(activeAnnotationId)
52
+ activeAnnotationIdRef.current = activeAnnotationId
53
+ const setActiveAnnotationIdRef = useRef(setActiveAnnotationId)
54
+ setActiveAnnotationIdRef.current = setActiveAnnotationId
55
+
56
+ // Decoration restoration after content renders or annotations change. Not
57
+ // only marks: every locator strategy puts its own decoration back here, so a
58
+ // host that mounts this hook gets region overlays restored too.
59
+ const restoreAnnotationsLen = restoreAnnotations.length
60
+ useEffect(() => {
61
+ if (!containerRef.current) return
62
+ const timer = setTimeout(() => {
63
+ if (containerRef.current) {
64
+ restoreAnnotationTargets(containerRef.current, restoreAnnotations)
65
+ }
66
+ }, 50)
67
+ return () => clearTimeout(timer)
68
+ }, [restoreKey, restoreAnnotationsLen])
69
+
70
+ // Mark active state tracking
71
+ const prevActiveRef = useRef<string | null>(null)
72
+ useEffect(() => {
73
+ updateAllMarkStates(activeAnnotationId, prevActiveRef.current)
74
+ prevActiveRef.current = activeAnnotationId
75
+ }, [activeAnnotationId])
76
+
77
+ // Mark click/hover/out handlers on the container (uses refs to avoid re-registration on every hover)
78
+ useEffect(() => {
79
+ const container = containerRef.current
80
+ if (!container) return
81
+
82
+ const handleClick = (e: MouseEvent) => {
83
+ const mark = (e.target as HTMLElement).closest('[data-annotation-id]') as HTMLElement | null
84
+ if (!mark) return
85
+ e.stopPropagation()
86
+ const annId = mark.getAttribute('data-annotation-id')!
87
+ if (annId === activeAnnotationIdRef.current) {
88
+ setEditPopover({ anchorEl: mark, annId })
89
+ } else {
90
+ setActiveAnnotationIdRef.current(annId)
91
+ }
92
+ }
93
+
94
+ const handleMouseOver = (e: MouseEvent) => {
95
+ const mark = (e.target as HTMLElement).closest('[data-annotation-id]') as HTMLElement | null
96
+ if (mark) setActiveAnnotationIdRef.current(mark.getAttribute('data-annotation-id')!)
97
+ }
98
+
99
+ const handleMouseOut = (e: MouseEvent) => {
100
+ const mark = (e.target as HTMLElement).closest('[data-annotation-id]')
101
+ const related = (e.relatedTarget as HTMLElement | null)?.closest?.('[data-annotation-id]')
102
+ if (mark && !related && !document.getElementById('ann-inline-popover')) setActiveAnnotationIdRef.current(null)
103
+ }
104
+
105
+ container.addEventListener('click', handleClick)
106
+ container.addEventListener('mouseover', handleMouseOver)
107
+ container.addEventListener('mouseout', handleMouseOut)
108
+ return () => {
109
+ container.removeEventListener('click', handleClick)
110
+ container.removeEventListener('mouseover', handleMouseOver)
111
+ container.removeEventListener('mouseout', handleMouseOut)
112
+ }
113
+ }, [restoreKey])
114
+
115
+ // Document-level mouseup for text selection → mark → popover
116
+ useEffect(() => {
117
+ const handler = () => handleMouseUp()
118
+ document.addEventListener('mouseup', handler)
119
+ return () => document.removeEventListener('mouseup', handler)
120
+ }, [])
121
+
122
+ const handleMouseUp = useCallback(() => {
123
+ const sel = window.getSelection()
124
+ if (!sel || sel.isCollapsed || !sel.toString().trim()) return
125
+ if (!containerRef.current) return
126
+ const range = sel.getRangeAt(0)
127
+ if (!containerRef.current.contains(range.startContainer)) return
128
+ if ((range.startContainer.parentElement as HTMLElement)?.closest?.('[data-annotation-id]')) return
129
+ // Not sel.toString(): a selection reaching over an existing annotation
130
+ // carries text the index does not hold, and a snippet naming that text can
131
+ // never be restored.
132
+ const snippet = rangeIndexText(range).trim()
133
+ if (snippet.length < 2) return
134
+
135
+ // Extract context before wrapping disturbs the DOM
136
+ const ctx = extractContextRef.current(range)
137
+ const savedRange = range.cloneRange()
138
+ const tempId = `__pending_${Date.now()}`
139
+ try {
140
+ wrapRangeWithMark(savedRange, tempId)
141
+ } catch {}
142
+ window.getSelection()?.removeAllRanges()
143
+
144
+ const marks = document.querySelectorAll(`[data-annotation-id="${tempId}"]`)
145
+ const lastMark = marks[marks.length - 1] as HTMLElement | undefined
146
+ if (!lastMark) return
147
+
148
+ setCreatePopover({ anchorEl: lastMark, tempId, snippet, ctx })
149
+ }, [])
150
+
151
+ const openCreatePopover = useCallback((anchorEl: HTMLElement, tempId: string, snippet: string, ctx?: AnnotationContext) => {
152
+ setCreatePopover({ anchorEl, tempId, snippet, ctx })
153
+ }, [])
154
+
155
+ const popovers = (
156
+ <>
157
+ {editPopover && (() => {
158
+ const ann = allAnnotations.find((a) => a.id === editPopover.annId)
159
+ if (!ann) return null
160
+ return (
161
+ <AnnotationEditPopover
162
+ anchorEl={editPopover.anchorEl}
163
+ scrollContainer={scrollContainer}
164
+ initialNote={ann.note}
165
+ initialImages={ann.images}
166
+ onUpdate={(note, images) => {
167
+ updateAnnotation(editPopover.annId, note)
168
+ // Sync images
169
+ const current = ann.images || []
170
+ for (const img of images) {
171
+ if (!current.includes(img)) addAnnotationImage(editPopover.annId, img)
172
+ }
173
+ for (const img of current) {
174
+ if (!images.includes(img)) removeAnnotationImage(editPopover.annId, img)
175
+ }
176
+ }}
177
+ onDelete={() => {
178
+ removeAnnotation(editPopover.annId)
179
+ setActiveAnnotationId(null)
180
+ }}
181
+ onClose={() => setEditPopover(null)}
182
+ />
183
+ )
184
+ })()}
185
+
186
+ {createPopover && (
187
+ <AnnotationCreatePopover
188
+ anchorEl={createPopover.anchorEl}
189
+ scrollContainer={scrollContainer}
190
+ snippet={createPopover.snippet}
191
+ truncateAt={80}
192
+ onAdd={(note, images) => {
193
+ const id = generateAnnotationId()
194
+ renameMarkId(createPopover.tempId, id)
195
+ addAnnotationRef.current(id, createPopover.snippet, note, filePathRef.current, createPopover.ctx, images, canvasFileRef.current)
196
+ setCreatePopover(null)
197
+ }}
198
+ onCancel={() => {
199
+ unwrapMarks(createPopover.tempId)
200
+ setCreatePopover(null)
201
+ }}
202
+ />
203
+ )}
204
+ </>
205
+ )
206
+
207
+ return { popovers, openCreatePopover, isPopoverOpen: !!(editPopover || createPopover) }
208
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,33 @@
1
+ /** Sentinel filePath for annotations on the agent response banner */
2
+ export const RESPONSE_ANNOTATION_PATH = '__agent-response__'
3
+
4
+ /**
5
+ * The file this annotation lives in, or undefined if it lives in no file.
6
+ * Response annotations ride in the same field under a sentinel, so a bare
7
+ * `if (ann.filePath)` opens a file tab for a path that is not one.
8
+ */
9
+ export function fileAnnotationPath(ann: { filePath?: string }): string | undefined {
10
+ return ann.filePath && ann.filePath !== RESPONSE_ANNOTATION_PATH ? ann.filePath : undefined
11
+ }
12
+
13
+ /**
14
+ * Whether the attach-image affordances are drawn at all.
15
+ *
16
+ * The button, the paste handler and the drop zone all end in one POST to the
17
+ * host's upload endpoint, so a host without one gets none of the three instead
18
+ * of controls that fail silently.
19
+ */
20
+ export function hostAcceptsUploads(host: { uploadUrl: (() => string) | null }): boolean {
21
+ return host.uploadUrl !== null
22
+ }
23
+
24
+ /** Generate a unique annotation ID */
25
+ export function generateAnnotationId(): string {
26
+ return `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
27
+ }
28
+
29
+ /** Auto-resize a textarea to fit its content */
30
+ export function autoResizeTextarea(el: HTMLTextAreaElement, minHeight?: number) {
31
+ el.style.height = 'auto'
32
+ el.style.height = (minHeight ? Math.max(minHeight, el.scrollHeight) : el.scrollHeight) + 'px'
33
+ }