@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Contember Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @fabrika/annotations
2
+
3
+ React annotation state, text/block/image-region locators, editors, popovers and
4
+ markdown serialization. No Bun runtime, canvas compiler, revision store or
5
+ question/response state is required. Preact hosts can map React imports to
6
+ `preact/compat`.
7
+
8
+ ## State and persistence
9
+
10
+ ```tsx
11
+ import {
12
+ AnnotationHostContext,
13
+ AnnotationProvider,
14
+ useAnnotations,
15
+ } from '@fabrika/annotations'
16
+ import type { AnnotationState } from '@fabrika/annotations'
17
+
18
+ const drafts = new Map<string, AnnotationState>()
19
+ const loadState = async (scope: string) => drafts.get(scope) ?? null
20
+ const saveState = (scope: string, state: AnnotationState) => {
21
+ drafts.set(scope, state)
22
+ }
23
+
24
+ function Notes() {
25
+ const { generalNote, setGeneralNote } = useAnnotations()
26
+ return (
27
+ <textarea
28
+ value={generalNote}
29
+ onChange={(event) => setGeneralNote(event.currentTarget.value)}
30
+ />
31
+ )
32
+ }
33
+
34
+ export function Review() {
35
+ return (
36
+ <AnnotationHostContext.Provider
37
+ value={{
38
+ sessionId: 'review',
39
+ isShared: false,
40
+ fsAvailable: false,
41
+ uploadUrl: null,
42
+ }}
43
+ >
44
+ <AnnotationProvider
45
+ scope="review:image-1"
46
+ loadState={loadState}
47
+ saveState={saveState}
48
+ >
49
+ <Notes />
50
+ </AnnotationProvider>
51
+ </AnnotationHostContext.Provider>
52
+ )
53
+ }
54
+ ```
55
+
56
+ `scope` identifies one draft. Changing it resets the draft and cancels stale loads.
57
+ The host supplies stable `loadState` and `saveState` callbacks. Persisted state
58
+ contains only `annotations` and `generalNote`; remote annotations are merged for
59
+ display but are not saved as local annotations.
60
+
61
+ Hosts with their own persistence coordinator can use `useAnnotationState` with
62
+ controlled state and provide its result through `AnnotationCtx.Provider`.
63
+
64
+ `AnnotationHost` declares upload and file capabilities. It does not load canvas
65
+ modules. `findAnnotationElement(annotation, root)` accepts a host-selected DOM
66
+ root. The optional `canvasFile` annotation metadata is retained for existing
67
+ serialized records; this package does not interpret canvas navigation or revisions.
68
+
69
+ ## Bundling and styles
70
+
71
+ An annotation locator implements `AnnotationTarget<L>`: `parse`, `format` and
72
+ `find`, with optional `restore` and `describe`. Text, document blocks and image
73
+ regions are included. Creation and lookup must use the same selector.
74
+
75
+ For one browser bundle, import the package normally. For multiple independently
76
+ built bundles, externalize `@fabrika/annotations/runtime` in **all** of them and
77
+ map it to one shared runtime bundle. That module owns annotation, session and host
78
+ contexts. Never inline a second copy into the components bundle.
79
+
80
+ Import `@fabrika/annotations/styles.css` into a Tailwind v4 stylesheet to scan the
81
+ surface classes. The host supplies theme tokens; the canvas kernel provides its
82
+ own theme through `@fabrika/canvas-kernel/styles.css`.
package/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ export { AnnotationCtx, AnnotationHostContext, SessionContext, useAnnotationHost, useAnnotations } from '@fabrika/annotations/runtime'
2
+ export { extractContext, formatSnippetInContext } from './src/annotationContext'
3
+ export {
4
+ ANNOTATABLE_SELECTOR,
5
+ ANNOTATION_TARGETS,
6
+ BLOCK_SELECTOR,
7
+ describeSnippet,
8
+ findAnnotationElement,
9
+ findSnippetElement,
10
+ getBlockSnippet,
11
+ restoreAnnotationTargets,
12
+ } from './src/annotationDom'
13
+ export { AnnotationDraftFooter } from './src/AnnotationDraftFooter'
14
+ export type { AnnotationDraftFooterProps } from './src/AnnotationDraftFooter'
15
+ export { AnnotationEditor, AttachButton, ImageThumbnails, imageToUrl } from './src/AnnotationEditor'
16
+ export { AnnotationList } from './src/AnnotationList'
17
+ export type { AnnotationListProps } from './src/AnnotationList'
18
+ export { renderAnnotation } from './src/annotationMarkdown'
19
+ export type { RenderableAnnotation } from './src/annotationMarkdown'
20
+ export { AnnotationProvider } from './src/AnnotationProvider'
21
+ export type { AnnotationProviderProps } from './src/AnnotationProvider'
22
+ export { sealTarget } from './src/annotationTarget'
23
+ export type { AnnotationTarget, SealedAnnotationTarget, TargetAnnotation } from './src/annotationTarget'
24
+ export { blockTarget } from './src/blockTarget'
25
+ export type { BlockLocator } from './src/blockTarget'
26
+ export { renameMarkId, restoreMarks, setMarkActive, unwrapMarks, updateAllMarkStates, wrapRangeWithMark } from './src/highlightRange'
27
+ export { AnnotationCreatePopover, AnnotationEditPopover, AnnotationPopover, Popover } from './src/Popover'
28
+ export {
29
+ createRegionOverlay,
30
+ describeRegion,
31
+ findRegionHost,
32
+ isDrawableRegion,
33
+ REGION_HOST_ATTR,
34
+ REGION_UNITS,
35
+ regionBetween,
36
+ regionPointIn,
37
+ regionStyle,
38
+ regionTarget,
39
+ } from './src/regionTarget'
40
+ export type { RegionBox, RegionLocator, RegionPoint, RegionShape } from './src/regionTarget'
41
+ export type {
42
+ Annotation,
43
+ AnnotationAttachment,
44
+ AnnotationAuthor,
45
+ AnnotationContext,
46
+ AnnotationContextValue,
47
+ AnnotationHost,
48
+ AnnotationState,
49
+ } from './src/runtime'
50
+ export { textTarget } from './src/textTarget'
51
+ export type { TextLocator } from './src/textTarget'
52
+ export { useAnnotationState } from './src/useAnnotationState'
53
+ export { useRegionAnnotation } from './src/useRegionAnnotation'
54
+ export { useTextAnnotation } from './src/useTextAnnotation'
55
+ export { autoResizeTextarea, fileAnnotationPath, generateAnnotationId, hostAcceptsUploads, RESPONSE_ANNOTATION_PATH } from './src/utils'
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@fabrika/annotations",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "React annotation state, text/block/region locators, editor and feedback surface.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/contember/fabrika-platform.git",
10
+ "directory": "packages/annotations"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "index.ts",
17
+ "src",
18
+ "!src/**/*.test.ts",
19
+ "!src/**/*.test.tsx"
20
+ ],
21
+ "exports": {
22
+ ".": {
23
+ "bun": "./index.ts",
24
+ "types": "./index.ts",
25
+ "default": "./index.ts"
26
+ },
27
+ "./runtime": {
28
+ "bun": "./src/runtime.ts",
29
+ "types": "./src/runtime.ts",
30
+ "default": "./src/runtime.ts"
31
+ },
32
+ "./styles.css": "./src/styles.css",
33
+ "./testing/dom.ts": {
34
+ "bun": "./src/testing/dom.ts",
35
+ "types": "./src/testing/dom.ts",
36
+ "default": "./src/testing/dom.ts"
37
+ }
38
+ },
39
+ "scripts": {
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "bun test"
42
+ },
43
+ "peerDependencies": {
44
+ "@happy-dom/global-registrator": "^20.11.2",
45
+ "react": ">=18",
46
+ "react-dom": ">=18"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "@happy-dom/global-registrator": {
50
+ "optional": true
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,84 @@
1
+ import type React from 'react'
2
+ import { useAnnotations } from './AnnotationProvider'
3
+ import { autoResizeTextarea } from './utils'
4
+
5
+ /**
6
+ * The foot of a draft: the note that belongs to no one annotation, and the
7
+ * action that sends the draft off.
8
+ *
9
+ * What counts as content, and what the payload looks like, is the host's —
10
+ * hence `hasContent` and a bare `onSubmit`. The two slots are where a host
11
+ * hangs whatever else its submit step needs (a preview button, a validation
12
+ * notice) without this component learning about it.
13
+ */
14
+ export interface AnnotationDraftFooterProps {
15
+ /** Whether the draft says anything. Hosts count more than annotations and
16
+ * the general note, so they decide. */
17
+ hasContent: boolean
18
+ onSubmit: () => void
19
+ /** Submitting an empty draft — an explicit "nothing to say", not a no-op. */
20
+ onSubmitEmpty: () => void
21
+ /** Sits between the note and the actions: validation, status, a warning. */
22
+ notice?: React.ReactNode
23
+ /** Sits left of the submit button, sharing the row. */
24
+ secondaryAction?: React.ReactNode
25
+ placeholder?: string
26
+ submitLabel?: string
27
+ emptyLabel?: string
28
+ }
29
+
30
+ export function AnnotationDraftFooter({
31
+ hasContent,
32
+ onSubmit,
33
+ onSubmitEmpty,
34
+ notice,
35
+ secondaryAction,
36
+ placeholder = 'General notes...',
37
+ submitLabel = 'Submit',
38
+ emptyLabel = 'Submit without feedback',
39
+ }: AnnotationDraftFooterProps) {
40
+ const { generalNote, setGeneralNote } = useAnnotations()
41
+
42
+ return (
43
+ <>
44
+ {/* General note — seamless */}
45
+ <div className="border-t border-border-subtle px-4 py-3 flex-shrink-0">
46
+ <textarea
47
+ value={generalNote}
48
+ onChange={(e) => setGeneralNote(e.currentTarget.value)}
49
+ className="w-full bg-transparent text-[13px] font-body text-text-primary resize-none leading-relaxed p-0 border-none ring-0 shadow-none outline-none focus:outline-none focus:ring-0 focus:border-none placeholder:text-text-disabled min-h-[40px]"
50
+ placeholder={placeholder}
51
+ onInput={(e) => autoResizeTextarea(e.currentTarget, 40)}
52
+ ref={(el) => {
53
+ if (el) autoResizeTextarea(el, 40)
54
+ }}
55
+ />
56
+ </div>
57
+
58
+ {notice}
59
+
60
+ <div className="px-4 py-3 border-t border-border-subtle flex-shrink-0">
61
+ {hasContent
62
+ ? (
63
+ <div className="flex gap-2">
64
+ {secondaryAction}
65
+ <button
66
+ onClick={onSubmit}
67
+ className="flex-1 py-2 rounded-lg font-body text-[13px] font-medium transition-all bg-btn-primary text-btn-primary-text hover:opacity-90 hover:-translate-y-px shadow-sm"
68
+ >
69
+ {submitLabel}
70
+ </button>
71
+ </div>
72
+ )
73
+ : (
74
+ <button
75
+ onClick={onSubmitEmpty}
76
+ className="w-full py-2 rounded-lg font-body text-[13px] font-medium border border-border-medium text-text-secondary hover:text-text-primary hover:border-border-hover hover:bg-bg-input transition-all"
77
+ >
78
+ {emptyLabel}
79
+ </button>
80
+ )}
81
+ </div>
82
+ </>
83
+ )
84
+ }
@@ -0,0 +1,393 @@
1
+ import { type AnnotationHost, useAnnotationHost } from '@fabrika/annotations/runtime'
2
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
3
+ import { autoResizeTextarea, hostAcceptsUploads } from './utils'
4
+
5
+ interface AnnotationEditorProps {
6
+ note: string
7
+ onNoteChange: (note: string) => void
8
+ images: string[]
9
+ onAddImage: (path: string) => void
10
+ onRemoveImage: (path: string) => void
11
+ sessionId: string
12
+
13
+ placeholder?: string
14
+ autoFocus?: boolean
15
+ autoResize?: boolean
16
+ readOnly?: boolean
17
+ onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
18
+ onTextareaClick?: (e: React.MouseEvent<HTMLTextAreaElement>) => void
19
+ textareaRef?: React.MutableRefObject<HTMLTextAreaElement | null>
20
+ textareaClassName?: string
21
+ textareaStyle?: React.CSSProperties
22
+ minHeight?: number
23
+ /** "always" (default), "on-focus" (sidebar), or "none" (popover — use openFilePickerRef) */
24
+ attachButton?: 'always' | 'on-focus' | 'none'
25
+ /** Parent can store the open-file-picker function for external trigger */
26
+ openFilePickerRef?: React.MutableRefObject<(() => void) | null>
27
+ }
28
+
29
+ export function imageToUrl(path: string): string {
30
+ // Shared-mode uploads come back from the worker as absolute URLs under
31
+ // /s/:shareId/uploads/... — return them as-is. Local daemon uploads are
32
+ // stored as absolute filesystem paths; translate to the served URL.
33
+ if (path.startsWith('/s/') || path.startsWith('http://') || path.startsWith('https://')) {
34
+ return path
35
+ }
36
+ const filename = path.split('/').pop()
37
+ return `/api/uploads/${filename}`
38
+ }
39
+
40
+ async function uploadImage(host: AnnotationHost, file: File): Promise<string | null> {
41
+ const endpoint = host.uploadUrl
42
+ if (!endpoint) return null
43
+ const formData = new FormData()
44
+ formData.append('image', file)
45
+ try {
46
+ const resp = await fetch(endpoint(), { method: 'POST', body: formData })
47
+ const data = await resp.json()
48
+ // Local daemon returns { path }; worker returns { url }. Normalise to
49
+ // a single string that `imageToUrl` can resolve.
50
+ if (host.isShared) return data.url || null
51
+ return data.path || null
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ export function AnnotationEditor({
58
+ note,
59
+ onNoteChange,
60
+ images,
61
+ onAddImage,
62
+ onRemoveImage,
63
+ sessionId,
64
+ placeholder = 'Add your note...',
65
+ autoFocus = false,
66
+ autoResize = false,
67
+ readOnly = false,
68
+ onKeyDown,
69
+ onTextareaClick,
70
+ textareaRef: externalRef,
71
+ textareaClassName,
72
+ textareaStyle,
73
+ minHeight,
74
+ attachButton = 'always',
75
+ openFilePickerRef,
76
+ }: AnnotationEditorProps) {
77
+ const host = useAnnotationHost()
78
+ // Button, paste and drop all end in the same upload; a host with no endpoint
79
+ // for it gets none of them rather than three ways to fail silently.
80
+ const canAttach = !readOnly && hostAcceptsUploads(host)
81
+ const [isDragging, setIsDragging] = useState(false)
82
+ const [isUploading, setIsUploading] = useState(false)
83
+ const [isFocused, setIsFocused] = useState(false)
84
+ const dragCounter = useRef(0)
85
+ const internalRef = useRef<HTMLTextAreaElement | null>(null)
86
+ const fileInputRef = useRef<HTMLInputElement | null>(null)
87
+
88
+ const openFilePicker = useCallback(() => fileInputRef.current?.click(), [])
89
+
90
+ // Expose file picker to parent
91
+ if (openFilePickerRef) openFilePickerRef.current = openFilePicker
92
+
93
+ const setRef = useCallback((el: HTMLTextAreaElement | null) => {
94
+ internalRef.current = el
95
+ if (externalRef) externalRef.current = el
96
+ if (el && autoResize) autoResizeTextarea(el, minHeight)
97
+ }, [externalRef, autoResize, minHeight])
98
+
99
+ useEffect(() => {
100
+ if (autoFocus && internalRef.current) {
101
+ const ta = internalRef.current
102
+ ta.focus()
103
+ if (ta.value) ta.setSelectionRange(ta.value.length, ta.value.length)
104
+ }
105
+ }, [autoFocus])
106
+
107
+ const doUpload = useCallback(async (file: File) => {
108
+ if (!file.type.startsWith('image/')) return
109
+ setIsUploading(true)
110
+ try {
111
+ const path = await uploadImage(host, file)
112
+ if (path) onAddImage(path)
113
+ } finally {
114
+ setIsUploading(false)
115
+ }
116
+ }, [host, onAddImage])
117
+
118
+ const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
119
+ for (const item of e.clipboardData?.items ?? []) {
120
+ if (item.type.startsWith('image/')) {
121
+ e.preventDefault()
122
+ const file = item.getAsFile()
123
+ if (file) doUpload(file)
124
+ return
125
+ }
126
+ }
127
+ }, [doUpload])
128
+
129
+ const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
130
+ e.preventDefault()
131
+ dragCounter.current++
132
+ if (dragCounter.current === 1) setIsDragging(true)
133
+ }, [])
134
+
135
+ const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
136
+ e.preventDefault()
137
+ }, [])
138
+
139
+ const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
140
+ e.preventDefault()
141
+ dragCounter.current--
142
+ if (dragCounter.current === 0) setIsDragging(false)
143
+ }, [])
144
+
145
+ const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
146
+ e.preventDefault()
147
+ dragCounter.current = 0
148
+ setIsDragging(false)
149
+ for (const file of e.dataTransfer?.files ?? []) {
150
+ if (file.type.startsWith('image/')) doUpload(file)
151
+ }
152
+ }, [doUpload])
153
+
154
+ const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
155
+ onNoteChange(e.currentTarget.value)
156
+ if (autoResize) {
157
+ e.currentTarget.style.height = 'auto'
158
+ e.currentTarget.style.height = (minHeight ? Math.max(minHeight, e.currentTarget.scrollHeight) : e.currentTarget.scrollHeight) + 'px'
159
+ }
160
+ }, [onNoteChange, autoResize, minHeight])
161
+
162
+ const defaultClassName =
163
+ 'w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none'
164
+ const defaultStyle: React.CSSProperties = {
165
+ width: '100%',
166
+ background: 'transparent',
167
+ border: 'none',
168
+ color: 'var(--color-text-primary)',
169
+ fontFamily: "'Inter', sans-serif",
170
+ fontSize: '13px',
171
+ lineHeight: '1.5',
172
+ outline: 'none',
173
+ ...(autoResize ? { resize: 'none' as const, overflow: 'hidden' } : { resize: 'vertical' as const }),
174
+ }
175
+
176
+ const showButton = canAttach && (
177
+ attachButton === 'always'
178
+ || (attachButton === 'on-focus' && isFocused)
179
+ )
180
+
181
+ return (
182
+ <div
183
+ style={{ position: 'relative' }}
184
+ onDragEnter={canAttach ? handleDragEnter : undefined}
185
+ onDragOver={canAttach ? handleDragOver : undefined}
186
+ onDragLeave={canAttach ? handleDragLeave : undefined}
187
+ onDrop={canAttach ? handleDrop : undefined}
188
+ >
189
+ <textarea
190
+ ref={setRef}
191
+ value={note}
192
+ onChange={readOnly ? undefined : handleChange}
193
+ onPaste={canAttach ? handlePaste : undefined}
194
+ onKeyDown={onKeyDown}
195
+ onClick={onTextareaClick}
196
+ onFocus={() => setIsFocused(true)}
197
+ onBlur={() => {
198
+ // Delay so click on attach button registers before hiding
199
+ setTimeout(() => setIsFocused(false), 150)
200
+ }}
201
+ readOnly={readOnly}
202
+ placeholder={placeholder}
203
+ className={textareaClassName ?? defaultClassName}
204
+ style={textareaStyle ?? defaultStyle}
205
+ rows={autoResize ? 1 : undefined}
206
+ />
207
+
208
+ {/* Hidden file input — always rendered for paste/drop + external trigger */}
209
+ {canAttach && (
210
+ <input
211
+ ref={fileInputRef}
212
+ type="file"
213
+ accept="image/*"
214
+ multiple
215
+ style={{ display: 'none' }}
216
+ onChange={(e) => {
217
+ const files = e.currentTarget.files
218
+ if (files) { for (const f of files) doUpload(f) }
219
+ e.currentTarget.value = ''
220
+ }}
221
+ />
222
+ )}
223
+
224
+ {/* Image thumbnails + inline attach button */}
225
+ {(images.length > 0 || showButton) && (
226
+ <div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '6px', alignItems: 'center' }}>
227
+ {images.map((img) => (
228
+ <div
229
+ key={img}
230
+ style={{
231
+ position: 'relative',
232
+ width: '56px',
233
+ height: '56px',
234
+ borderRadius: '6px',
235
+ overflow: 'hidden',
236
+ border: '1px solid var(--color-border-subtle)',
237
+ flexShrink: 0,
238
+ }}
239
+ >
240
+ <img
241
+ src={imageToUrl(img)}
242
+ alt=""
243
+ style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
244
+ />
245
+ {!readOnly && (
246
+ <button
247
+ onClick={(e) => {
248
+ e.stopPropagation()
249
+ onRemoveImage(img)
250
+ }}
251
+ style={{
252
+ position: 'absolute',
253
+ top: '2px',
254
+ right: '2px',
255
+ width: '16px',
256
+ height: '16px',
257
+ borderRadius: '50%',
258
+ background: 'rgba(0,0,0,0.6)',
259
+ color: 'white',
260
+ border: 'none',
261
+ cursor: 'pointer',
262
+ fontSize: '11px',
263
+ display: 'flex',
264
+ alignItems: 'center',
265
+ justifyContent: 'center',
266
+ lineHeight: 1,
267
+ padding: 0,
268
+ }}
269
+ title="Remove image"
270
+ >
271
+ &times;
272
+ </button>
273
+ )}
274
+ </div>
275
+ ))}
276
+ {showButton && <AttachButton onClick={openFilePicker} />}
277
+ </div>
278
+ )}
279
+
280
+ {isUploading && (
281
+ <div
282
+ style={{
283
+ fontSize: '11px',
284
+ color: 'var(--color-text-tertiary)',
285
+ fontFamily: "'Inter', sans-serif",
286
+ marginTop: '4px',
287
+ }}
288
+ >
289
+ Uploading...
290
+ </div>
291
+ )}
292
+
293
+ {isDragging && (
294
+ <div
295
+ style={{
296
+ position: 'absolute',
297
+ inset: 0,
298
+ display: 'flex',
299
+ alignItems: 'center',
300
+ justifyContent: 'center',
301
+ background: 'var(--color-highlight-bg)',
302
+ opacity: 0.9,
303
+ border: '2px dashed var(--color-highlight-border)',
304
+ borderRadius: '6px',
305
+ zIndex: 10,
306
+ pointerEvents: 'none',
307
+ fontSize: '12px',
308
+ fontFamily: "'Inter', sans-serif",
309
+ color: 'var(--color-text-secondary)',
310
+ }}
311
+ >
312
+ Drop image
313
+ </div>
314
+ )}
315
+ </div>
316
+ )
317
+ }
318
+
319
+ /** Small attach-image button — reusable in editor and popover button bar */
320
+ export function AttachButton({ onClick, size = 16 }: { onClick: () => void; size?: number }) {
321
+ return (
322
+ <button
323
+ onClick={(e) => {
324
+ e.stopPropagation()
325
+ onClick()
326
+ }}
327
+ style={{
328
+ width: `${size * 2}px`,
329
+ height: `${size * 2}px`,
330
+ borderRadius: '6px',
331
+ border: 'none',
332
+ background: 'transparent',
333
+ cursor: 'pointer',
334
+ display: 'flex',
335
+ alignItems: 'center',
336
+ justifyContent: 'center',
337
+ color: 'var(--color-text-tertiary)',
338
+ flexShrink: 0,
339
+ transition: 'color 0.15s',
340
+ }}
341
+ title="Attach image"
342
+ onMouseEnter={(e) => {
343
+ e.currentTarget.style.color = 'var(--color-text-secondary)'
344
+ }}
345
+ onMouseLeave={(e) => {
346
+ e.currentTarget.style.color = 'var(--color-text-tertiary)'
347
+ }}
348
+ >
349
+ <svg
350
+ width={size}
351
+ height={size}
352
+ viewBox="0 0 24 24"
353
+ fill="none"
354
+ stroke="currentColor"
355
+ strokeWidth="1.5"
356
+ strokeLinecap="round"
357
+ strokeLinejoin="round"
358
+ >
359
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
360
+ <circle cx="8.5" cy="8.5" r="1.5" />
361
+ <polyline points="21 15 16 10 5 21" />
362
+ </svg>
363
+ </button>
364
+ )
365
+ }
366
+
367
+ /** Read-only image thumbnails for display in read-only annotations */
368
+ export function ImageThumbnails({ images }: { images: string[] }) {
369
+ if (!images.length) return null
370
+ return (
371
+ <div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '6px' }}>
372
+ {images.map((img) => (
373
+ <div
374
+ key={img}
375
+ style={{
376
+ width: '56px',
377
+ height: '56px',
378
+ borderRadius: '6px',
379
+ overflow: 'hidden',
380
+ border: '1px solid var(--color-border-subtle)',
381
+ flexShrink: 0,
382
+ }}
383
+ >
384
+ <img
385
+ src={imageToUrl(img)}
386
+ alt=""
387
+ style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
388
+ />
389
+ </div>
390
+ ))}
391
+ </div>
392
+ )
393
+ }