@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,214 @@
1
+ import { SessionContext } from '@fabrika/annotations/runtime'
2
+ import { useContext, useEffect, useRef } from 'react'
3
+ import { describeSnippet } from './annotationDom'
4
+ import { AnnotationEditor, ImageThumbnails } from './AnnotationEditor'
5
+ import { useAnnotations } from './AnnotationProvider'
6
+ import type { Annotation } from './AnnotationProvider'
7
+ import { setMarkActive } from './highlightRange'
8
+
9
+ /**
10
+ * The annotations a host has collected, as a list of editable cards.
11
+ *
12
+ * Flat by design: what an annotation belongs to — a file, a screen, a section —
13
+ * is the host's taxonomy, so the host slices the annotations into groups and
14
+ * renders one list per group. The card itself is the same everywhere.
15
+ */
16
+
17
+ /** The one line a card shows for what its annotation points at. */
18
+ function snippetLabel(snippet: string): string {
19
+ const label = describeSnippet(snippet)
20
+ return label.length > 80 ? label.slice(0, 80) + '...' : label
21
+ }
22
+
23
+ export interface AnnotationListProps {
24
+ annotations: Annotation[]
25
+ /** Frozen view: notes read as text, nothing is deletable, and the list does
26
+ * not chase the active card. */
27
+ readOnly?: boolean
28
+ /** A card just became the active one — hosts reveal what it points at here. */
29
+ onSelect?: (ann: Annotation) => void
30
+ /** Backs the reveal button. Omit it and no button is drawn. */
31
+ onReveal?: (ann: Annotation) => void
32
+ /** Remote annotations ticked for submission. Pass both or neither — a host
33
+ * with no notion of remote annotations passes neither and gets none of the
34
+ * remote affordances. */
35
+ includedRemoteIds?: Set<string>
36
+ onToggleRemoteId?: (id: string) => void
37
+ }
38
+
39
+ export function AnnotationList({
40
+ annotations,
41
+ readOnly = false,
42
+ onSelect,
43
+ onReveal,
44
+ includedRemoteIds,
45
+ onToggleRemoteId,
46
+ }: AnnotationListProps) {
47
+ const {
48
+ updateAnnotation,
49
+ removeAnnotation,
50
+ addAnnotationImage,
51
+ removeAnnotationImage,
52
+ activeAnnotationId,
53
+ setActiveAnnotationId,
54
+ } = useAnnotations()
55
+ const sessionId = useContext(SessionContext)
56
+ const annRefs = useRef<Map<string, HTMLDivElement>>(new Map())
57
+
58
+ // Follow the active annotation, which is as often picked in the document as
59
+ // in this list.
60
+ useEffect(() => {
61
+ if (readOnly || !activeAnnotationId) return
62
+ const el = annRefs.current.get(activeAnnotationId)
63
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
64
+ }, [activeAnnotationId, readOnly])
65
+
66
+ // Hover on a card → highlight the mark it points at.
67
+ const handleMouseEnter = (annId: string) => {
68
+ setActiveAnnotationId(annId)
69
+ setMarkActive(annId, true)
70
+ }
71
+
72
+ const handleMouseLeave = (annId: string) => {
73
+ setActiveAnnotationId(null)
74
+ setMarkActive(annId, false)
75
+ }
76
+
77
+ const handleClick = (ann: Annotation) => {
78
+ if (ann.id === activeAnnotationId) {
79
+ setActiveAnnotationId(null)
80
+ return
81
+ }
82
+ setActiveAnnotationId(ann.id)
83
+ onSelect?.(ann)
84
+ }
85
+
86
+ // Remote is only a concept for hosts that handle it.
87
+ const handlesRemote = !!includedRemoteIds && !!onToggleRemoteId
88
+
89
+ const renderAnnotation = (ann: Annotation) => {
90
+ const isRemote = handlesRemote && ann.source === 'remote'
91
+ return (
92
+ <div
93
+ key={ann.id}
94
+ ref={(el) => {
95
+ if (el) annRefs.current.set(ann.id, el)
96
+ else annRefs.current.delete(ann.id)
97
+ }}
98
+ className={`group/ann relative px-3 py-2.5 transition-colors duration-150 ${readOnly ? 'cursor-pointer ' : ''}${
99
+ activeAnnotationId === ann.id
100
+ ? 'bg-highlight-selected'
101
+ : isRemote
102
+ ? 'bg-accent-purple-muted/30 hover:bg-accent-purple-muted/50'
103
+ : 'odd:bg-bg-elevated-half hover:bg-bg-input'
104
+ }`}
105
+ onMouseEnter={() => handleMouseEnter(ann.id)}
106
+ onMouseLeave={() => handleMouseLeave(ann.id)}
107
+ onClick={() => handleClick(ann)}
108
+ >
109
+ {isRemote && ann.author && (
110
+ <div className="flex items-center gap-1.5 mb-1.5">
111
+ <input
112
+ type="checkbox"
113
+ checked={includedRemoteIds.has(ann.id)}
114
+ onChange={(e) => {
115
+ e.stopPropagation()
116
+ onToggleRemoteId(ann.id)
117
+ }}
118
+ onClick={(e) => e.stopPropagation()}
119
+ className="accent-accent-purple flex-shrink-0"
120
+ title="Include in submission"
121
+ />
122
+ <span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent-purple text-white text-[9px] font-medium uppercase">
123
+ {ann.author.name.charAt(0)}
124
+ </span>
125
+ <span className="text-[11px] font-medium text-accent-purple font-body">
126
+ {ann.author.name}
127
+ </span>
128
+ <span className="text-[10px] text-text-tertiary font-body uppercase tracking-widest">
129
+ Remote
130
+ </span>
131
+ </div>
132
+ )}
133
+
134
+ {/* Snippet quote */}
135
+ <div className="text-[11px] text-text-tertiary italic line-clamp-2 mb-1.5 leading-snug font-body border-l-2 border-border-medium pl-2">
136
+ {snippetLabel(ann.snippet)}
137
+ </div>
138
+
139
+ {/* Editable note + images — only the reader's own live annotations. */}
140
+ {readOnly
141
+ ? (
142
+ <>
143
+ {ann.note.trim() && (
144
+ <div className="text-[13px] font-body text-text-primary leading-relaxed">
145
+ {ann.note}
146
+ </div>
147
+ )}
148
+ <ImageThumbnails images={ann.images || []} />
149
+ </>
150
+ )
151
+ : isRemote
152
+ ? (
153
+ <div className="text-[13px] font-body text-text-primary leading-relaxed whitespace-pre-wrap">
154
+ {ann.note}
155
+ </div>
156
+ )
157
+ : (
158
+ <div onClick={(e) => e.stopPropagation()}>
159
+ <AnnotationEditor
160
+ note={ann.note}
161
+ onNoteChange={(note) => updateAnnotation(ann.id, note)}
162
+ images={ann.images || []}
163
+ onAddImage={(path) => addAnnotationImage(ann.id, path)}
164
+ onRemoveImage={(path) => removeAnnotationImage(ann.id, path)}
165
+ sessionId={sessionId}
166
+ autoResize
167
+ minHeight={20}
168
+ textareaClassName="w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none min-h-[20px]"
169
+ textareaStyle={{ height: 'auto', overflow: 'hidden' }}
170
+ placeholder="Add your note..."
171
+ attachButton="on-focus"
172
+ />
173
+ </div>
174
+ )}
175
+
176
+ {/* Actions — top right on hover */}
177
+ <div className="absolute top-1.5 right-1.5 opacity-0 group-hover/ann:opacity-100 transition-opacity duration-100 flex items-center gap-0.5">
178
+ {onReveal && (
179
+ <button
180
+ onClick={(e) => {
181
+ e.stopPropagation()
182
+ onReveal(ann)
183
+ }}
184
+ className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-input transition-colors"
185
+ title="Scroll to annotation"
186
+ >
187
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
188
+ <circle cx="12" cy="12" r="3" />
189
+ <path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
190
+ </svg>
191
+ </button>
192
+ )}
193
+ {!readOnly && !isRemote && (
194
+ <button
195
+ onClick={(e) => {
196
+ e.stopPropagation()
197
+ removeAnnotation(ann.id)
198
+ }}
199
+ className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-accent-red hover:bg-accent-red-muted transition-colors"
200
+ title="Delete annotation"
201
+ >
202
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
203
+ <polyline points="3 6 5 6 21 6" />
204
+ <path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
205
+ </svg>
206
+ </button>
207
+ )}
208
+ </div>
209
+ </div>
210
+ )
211
+ }
212
+
213
+ return <>{annotations.map(renderAnnotation)}</>
214
+ }
@@ -0,0 +1,62 @@
1
+ import { AnnotationCtx, SessionContext, useAnnotationHost } from '@fabrika/annotations/runtime'
2
+ import React, { useEffect, useState } from 'react'
3
+ import type { Annotation, AnnotationState } from './runtime'
4
+ import { useAnnotationState } from './useAnnotationState'
5
+
6
+ export { useAnnotations } from '@fabrika/annotations/runtime'
7
+ export type { Annotation, AnnotationContext, AnnotationContextValue, AnnotationState } from './runtime'
8
+
9
+ export interface AnnotationProviderProps {
10
+ scope: string
11
+ isReadOnly?: boolean
12
+ remoteAnnotations?: Annotation[]
13
+ loadState: (scope: string) => Promise<AnnotationState | null>
14
+ saveState: (scope: string, state: AnnotationState) => void
15
+ children: React.ReactNode
16
+ }
17
+
18
+ export function AnnotationProvider(props: AnnotationProviderProps) {
19
+ return <AnnotationDraft key={props.scope} {...props} />
20
+ }
21
+
22
+ function AnnotationDraft({ scope, isReadOnly = false, remoteAnnotations, loadState, saveState, children }: AnnotationProviderProps) {
23
+ const host = useAnnotationHost()
24
+ const [annotations, setAnnotations] = useState<Annotation[]>([])
25
+ const [generalNote, setGeneralNote] = useState('')
26
+ const [hydrated, setHydrated] = useState(false)
27
+ const value = useAnnotationState({
28
+ annotations,
29
+ setAnnotations,
30
+ generalNote,
31
+ setGeneralNote,
32
+ remoteAnnotations,
33
+ isReadOnly,
34
+ onClear: () => saveState(scope, { annotations: [], generalNote: '' }),
35
+ })
36
+
37
+ useEffect(() => {
38
+ let cancelled = false
39
+ setHydrated(false)
40
+ loadState(scope).then((saved) => {
41
+ if (cancelled) return
42
+ setAnnotations(saved?.annotations ?? [])
43
+ setGeneralNote(saved?.generalNote ?? '')
44
+ setHydrated(true)
45
+ })
46
+ return () => {
47
+ cancelled = true
48
+ }
49
+ }, [scope, loadState])
50
+
51
+ useEffect(() => {
52
+ if (!hydrated || isReadOnly) return
53
+ const timer = setTimeout(() => saveState(scope, { annotations, generalNote }), 300)
54
+ return () => clearTimeout(timer)
55
+ }, [scope, annotations, generalNote, hydrated, isReadOnly, saveState])
56
+
57
+ return (
58
+ <SessionContext.Provider value={host.sessionId}>
59
+ <AnnotationCtx.Provider value={value}>{children}</AnnotationCtx.Provider>
60
+ </SessionContext.Provider>
61
+ )
62
+ }