@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,496 @@
1
+ import { SessionContext, useAnnotationHost } from '@fabrika/annotations/runtime'
2
+ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
3
+ import { createPortal } from 'react-dom'
4
+ import { AnnotationEditor, AttachButton } from './AnnotationEditor'
5
+ import { getPopoverPosition, pxWidth } from './popoverPosition'
6
+ import { hostAcceptsUploads } from './utils'
7
+
8
+ interface PopoverProps {
9
+ anchorEl: HTMLElement
10
+ scrollContainer?: HTMLElement | null
11
+ onClose: () => void
12
+ /** Selectors that should not trigger outside-click close */
13
+ ignoreSelector?: string
14
+ zIndex?: string
15
+ padding?: string
16
+ width?: string
17
+ children: React.ReactNode
18
+ }
19
+
20
+ export function Popover(
21
+ { anchorEl, scrollContainer, onClose, ignoreSelector, zIndex = '50', padding = '12px', width = '280px', children }: PopoverProps,
22
+ ) {
23
+ const popRef = useRef<HTMLDivElement>(null)
24
+ const onCloseRef = useRef(onClose)
25
+ onCloseRef.current = onClose
26
+ // Placed by the width it is about to be rendered at, or it overflows the edge
27
+ // it was supposed to be clamped away from.
28
+ const { style: posStyle, parent } = getPopoverPosition(anchorEl, scrollContainer, pxWidth(width))
29
+
30
+ useEffect(() => {
31
+ const timer = setTimeout(() => {
32
+ const handler = (e: MouseEvent) => {
33
+ if (popRef.current && !popRef.current.contains(e.target as Node)) {
34
+ if (ignoreSelector && (e.target as HTMLElement).closest?.(ignoreSelector)) return
35
+ onCloseRef.current()
36
+ document.removeEventListener('mousedown', handler)
37
+ }
38
+ }
39
+ document.addEventListener('mousedown', handler)
40
+ return () => document.removeEventListener('mousedown', handler)
41
+ }, 0)
42
+ return () => clearTimeout(timer)
43
+ }, [ignoreSelector])
44
+
45
+ return createPortal(
46
+ <div
47
+ ref={popRef}
48
+ style={{
49
+ ...posStyle,
50
+ zIndex,
51
+ width,
52
+ background: 'var(--color-bg-elevated)',
53
+ border: '1px solid var(--color-border-hover)',
54
+ borderRadius: '8px',
55
+ boxShadow: '0 4px 12px var(--color-shadow)',
56
+ padding,
57
+ fontFamily: "'Inter', sans-serif",
58
+ }}
59
+ >
60
+ {children}
61
+ </div>,
62
+ parent,
63
+ )
64
+ }
65
+
66
+ /* ── Create/Edit annotation popover ────────────────────────── */
67
+
68
+ interface AnnotationPopoverProps {
69
+ anchorEl: HTMLElement
70
+ scrollContainer?: HTMLElement | null
71
+ snippet: string
72
+ /** Max length for displayed snippet (default 100) */
73
+ truncateAt?: number
74
+ /** Custom header instead of the default quoted snippet */
75
+ header?: React.ReactNode
76
+ /** Initial note text (for edit mode) */
77
+ initialNote?: string
78
+ /** Initial images (for edit mode) */
79
+ initialImages?: string[]
80
+ /** Called on confirm — passes note and images */
81
+ onConfirm: (note: string, images: string[]) => void
82
+ /** Called on cancel / close without saving */
83
+ onCancel: () => void
84
+ /** Called when annotation should be deleted (edit mode) */
85
+ onDelete?: () => void
86
+ }
87
+
88
+ const SUGGESTION_GROUPS: { label: string; items: string[] }[] = [
89
+ { label: 'Clarity', items: ['Unclear', 'Why?', 'Needs example', 'Missing context'] },
90
+ { label: 'Scope', items: ['Overkill', 'Out of scope', 'Important', 'Risky'] },
91
+ { label: 'Action', items: ['Simplify', 'Rethink', 'Expand on this', 'Skip this'] },
92
+ ]
93
+ const ALL_SUGGESTIONS = SUGGESTION_GROUPS.flatMap((g) => g.items)
94
+
95
+ export function AnnotationPopover({
96
+ anchorEl,
97
+ scrollContainer,
98
+ snippet,
99
+ truncateAt = 100,
100
+ header,
101
+ initialNote = '',
102
+ initialImages,
103
+ onConfirm,
104
+ onCancel,
105
+ onDelete,
106
+ }: AnnotationPopoverProps) {
107
+ const sessionId = useContext(SessionContext)
108
+ // The footer draws the editor's attach button itself, so it owes the same
109
+ // check the editor makes: no upload endpoint, no button.
110
+ const host = useAnnotationHost()
111
+ const canAttach = hostAcceptsUploads(host)
112
+ const isEditMode = initialNote !== '' || !!onDelete
113
+ const [note, setNote] = useState(initialNote)
114
+ const [images, setImages] = useState<string[]>(initialImages || [])
115
+ const [selectedIdx, setSelectedIdx] = useState<number | null>(null)
116
+ const [suppressed, setSuppressed] = useState(isEditMode)
117
+ const [deleteHover, setDeleteHover] = useState(false)
118
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null)
119
+ const openFilePickerRef = useRef<(() => void) | null>(null)
120
+
121
+ const submit = useCallback(() => {
122
+ if (note.trim() || images.length > 0) onConfirm(note.trim(), images)
123
+ else if (isEditMode && onDelete) onDelete()
124
+ else onCancel()
125
+ }, [note, images, onConfirm, onCancel, isEditMode, onDelete])
126
+
127
+ const handleClose = useCallback(() => {
128
+ if (isEditMode) {
129
+ if (!note.trim() && images.length === 0 && onDelete) {
130
+ onDelete()
131
+ onCancel()
132
+ return
133
+ }
134
+ onConfirm(note, images)
135
+ return
136
+ }
137
+ onCancel()
138
+ }, [note, images, isEditMode, onConfirm, onCancel, onDelete])
139
+
140
+ // Suggestions (only in create mode)
141
+ const showSuggestions = !isEditMode
142
+ const matches = note.trim().length > 0
143
+ ? ALL_SUGGESTIONS.filter((s) => s.toLowerCase().startsWith(note.trim().toLowerCase()))
144
+ : ALL_SUGGESTIONS
145
+ const filtered = !showSuggestions || suppressed ? [] : matches.length === 1 && matches[0]?.toLowerCase() === note.trim().toLowerCase() ? [] : matches
146
+ const matchSet = new Set(filtered)
147
+
148
+ const filteredGroups = SUGGESTION_GROUPS
149
+ .map((g) => ({ label: g.label, items: g.items.filter((s) => matchSet.has(s)) }))
150
+ .filter((g) => g.items.length > 0)
151
+
152
+ const colItems = filteredGroups.map((g) => g.items)
153
+ /** All the arrow-key math indexes columns; out of range simply means empty. */
154
+ const colLength = (c: number) => colItems[c]?.length ?? 0
155
+ const flatFiltered = colItems.flat()
156
+
157
+ const selectedCol = useMemo(() => {
158
+ if (selectedIdx === null) return 0
159
+ let count = 0
160
+ for (let c = 0; c < colItems.length; c++) {
161
+ if (selectedIdx < count + colLength(c)) return c
162
+ count += colLength(c)
163
+ }
164
+ return 0
165
+ }, [selectedIdx, colItems])
166
+ const selectedRow = useMemo(() => {
167
+ if (selectedIdx === null) return 0
168
+ let count = 0
169
+ for (let c = 0; c < selectedCol; c++) count += colLength(c)
170
+ return selectedIdx - count
171
+ }, [selectedIdx, selectedCol, colItems])
172
+
173
+ const colRowToFlat = (col: number, row: number) => {
174
+ let idx = 0
175
+ for (let c = 0; c < col; c++) idx += colLength(c)
176
+ return idx + row
177
+ }
178
+
179
+ const [prevNote, setPrevNote] = useState(note)
180
+ if (note !== prevNote) {
181
+ setPrevNote(note)
182
+ setSelectedIdx(null)
183
+ setSuppressed(isEditMode)
184
+ }
185
+
186
+ const applySuggestion = useCallback((s: string) => {
187
+ setNote(s)
188
+ }, [])
189
+
190
+ const truncated = snippet.length > truncateAt ? snippet.slice(0, truncateAt) + '...' : snippet
191
+
192
+ const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
193
+ if (flatFiltered.length > 0 && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
194
+ e.preventDefault()
195
+ if (selectedIdx === null) {
196
+ setSelectedIdx(0)
197
+ return
198
+ }
199
+ if (e.key === 'ArrowDown') {
200
+ const newRow = Math.min(selectedRow + 1, colLength(selectedCol) - 1)
201
+ setSelectedIdx(colRowToFlat(selectedCol, newRow))
202
+ } else if (e.key === 'ArrowUp') {
203
+ const newRow = Math.max(selectedRow - 1, 0)
204
+ setSelectedIdx(colRowToFlat(selectedCol, newRow))
205
+ } else if (e.key === 'ArrowRight') {
206
+ const newCol = Math.min(selectedCol + 1, colItems.length - 1)
207
+ const newRow = Math.min(selectedRow, colLength(newCol) - 1)
208
+ setSelectedIdx(colRowToFlat(newCol, newRow))
209
+ } else if (e.key === 'ArrowLeft') {
210
+ const newCol = Math.max(selectedCol - 1, 0)
211
+ const newRow = Math.min(selectedRow, colLength(newCol) - 1)
212
+ setSelectedIdx(colRowToFlat(newCol, newRow))
213
+ }
214
+ return
215
+ }
216
+ const selected = selectedIdx === null ? undefined : flatFiltered[selectedIdx]
217
+ if (selected !== undefined) {
218
+ if (e.key === 'Tab') {
219
+ e.preventDefault()
220
+ applySuggestion(selected)
221
+ return
222
+ }
223
+ if (e.key === 'Enter' && !e.shiftKey) {
224
+ e.preventDefault()
225
+ onConfirm(selected, images)
226
+ return
227
+ }
228
+ }
229
+ if (e.key === 'Enter' && !e.shiftKey) {
230
+ e.preventDefault()
231
+ submit()
232
+ return
233
+ }
234
+ if (e.key === 'Escape') {
235
+ if (flatFiltered.length > 0 && !suppressed) setSuppressed(true)
236
+ else if (isEditMode) handleClose()
237
+ else onCancel()
238
+ }
239
+ }, [
240
+ flatFiltered,
241
+ selectedIdx,
242
+ selectedRow,
243
+ selectedCol,
244
+ colItems,
245
+ colRowToFlat,
246
+ applySuggestion,
247
+ onConfirm,
248
+ images,
249
+ submit,
250
+ suppressed,
251
+ isEditMode,
252
+ handleClose,
253
+ onCancel,
254
+ ])
255
+
256
+ return (
257
+ <Popover
258
+ anchorEl={anchorEl}
259
+ scrollContainer={scrollContainer}
260
+ onClose={isEditMode ? handleClose : onCancel}
261
+ ignoreSelector={isEditMode ? '[data-annotation-id]' : undefined}
262
+ zIndex={isEditMode ? '60' : '50'}
263
+ padding={isEditMode ? '10px 12px' : '12px'}
264
+ width="420px"
265
+ >
266
+ {!isEditMode
267
+ && (header ?? (
268
+ <div
269
+ style={{
270
+ fontSize: '11px',
271
+ color: 'var(--color-text-tertiary)',
272
+ fontStyle: 'italic',
273
+ marginBottom: '8px',
274
+ lineHeight: '1.4',
275
+ display: '-webkit-box',
276
+ WebkitLineClamp: 2,
277
+ WebkitBoxOrient: 'vertical',
278
+ overflow: 'hidden',
279
+ }}
280
+ >
281
+ &ldquo;{truncated}&rdquo;
282
+ </div>
283
+ ))}
284
+
285
+ <AnnotationEditor
286
+ note={note}
287
+ onNoteChange={setNote}
288
+ images={images}
289
+ onAddImage={(path) => setImages((prev) => [...prev, path])}
290
+ onRemoveImage={(path) => setImages((prev) => prev.filter((p) => p !== path))}
291
+ sessionId={sessionId}
292
+ autoFocus
293
+ autoResize={isEditMode}
294
+ textareaRef={textareaRef}
295
+ onKeyDown={handleKeyDown}
296
+ attachButton="none"
297
+ openFilePickerRef={openFilePickerRef}
298
+ textareaStyle={isEditMode
299
+ ? {
300
+ width: '100%',
301
+ background: 'transparent',
302
+ border: 'none',
303
+ color: 'var(--color-text-primary)',
304
+ fontFamily: "'Inter', sans-serif",
305
+ fontSize: '13px',
306
+ lineHeight: '1.5',
307
+ resize: 'none',
308
+ outline: 'none',
309
+ overflow: 'hidden',
310
+ }
311
+ : {
312
+ width: '100%',
313
+ minHeight: '60px',
314
+ background: 'transparent',
315
+ border: 'none',
316
+ color: 'var(--color-text-primary)',
317
+ fontFamily: "'Inter', sans-serif",
318
+ fontSize: '13px',
319
+ lineHeight: '1.5',
320
+ resize: 'vertical',
321
+ outline: 'none',
322
+ }}
323
+ placeholder="Add your note..."
324
+ />
325
+
326
+ {/* Action buttons */}
327
+ {/* Action buttons */}
328
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: isEditMode ? '6px' : '8px' }}>
329
+ {canAttach && <AttachButton onClick={() => openFilePickerRef.current?.()} size={14} />}
330
+ <div style={{ flex: 1 }} />
331
+ {isEditMode
332
+ ? (
333
+ <button
334
+ onClick={() => {
335
+ if (onDelete) onDelete()
336
+ onCancel()
337
+ }}
338
+ onMouseEnter={() => setDeleteHover(true)}
339
+ onMouseLeave={() => setDeleteHover(false)}
340
+ style={{
341
+ fontSize: '11px',
342
+ color: deleteHover ? 'var(--color-accent-red)' : 'var(--color-text-tertiary)',
343
+ background: 'none',
344
+ border: 'none',
345
+ cursor: 'pointer',
346
+ padding: '2px 0',
347
+ fontFamily: "'Inter', sans-serif",
348
+ }}
349
+ >
350
+ Delete
351
+ </button>
352
+ )
353
+ : (
354
+ <>
355
+ <button
356
+ onClick={onCancel}
357
+ style={{
358
+ fontSize: '11px',
359
+ color: 'var(--color-text-tertiary)',
360
+ background: 'none',
361
+ border: 'none',
362
+ cursor: 'pointer',
363
+ padding: '4px 12px',
364
+ fontFamily: "'Inter', sans-serif",
365
+ }}
366
+ >
367
+ Cancel
368
+ </button>
369
+ <button
370
+ onClick={submit}
371
+ style={{
372
+ fontSize: '11px',
373
+ fontWeight: 500,
374
+ padding: '4px 12px',
375
+ borderRadius: '6px',
376
+ background: 'var(--color-highlight-bg)',
377
+ color: 'var(--color-text-primary)',
378
+ border: '1px solid var(--color-highlight-border)',
379
+ cursor: 'pointer',
380
+ fontFamily: "'Inter', sans-serif",
381
+ }}
382
+ >
383
+ Add
384
+ </button>
385
+ </>
386
+ )}
387
+ </div>
388
+
389
+ {/* Suggestions (create mode only) */}
390
+ {flatFiltered.length > 0 && (
391
+ <div style={{ marginTop: '6px', borderTop: '1px solid var(--color-border-subtle)', paddingTop: '8px', display: 'flex', gap: '4px' }}>
392
+ {filteredGroups.map((group) => (
393
+ <div key={group.label} style={{ flex: 1 }}>
394
+ <div
395
+ style={{
396
+ fontSize: '10px',
397
+ fontWeight: 600,
398
+ color: 'var(--color-text-disabled)',
399
+ textTransform: 'uppercase',
400
+ letterSpacing: '0.05em',
401
+ padding: '2px 6px',
402
+ marginBottom: '2px',
403
+ fontFamily: "'Inter', sans-serif",
404
+ }}
405
+ >
406
+ {group.label}
407
+ </div>
408
+ {group.items.map((s) => {
409
+ const flatIdx = flatFiltered.indexOf(s)
410
+ return (
411
+ <div
412
+ key={s}
413
+ onMouseDown={(e) => {
414
+ e.preventDefault()
415
+ e.stopPropagation()
416
+ applySuggestion(s)
417
+ }}
418
+ onMouseEnter={() => setSelectedIdx(flatIdx)}
419
+ style={{
420
+ padding: '3px 6px',
421
+ fontSize: '12px',
422
+ borderRadius: '4px',
423
+ fontFamily: "'Inter', sans-serif",
424
+ color: flatIdx === selectedIdx ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
425
+ background: flatIdx === selectedIdx ? 'var(--color-highlight-selected)' : 'transparent',
426
+ cursor: 'pointer',
427
+ }}
428
+ >
429
+ <span style={{ fontWeight: 500 }}>{s.slice(0, note.trim().length)}</span>
430
+ {s.slice(note.trim().length)}
431
+ </div>
432
+ )
433
+ })}
434
+ </div>
435
+ ))}
436
+ </div>
437
+ )}
438
+ </Popover>
439
+ )
440
+ }
441
+
442
+ /* ── Legacy exports for backwards compatibility ───────────── */
443
+
444
+ interface AnnotationCreatePopoverProps {
445
+ anchorEl: HTMLElement
446
+ scrollContainer?: HTMLElement | null
447
+ snippet: string
448
+ truncateAt?: number
449
+ header?: React.ReactNode
450
+ onAdd: (note: string, images?: string[]) => void
451
+ onCancel: () => void
452
+ }
453
+
454
+ export function AnnotationCreatePopover({ anchorEl, scrollContainer, snippet, truncateAt, header, onAdd, onCancel }: AnnotationCreatePopoverProps) {
455
+ return (
456
+ <AnnotationPopover
457
+ anchorEl={anchorEl}
458
+ scrollContainer={scrollContainer}
459
+ snippet={snippet}
460
+ truncateAt={truncateAt}
461
+ header={header}
462
+ onConfirm={(note, images) => onAdd(note, images)}
463
+ onCancel={onCancel}
464
+ />
465
+ )
466
+ }
467
+
468
+ interface AnnotationEditPopoverProps {
469
+ anchorEl: HTMLElement
470
+ scrollContainer?: HTMLElement | null
471
+ initialNote: string
472
+ initialImages?: string[]
473
+ onUpdate: (note: string, images: string[]) => void
474
+ onDelete: () => void
475
+ onClose: () => void
476
+ }
477
+
478
+ export function AnnotationEditPopover(
479
+ { anchorEl, scrollContainer, initialNote, initialImages, onUpdate, onDelete, onClose }: AnnotationEditPopoverProps,
480
+ ) {
481
+ return (
482
+ <AnnotationPopover
483
+ anchorEl={anchorEl}
484
+ scrollContainer={scrollContainer}
485
+ snippet=""
486
+ initialNote={initialNote}
487
+ initialImages={initialImages}
488
+ onConfirm={(note, images) => {
489
+ onUpdate(note, images)
490
+ onClose()
491
+ }}
492
+ onDelete={onDelete}
493
+ onCancel={onClose}
494
+ />
495
+ )
496
+ }
@@ -0,0 +1,130 @@
1
+ import type { AnnotationContext } from './AnnotationProvider'
2
+ import { isIndexedText } from './highlightRange'
3
+
4
+ /**
5
+ * Extract disambiguation context from a Range:
6
+ * - surrounding text (before/after the selection)
7
+ * - hierarchy path (Section title → Task label)
8
+ */
9
+ export function extractContext(range: Range, planContainer: HTMLElement): AnnotationContext {
10
+ const before = getSurroundingText(range, 'before', 60)
11
+ const after = getSurroundingText(range, 'after', 60)
12
+ const hierarchy = getHierarchy(range.startContainer, planContainer)
13
+
14
+ return { before, after, hierarchy }
15
+ }
16
+
17
+ function getSurroundingText(range: Range, direction: 'before' | 'after', maxChars: number): string {
18
+ const block = getBlockParent(direction === 'before' ? range.startContainer : range.endContainer)
19
+ if (!block) return ''
20
+
21
+ let text = ''
22
+
23
+ if (direction === 'before') {
24
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT)
25
+ let node: Text | null
26
+ while ((node = walker.nextNode() as Text | null)) {
27
+ if (node === range.startContainer) {
28
+ text += (node.textContent || '').slice(0, range.startOffset)
29
+ break
30
+ }
31
+ if (!isIndexedText(node)) continue
32
+ text += node.textContent || ''
33
+ }
34
+ if (text.length > maxChars) {
35
+ text = text.slice(-maxChars)
36
+ const spaceIdx = text.indexOf(' ')
37
+ if (spaceIdx > 0) text = text.slice(spaceIdx + 1)
38
+ }
39
+ } else {
40
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT)
41
+ let found = false
42
+ let node: Text | null
43
+ while ((node = walker.nextNode() as Text | null)) {
44
+ if (node === range.endContainer) {
45
+ text += (node.textContent || '').slice(range.endOffset)
46
+ found = true
47
+ continue
48
+ }
49
+ if (found) {
50
+ if (!isIndexedText(node)) continue
51
+ text += node.textContent || ''
52
+ }
53
+ }
54
+ if (text.length > maxChars) {
55
+ text = text.slice(0, maxChars)
56
+ const spaceIdx = text.lastIndexOf(' ')
57
+ if (spaceIdx > 0) text = text.slice(0, spaceIdx)
58
+ }
59
+ }
60
+
61
+ return text.trim()
62
+ }
63
+
64
+ function getBlockParent(node: Node): Element | null {
65
+ const blockTags = new Set(['DIV', 'P', 'LI', 'PRE', 'BLOCKQUOTE', 'SECTION', 'ARTICLE'])
66
+ let el = node.parentElement
67
+ while (el) {
68
+ if (blockTags.has(el.tagName)) return el
69
+ el = el.parentElement
70
+ }
71
+ return null
72
+ }
73
+
74
+ function getHierarchy(node: Node, planContainer: HTMLElement): string[] {
75
+ const path: string[] = []
76
+ let el = node instanceof Element ? node : node.parentElement
77
+
78
+ while (el && el !== planContainer) {
79
+ const taskId = el.getAttribute?.('data-task-id')
80
+ if (taskId) {
81
+ const label = el.querySelector('.font-semibold')
82
+ if (label) path.unshift(label.textContent?.trim() || taskId)
83
+ }
84
+
85
+ const heading = ownHeading(el)
86
+ if (heading) path.unshift(heading)
87
+
88
+ el = el.parentElement
89
+ }
90
+
91
+ return path
92
+ }
93
+
94
+ /**
95
+ * The heading this block owns, if any. Section carries its title as an
96
+ * attribute; hosts that render their own headings only have the markup, and
97
+ * there the heading must sit directly under the block's own header row —
98
+ * anything deeper belongs to a nested block. Detecting one heading and then
99
+ * reading another is what put a neighbouring section's title in the path.
100
+ */
101
+ function ownHeading(el: Element): string | null {
102
+ if (el.getAttribute('data-md') === 'section') {
103
+ const title = el.getAttribute('data-md-title')
104
+ if (title) return title
105
+ }
106
+ if (el.tagName !== 'DIV') return null
107
+ const heading = el.querySelector(':scope > button > .font-heading, :scope > div > .font-heading')
108
+ return heading?.textContent?.trim() || null
109
+ }
110
+
111
+ /**
112
+ * Format a snippet with surrounding context for disambiguation.
113
+ * Short snippets get before/after context.
114
+ */
115
+ export function formatSnippetInContext(ann: { snippet: string; context?: AnnotationContext }): string {
116
+ const snippet = ann.snippet.trim()
117
+ const ctx = ann.context
118
+
119
+ const isShort = snippet.length < 30
120
+
121
+ if (ctx && isShort && (ctx.before || ctx.after)) {
122
+ const parts: string[] = []
123
+ if (ctx.before) parts.push(`...${ctx.before} `)
124
+ parts.push(`**${snippet}**`)
125
+ if (ctx.after) parts.push(` ${ctx.after}...`)
126
+ return parts.join('')
127
+ }
128
+
129
+ return snippet
130
+ }
@@ -0,0 +1,42 @@
1
+ import { type SealedAnnotationTarget, sealTarget, type TargetAnnotation } from './annotationTarget'
2
+ import { blockTarget } from './blockTarget'
3
+ import { regionTarget } from './regionTarget'
4
+ import type { Annotation } from './runtime'
5
+ import { textTarget } from './textTarget'
6
+
7
+ export { ANNOTATABLE_SELECTOR, BLOCK_SELECTOR, getBlockSnippet } from './blockTarget'
8
+
9
+ // Text accepts every snippet, so more specific locators must run first.
10
+ export const ANNOTATION_TARGETS: SealedAnnotationTarget[] = [
11
+ sealTarget(regionTarget),
12
+ sealTarget(blockTarget),
13
+ sealTarget(textTarget),
14
+ ]
15
+
16
+ export function findSnippetElement(snippet: string, root: ParentNode): HTMLElement | null {
17
+ for (const target of ANNOTATION_TARGETS) {
18
+ const element = target.find(snippet, root)
19
+ if (element) return element
20
+ }
21
+ return null
22
+ }
23
+
24
+ export function restoreAnnotationTargets(root: HTMLElement, annotations: readonly TargetAnnotation[]): void {
25
+ for (const annotation of annotations) {
26
+ if (!annotation.snippet) continue
27
+ for (const target of ANNOTATION_TARGETS) target.restore(root, annotation)
28
+ }
29
+ }
30
+
31
+ export function describeSnippet(snippet: string): string {
32
+ for (const target of ANNOTATION_TARGETS) {
33
+ const described = target.describe(snippet)
34
+ if (described !== null) return described
35
+ }
36
+ return snippet
37
+ }
38
+
39
+ export function findAnnotationElement(annotation: Annotation, root: ParentNode = document): HTMLElement | null {
40
+ const decoration = root.querySelector(`[data-annotation-id="${CSS.escape(annotation.id)}"]`)
41
+ return decoration instanceof HTMLElement ? decoration : findSnippetElement(annotation.snippet, root)
42
+ }