annotate-kit 0.1.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,195 @@
1
+ // A tiny markup editor: draw pen / rectangle / arrow strokes onto a captured screenshot, then copy or
2
+ // download the flattened image. Self-contained — one <canvas>, no dependencies beyond React + icons.
3
+ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
4
+ import { X, Pencil, Square, ArrowUpRight, Undo2, Trash2, Copy, Download } from 'lucide-react'
5
+
6
+ type Tool = 'pen' | 'rect' | 'arrow'
7
+ type Pt = { x: number; y: number }
8
+ type Shape =
9
+ | { tool: 'pen'; color: string; pts: Pt[] }
10
+ | { tool: 'rect' | 'arrow'; color: string; x: number; y: number; x2: number; y2: number }
11
+
12
+ const COLORS = ['#ef4444', '#f59e0b', '#22c55e', '#3b82f6', '#111827', '#ffffff']
13
+
14
+ function drawShape(ctx: CanvasRenderingContext2D, s: Shape) {
15
+ ctx.strokeStyle = s.color
16
+ ctx.fillStyle = s.color
17
+ ctx.lineWidth = Math.max(3, ctx.canvas.width / 400)
18
+ ctx.lineJoin = 'round'
19
+ ctx.lineCap = 'round'
20
+ if (s.tool === 'pen') {
21
+ ctx.beginPath()
22
+ s.pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)))
23
+ ctx.stroke()
24
+ } else if (s.tool === 'rect') {
25
+ ctx.strokeRect(Math.min(s.x, s.x2), Math.min(s.y, s.y2), Math.abs(s.x2 - s.x), Math.abs(s.y2 - s.y))
26
+ } else {
27
+ ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(s.x2, s.y2); ctx.stroke()
28
+ const ang = Math.atan2(s.y2 - s.y, s.x2 - s.x)
29
+ const h = Math.max(12, ctx.lineWidth * 4)
30
+ ctx.beginPath(); ctx.moveTo(s.x2, s.y2)
31
+ ctx.lineTo(s.x2 - h * Math.cos(ang - Math.PI / 6), s.y2 - h * Math.sin(ang - Math.PI / 6))
32
+ ctx.lineTo(s.x2 - h * Math.cos(ang + Math.PI / 6), s.y2 - h * Math.sin(ang + Math.PI / 6))
33
+ ctx.closePath(); ctx.fill()
34
+ }
35
+ }
36
+
37
+ const TOOLS: { key: Tool; Icon: typeof Pencil; label: string }[] = [
38
+ { key: 'pen', Icon: Pencil, label: 'Draw' },
39
+ { key: 'rect', Icon: Square, label: 'Box' },
40
+ { key: 'arrow', Icon: ArrowUpRight, label: 'Arrow' },
41
+ ]
42
+
43
+ export function ScreenshotEditor({ blob, onToast, onClose }: {
44
+ blob: Blob
45
+ onToast: (kind: 'success' | 'error', msg: string) => void
46
+ onClose: () => void
47
+ }) {
48
+ const canvasRef = useRef<HTMLCanvasElement | null>(null)
49
+ const imgRef = useRef<HTMLImageElement | null>(null)
50
+ const drawingRef = useRef<Shape | null>(null)
51
+ const panelRef = useRef<HTMLDivElement | null>(null)
52
+ const [tool, setTool] = useState<Tool>('pen')
53
+ const [color, setColor] = useState(COLORS[0])
54
+ const [shapes, setShapes] = useState<Shape[]>([])
55
+ const [ready, setReady] = useState(false)
56
+
57
+ useEffect(() => {
58
+ const url = URL.createObjectURL(blob)
59
+ const img = new Image()
60
+ img.onload = () => { imgRef.current = img; setReady(true) }
61
+ img.src = url
62
+ return () => URL.revokeObjectURL(url)
63
+ }, [blob])
64
+
65
+ const redraw = () => {
66
+ const canvas = canvasRef.current
67
+ const img = imgRef.current
68
+ if (!canvas || !img) return
69
+ if (canvas.width !== img.naturalWidth) canvas.width = img.naturalWidth
70
+ if (canvas.height !== img.naturalHeight) canvas.height = img.naturalHeight
71
+ const ctx = canvas.getContext('2d')
72
+ if (!ctx) return
73
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
74
+ ctx.drawImage(img, 0, 0)
75
+ const all = drawingRef.current ? [...shapes, drawingRef.current] : shapes
76
+ for (const s of all) drawShape(ctx, s)
77
+ }
78
+ useEffect(() => { redraw() }, [ready, shapes]) // eslint-disable-line react-hooks/exhaustive-deps
79
+
80
+ useEffect(() => {
81
+ const h = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); onClose() } }
82
+ window.addEventListener('keydown', h, true) // capture — Escape closes only the editor, not the host panel
83
+ return () => window.removeEventListener('keydown', h, true)
84
+ }, [onClose])
85
+
86
+ // focus trap (a11y)
87
+ useEffect(() => {
88
+ const el = panelRef.current
89
+ if (!el) return
90
+ const SEL = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])'
91
+ const list = () => Array.from(el.querySelectorAll<HTMLElement>(SEL)).filter((x) => x.offsetParent !== null)
92
+ ;(list()[0] ?? el).focus()
93
+ const onKey = (e: KeyboardEvent) => {
94
+ if (e.key !== 'Tab') return
95
+ const f = list()
96
+ if (!f.length) return
97
+ const first = f[0], last = f[f.length - 1]
98
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
99
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
100
+ }
101
+ el.addEventListener('keydown', onKey)
102
+ return () => el.removeEventListener('keydown', onKey)
103
+ }, [])
104
+
105
+ const toCanvas = (e: ReactPointerEvent): Pt => {
106
+ const canvas = canvasRef.current!
107
+ const r = canvas.getBoundingClientRect()
108
+ // map to the content box (exclude the 1px border via clientLeft/Top + clientWidth/Height)
109
+ return {
110
+ x: (e.clientX - r.left - canvas.clientLeft) * (canvas.width / canvas.clientWidth),
111
+ y: (e.clientY - r.top - canvas.clientTop) * (canvas.height / canvas.clientHeight),
112
+ }
113
+ }
114
+ const onDown = (e: ReactPointerEvent) => {
115
+ e.preventDefault()
116
+ const p = toCanvas(e)
117
+ drawingRef.current = tool === 'pen' ? { tool: 'pen', color, pts: [p] } : { tool, color, x: p.x, y: p.y, x2: p.x, y2: p.y }
118
+ try { (e.target as HTMLElement).setPointerCapture(e.pointerId) } catch { /* noop */ }
119
+ }
120
+ const onMove = (e: ReactPointerEvent) => {
121
+ const d = drawingRef.current
122
+ if (!d) return
123
+ const p = toCanvas(e)
124
+ if (d.tool === 'pen') d.pts.push(p)
125
+ else { d.x2 = p.x; d.y2 = p.y }
126
+ redraw()
127
+ }
128
+ const onUp = () => {
129
+ const d = drawingRef.current
130
+ if (d) { setShapes((s) => [...s, d]); drawingRef.current = null }
131
+ }
132
+
133
+ const exportBlob = (): Promise<Blob | null> =>
134
+ new Promise((res) => (canvasRef.current ? canvasRef.current.toBlob((b) => res(b), 'image/png') : res(null)))
135
+
136
+ const copy = async () => {
137
+ const b = await exportBlob()
138
+ if (b && navigator.clipboard && typeof window.ClipboardItem === 'function') {
139
+ try { await navigator.clipboard.write([new window.ClipboardItem({ 'image/png': b })]); onToast('success', 'Marked-up image copied — paste anywhere'); onClose(); return } catch { /* fall through */ }
140
+ }
141
+ onToast('error', 'Clipboard blocked — use Download')
142
+ }
143
+ const download = async () => {
144
+ const b = await exportBlob()
145
+ if (!b) return
146
+ const url = URL.createObjectURL(b)
147
+ const a = document.createElement('a')
148
+ a.href = url
149
+ a.download = `markup-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`
150
+ a.click()
151
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
152
+ onToast('success', 'Marked-up image downloaded')
153
+ onClose()
154
+ }
155
+
156
+ const btn = 'inline-flex items-center gap-1 rounded-input px-2.5 py-1.5 text-xs font-semibold transition'
157
+
158
+ return (
159
+ <div data-annot-ui role="dialog" aria-modal="true" aria-label="Mark up screenshot"
160
+ className="fixed inset-0 z-[10002] flex flex-col bg-fg/70 p-3" onClick={onClose}>
161
+ <div ref={panelRef} tabIndex={-1} className="mx-auto flex w-full max-w-4xl flex-col overflow-hidden rounded-modal border border-line bg-surface shadow-e4 outline-none" onClick={(e) => e.stopPropagation()}>
162
+ {/* toolbar */}
163
+ <div className="flex flex-wrap items-center gap-1.5 border-b border-line px-3 py-2">
164
+ {TOOLS.map(({ key, Icon, label }) => (
165
+ <button key={key} type="button" title={label} aria-label={label} onClick={() => setTool(key)}
166
+ className={btn + ' ' + (tool === key ? 'bg-brand text-white' : 'text-fg-muted hover:bg-row-alt')}>
167
+ <Icon size={14} /> {label}
168
+ </button>
169
+ ))}
170
+ <span className="mx-1 flex items-center gap-1">
171
+ {COLORS.map((c) => (
172
+ <button key={c} type="button" aria-label={`Colour ${c}`} onClick={() => setColor(c)} style={{ background: c }}
173
+ className={'h-5 w-5 rounded-full border-2 ' + (color === c ? 'scale-110 border-fg-secondary' : 'border-line')} />
174
+ ))}
175
+ </span>
176
+ <button type="button" onClick={() => setShapes((s) => s.slice(0, -1))} disabled={!shapes.length} title="Undo"
177
+ className={btn + ' text-fg-muted hover:bg-row-alt disabled:opacity-40'}><Undo2 size={14} /></button>
178
+ <button type="button" onClick={() => setShapes([])} disabled={!shapes.length} title="Clear all"
179
+ className={btn + ' text-fg-muted hover:bg-row-alt disabled:opacity-40'}><Trash2 size={14} /></button>
180
+ <div className="ml-auto flex items-center gap-1.5">
181
+ <button type="button" onClick={download} className={btn + ' border border-line text-fg-secondary hover:bg-row-alt'}><Download size={14} /> Download</button>
182
+ <button type="button" onClick={copy} className={btn + ' bg-brand text-white'}><Copy size={14} /> Copy</button>
183
+ <button type="button" onClick={onClose} title="Close" aria-label="Close" className="rounded-input p-1.5 text-fg-muted hover:bg-row-alt"><X size={16} /></button>
184
+ </div>
185
+ </div>
186
+ {/* canvas */}
187
+ <div className="grid max-h-[78vh] place-items-center overflow-auto bg-sunken p-3">
188
+ <canvas ref={canvasRef} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
189
+ style={{ maxWidth: '100%', maxHeight: '72vh', touchAction: 'none', cursor: 'crosshair' }}
190
+ className="rounded-input border border-line shadow-e2" />
191
+ </div>
192
+ </div>
193
+ </div>
194
+ )
195
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,31 @@
1
+ // The one seam between the overlay and your backend. Implement these 9 methods for any store
2
+ // (Supabase / Firebase / your own API) and the entire Annotate UI works unchanged. Ready-made
3
+ // adapters ship at `annotate-kit/supabase` and `annotate-kit/firebase`.
4
+ import type { Annotation, NewAnnotation, AnnotationPatch, AnnotateAccess, AnnVisibility } from './types'
5
+
6
+ export interface StorageAdapter {
7
+ /** Who is looking + what they may do. Called on mount (and after toggling access). */
8
+ getAccess(): Promise<AnnotateAccess>
9
+ /** Flip the global "visible to all users" toggle (ADMIN only). */
10
+ setAccess(enabled: boolean): Promise<{ message?: string }>
11
+ /** All marks the viewer may see, each with `screenshot_url` resolved for display. */
12
+ list(): Promise<Annotation[]>
13
+ /** Persist a new mark; returns its new id. */
14
+ save(input: NewAnnotation): Promise<{ id: number }>
15
+ /** Patch a mark (status/note/type/priority) by id. */
16
+ update(patch: AnnotationPatch): Promise<void>
17
+ /** Delete a mark (and its screenshot) by id. */
18
+ remove(id: number): Promise<void>
19
+ /** Append a follow-up reply to a mark. */
20
+ addReply(id: number, note: string): Promise<void>
21
+ /** Delete every done/dismissed mark (ADMIN/see-all cleanup). */
22
+ clearResolved(): Promise<{ message?: string }>
23
+ /** Upload a screenshot; returns the storage path stored on the row (list() turns it into a URL).
24
+ * `visibility` decides where the file lives: a 'public' mark's screenshot is readable by any
25
+ * authenticated user, a 'private' one only by its owner/admin. Defaults to 'private'. */
26
+ uploadScreenshot(file: File, opts?: { visibility?: AnnVisibility }): Promise<{ path: string }>
27
+ /** Optional: subscribe to live changes and call `onChange` when marks may have changed.
28
+ * Return an unsubscribe function. When present, the overlay updates in real time (with a slow
29
+ * poll as a safety net) instead of polling only while open. */
30
+ subscribe?(onChange: () => void): () => void
31
+ }
@@ -0,0 +1,197 @@
1
+ // Firebase storage adapter — Firestore for marks, Cloud Storage for screenshots.
2
+ // Apply backend/firebase/firestore.rules + storage.rules once, then create this adapter.
3
+ // Security rules are the real gate; this is just the client.
4
+ import type { Firestore } from 'firebase/firestore'
5
+ import {
6
+ collection, doc, getDoc, getDocs, setDoc, updateDoc, deleteDoc, query, where, orderBy, limit,
7
+ arrayUnion, runTransaction, onSnapshot,
8
+ } from 'firebase/firestore'
9
+ import type { FirebaseStorage } from 'firebase/storage'
10
+ import { ref, uploadBytes, getDownloadURL, deleteObject } from 'firebase/storage'
11
+ import type { Auth } from 'firebase/auth'
12
+ import type { StorageAdapter } from '../adapter'
13
+ import type { Annotation, NewAnnotation, AnnotationPatch, AnnotateAccess, AnnVisibility } from '../types'
14
+
15
+ export type ResolvedUser = { email?: string; name?: string; role?: string }
16
+
17
+ export type FirebaseAdapterConfig = {
18
+ firestore: Firestore
19
+ storage: FirebaseStorage
20
+ /** Firebase Auth — used to resolve the current user + admin claim (unless currentUser/isAdmin given). */
21
+ auth?: Auth
22
+ /** Firestore collection for marks (default 'annotations'). */
23
+ collectionPath?: string
24
+ /** Firestore doc holding the global toggle, as 'collection/doc' (default: 'annotate_settings/global'). */
25
+ settingsPath?: string
26
+ /** Storage folder for screenshots (default 'annotations'). */
27
+ storagePath?: string
28
+ /** Max docs fetched per list() call (default 1000). Keeps polling bounded on busy projects. */
29
+ listLimit?: number
30
+ /** Is the current viewer an admin? boolean or predicate.
31
+ * Default: the auth user's custom claim `admin === true` or `role === 'admin'`
32
+ * (must match backend/firebase/firestore.rules isAdmin()). */
33
+ isAdmin?: boolean | ((user: ResolvedUser) => boolean | Promise<boolean>)
34
+ /** Override current-user resolution (default: auth.currentUser + its ID-token claims). */
35
+ currentUser?: () => Promise<ResolvedUser | null>
36
+ }
37
+
38
+ // Firestore rejects `undefined` field values — strip them (deeply) before writing.
39
+ function prune<T>(o: T): T {
40
+ if (Array.isArray(o)) return o.map((v) => prune(v)) as unknown as T
41
+ if (o && typeof o === 'object') {
42
+ const out: Record<string, unknown> = {}
43
+ for (const [k, v] of Object.entries(o as Record<string, unknown>)) if (v !== undefined) out[k] = prune(v)
44
+ return out as T
45
+ }
46
+ return o
47
+ }
48
+
49
+ export function createFirebaseAdapter(config: FirebaseAdapterConfig): StorageAdapter {
50
+ const db = config.firestore
51
+ const storage = config.storage
52
+ const auth = config.auth
53
+ const collPath = config.collectionPath ?? 'annotations'
54
+ const settingsFullPath = config.settingsPath ?? 'annotate_settings/global'
55
+ if (settingsFullPath.split('/').filter(Boolean).length % 2 !== 0) {
56
+ throw new Error(`annotate: settingsPath must be a document path like 'collection/doc' (got '${settingsFullPath}')`)
57
+ }
58
+ const storageDir = config.storagePath ?? 'annotations'
59
+ const listLimit = config.listLimit ?? 1000
60
+ const col = collection(db, collPath)
61
+ const settingsRef = doc(db, settingsFullPath)
62
+ const urlCache = new Map<string, string>() // download URLs are long-lived; cache per session
63
+
64
+ async function claims(): Promise<Record<string, unknown>> {
65
+ if (!auth?.currentUser) return {}
66
+ try { return (await auth.currentUser.getIdTokenResult()).claims as Record<string, unknown> } catch { return {} }
67
+ }
68
+ async function resolveUser(): Promise<ResolvedUser> {
69
+ if (config.currentUser) return (await config.currentUser()) ?? {}
70
+ const u = auth?.currentUser
71
+ if (!u) return {}
72
+ const c = await claims()
73
+ return { email: u.email ?? undefined, name: u.displayName ?? u.email ?? undefined, role: (c.role as string) || undefined }
74
+ }
75
+ async function resolveIsAdmin(user: ResolvedUser): Promise<boolean> {
76
+ if (typeof config.isAdmin === 'boolean') return config.isAdmin
77
+ if (typeof config.isAdmin === 'function') return !!(await config.isAdmin(user))
78
+ const c = await claims()
79
+ return c.admin === true || c.role === 'admin'
80
+ }
81
+ async function urlFor(path?: string | null): Promise<string | null> {
82
+ if (!path) return null
83
+ const hit = urlCache.get(path)
84
+ if (hit) return hit
85
+ try { const u = await getDownloadURL(ref(storage, path)); urlCache.set(path, u); return u } catch { return null }
86
+ }
87
+
88
+ return {
89
+ async getAccess(): Promise<AnnotateAccess> {
90
+ const me = await resolveUser()
91
+ const isAdmin = await resolveIsAdmin(me)
92
+ let enabled = false
93
+ try {
94
+ const snap = await getDoc(settingsRef)
95
+ if (snap.exists() && typeof snap.data().enabled === 'boolean') enabled = snap.data().enabled
96
+ } catch { /* missing → treat as disabled (admin-only) */ }
97
+ return { enabled, isAdmin, canUse: enabled || isAdmin, seeAll: isAdmin, me }
98
+ },
99
+
100
+ async setAccess(enabled: boolean) {
101
+ await setDoc(settingsRef, { enabled }, { merge: true })
102
+ return { message: enabled ? 'Enabled for all users' : 'ADMIN-only' }
103
+ },
104
+
105
+ async list(): Promise<Annotation[]> {
106
+ const me = await resolveUser()
107
+ const isAdmin = await resolveIsAdmin(me)
108
+ const seen = new Map<number, Annotation>()
109
+ const collect = (docs: { id: string; data: () => Record<string, unknown> }[]) => {
110
+ for (const d of docs) {
111
+ const data = d.data()
112
+ const id = typeof data.id === 'number' ? data.id : Number(d.id)
113
+ seen.set(id, { ...(data as Annotation), id })
114
+ }
115
+ }
116
+ if (isAdmin) {
117
+ const s = await getDocs(query(col, orderBy('id', 'desc'), limit(listLimit)))
118
+ collect(s.docs)
119
+ } else {
120
+ const [pub, mine] = await Promise.all([
121
+ getDocs(query(col, where('visibility', '==', 'public'), orderBy('id', 'desc'), limit(listLimit))),
122
+ me.email ? getDocs(query(col, where('created_by', '==', me.email), orderBy('id', 'desc'), limit(listLimit))) : Promise.resolve({ docs: [] as never[] }),
123
+ ])
124
+ collect(pub.docs); collect(mine.docs)
125
+ }
126
+ const rows = [...seen.values()].sort((a, b) => b.id - a.id)
127
+ await Promise.all(rows.map(async (r) => { if (r.screenshot_path) r.screenshot_url = await urlFor(r.screenshot_path) }))
128
+ return rows
129
+ },
130
+
131
+ async save(input: NewAnnotation) {
132
+ const me = await resolveUser()
133
+ const base = {
134
+ ...input, status: 'open',
135
+ created_by: me.email ?? '', created_by_name: me.name ?? me.email ?? '', created_by_role: me.role ?? null,
136
+ created_at: new Date().toISOString(),
137
+ }
138
+ // Allocate a collision-free numeric id transactionally (never overwrite an existing mark).
139
+ let id = 0
140
+ await runTransaction(db, async (tx) => {
141
+ for (let i = 0; i < 6; i++) {
142
+ const cand = Date.now() * 1000 + Math.floor(Math.random() * 1000)
143
+ const dref = doc(col, String(cand))
144
+ const snap = await tx.get(dref)
145
+ if (!snap.exists()) { id = cand; tx.set(dref, prune({ ...base, id }) as Record<string, unknown>); return }
146
+ }
147
+ throw new Error('annotate: could not allocate a unique id — retry')
148
+ })
149
+ return { id }
150
+ },
151
+
152
+ async update(patch: AnnotationPatch) {
153
+ const { id, ...rest } = patch
154
+ await updateDoc(doc(col, String(id)), { ...prune(rest), updated_at: new Date().toISOString() })
155
+ },
156
+
157
+ async remove(id: number) {
158
+ const snap = await getDoc(doc(col, String(id)))
159
+ const path = snap.exists() ? (snap.data().screenshot_path as string | undefined) : undefined
160
+ await deleteDoc(doc(col, String(id)))
161
+ if (path) { urlCache.delete(path); try { await deleteObject(ref(storage, path)) } catch (e) { console.warn('[annotate] screenshot delete failed', e) } }
162
+ },
163
+
164
+ async addReply(id: number, note: string) {
165
+ const me = await resolveUser()
166
+ const reply = { note: note.slice(0, 2000), author: me.name ?? me.email ?? '', at: new Date().toISOString() }
167
+ // atomic append — no read-modify-write race
168
+ await updateDoc(doc(col, String(id)), { replies: arrayUnion(reply), updated_at: new Date().toISOString() })
169
+ },
170
+
171
+ async clearResolved() {
172
+ const s = await getDocs(query(col, where('status', 'in', ['done', 'dismissed'])))
173
+ await Promise.all(s.docs.map(async (d) => {
174
+ const path = d.data().screenshot_path as string | undefined
175
+ await deleteDoc(d.ref)
176
+ if (path) { urlCache.delete(path); try { await deleteObject(ref(storage, path)) } catch (e) { console.warn('[annotate] screenshot delete failed', e) } }
177
+ }))
178
+ return { message: `Cleared ${s.docs.length} resolved` }
179
+ },
180
+
181
+ async uploadScreenshot(file: File, opts?: { visibility?: AnnVisibility }) {
182
+ const vis = opts?.visibility === 'public' ? 'public' : 'private'
183
+ const uid = auth?.currentUser?.uid
184
+ if (!uid) throw new Error('annotate: must be signed in to upload a screenshot')
185
+ const rand = Math.random().toString(36).slice(2, 8)
186
+ const path = `${storageDir}/${vis}/${uid}/${Date.now()}-${rand}.png`
187
+ await uploadBytes(ref(storage, path), file, { contentType: file.type || 'image/png' })
188
+ return { path }
189
+ },
190
+
191
+ subscribe(onChange: () => void) {
192
+ // Live for public marks (any signed-in user may read them); the overlay's slow safety poll
193
+ // covers own/admin-only changes.
194
+ return onSnapshot(query(col, where('visibility', '==', 'public')), () => onChange(), () => { /* ignore */ })
195
+ },
196
+ }
197
+ }
@@ -0,0 +1,181 @@
1
+ // IndexedDB adapter — zero-backend like `annotate-kit/local`, but with real capacity: marks live in
2
+ // an object store and screenshots are kept as native Blobs (no base64 bloat, no ~5MB localStorage cap).
3
+ // Ideal for demos, offline, or single-user setups that take many/large screenshots.
4
+ //
5
+ // import { createIdbAdapter } from 'annotate-kit/idb'
6
+ // <Annotate adapter={createIdbAdapter()} />
7
+ //
8
+ // SECURITY: like the local adapter, this is single-browser storage with NO multi-user boundary — it is
9
+ // fully visible to whoever uses that browser. Use it for one user / demos, not shared data.
10
+ import type { StorageAdapter } from '../adapter'
11
+ import type { Annotation, NewAnnotation, AnnotationPatch, AnnotateAccess, AnnVisibility, AnnReply } from '../types'
12
+
13
+ export type IdbAdapterConfig = {
14
+ /** Database name (default 'annotate-kit'). */
15
+ dbName?: string
16
+ /** The single local user shown as author. */
17
+ me?: { email?: string; name?: string; role?: string }
18
+ /** IndexedDB factory (default globalThis.indexedDB). Inject a fake for tests / non-browser envs. */
19
+ factory?: IDBFactory
20
+ }
21
+
22
+ const MARKS = 'marks'
23
+ const SHOTS = 'shots'
24
+ const SETTINGS = 'settings'
25
+
26
+ export function createIdbAdapter(config: IdbAdapterConfig = {}): StorageAdapter {
27
+ const dbName = config.dbName ?? 'annotate-kit'
28
+ const me = config.me ?? { email: 'you@local', name: 'You' }
29
+ const idb = config.factory ?? (typeof indexedDB !== 'undefined' ? indexedDB : undefined)
30
+ // Do NOT throw at construction — it must be SSR-safe (e.g. inline `createIdbAdapter()` on the server).
31
+ const now = () => new Date().toISOString()
32
+ const canObjectUrl = typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'
33
+ const urlCache = new Map<string, string>()
34
+
35
+ let dbp: Promise<IDBDatabase> | null = null
36
+ function db(): Promise<IDBDatabase> {
37
+ if (!idb) return Promise.reject(new Error('annotate: IndexedDB is unavailable — pass config.factory or use another adapter'))
38
+ if (!dbp) dbp = new Promise((resolve, reject) => {
39
+ const req = idb.open(dbName, 1)
40
+ req.onupgradeneeded = () => {
41
+ const d = req.result
42
+ if (!d.objectStoreNames.contains(MARKS)) d.createObjectStore(MARKS, { keyPath: 'id', autoIncrement: true })
43
+ if (!d.objectStoreNames.contains(SHOTS)) d.createObjectStore(SHOTS)
44
+ if (!d.objectStoreNames.contains(SETTINGS)) d.createObjectStore(SETTINGS)
45
+ }
46
+ req.onsuccess = () => resolve(req.result)
47
+ req.onerror = () => reject(req.error)
48
+ })
49
+ return dbp
50
+ }
51
+ // Run a transaction whose body issues its requests synchronously (so the tx never goes inactive),
52
+ // resolving with `out.value` once the tx commits.
53
+ function run<T>(stores: string[], mode: IDBTransactionMode, body: (t: IDBTransaction, out: { value?: T }) => void): Promise<T | undefined> {
54
+ return db().then((d) => new Promise<T | undefined>((resolve, reject) => {
55
+ const t = d.transaction(stores, mode)
56
+ const out: { value?: T } = {}
57
+ try { body(t, out) } catch (e) { reject(e); try { t.abort() } catch { /* noop */ } return }
58
+ t.oncomplete = () => resolve(out.value)
59
+ t.onerror = () => reject(t.error)
60
+ t.onabort = () => reject(t.error)
61
+ }))
62
+ }
63
+
64
+ return {
65
+ async getAccess(): Promise<AnnotateAccess> {
66
+ const enabled = await run<boolean>([SETTINGS], 'readonly', (t, out) => {
67
+ const g = t.objectStore(SETTINGS).get('enabled')
68
+ g.onsuccess = () => { out.value = g.result === undefined ? true : !!g.result }
69
+ })
70
+ return { enabled: enabled ?? true, isAdmin: true, canUse: true, seeAll: true, me }
71
+ },
72
+ async setAccess(enabled: boolean) {
73
+ await run([SETTINGS], 'readwrite', (t) => { t.objectStore(SETTINGS).put(enabled, 'enabled') })
74
+ return { message: enabled ? 'Enabled' : 'Disabled' }
75
+ },
76
+ async list(): Promise<Annotation[]> {
77
+ const rows = await run<Annotation[]>([MARKS, SHOTS], 'readonly', (t, out) => {
78
+ const shots = t.objectStore(SHOTS)
79
+ const ga = t.objectStore(MARKS).getAll()
80
+ ga.onsuccess = () => {
81
+ const all = (ga.result || []) as Annotation[]
82
+ for (const r of all) {
83
+ if (r.screenshot_path && canObjectUrl) {
84
+ const cached = urlCache.get(r.screenshot_path)
85
+ if (cached) r.screenshot_url = cached
86
+ else {
87
+ const sr = shots.get(r.screenshot_path)
88
+ sr.onsuccess = () => { const b = sr.result as Blob | undefined; if (b) { const u = URL.createObjectURL(b); urlCache.set(r.screenshot_path!, u); r.screenshot_url = u } }
89
+ }
90
+ }
91
+ }
92
+ out.value = all
93
+ }
94
+ })
95
+ return (rows ?? []).sort((a, b) => b.id - a.id)
96
+ },
97
+ async save(input: NewAnnotation) {
98
+ const row = {
99
+ ...input, status: 'open',
100
+ created_by: me.email ?? '', created_by_name: me.name ?? me.email ?? '', created_by_role: me.role ?? null,
101
+ created_at: now(), updated_at: now(),
102
+ }
103
+ const id = await run<number>([MARKS], 'readwrite', (t, out) => {
104
+ const add = t.objectStore(MARKS).add(row) // autoIncrement fills row.id in the stored value
105
+ add.onsuccess = () => { out.value = Number(add.result) }
106
+ })
107
+ return { id: id ?? 0 }
108
+ },
109
+ async update(patch: AnnotationPatch) {
110
+ const { id, ...rest } = patch
111
+ await run([MARKS], 'readwrite', (t) => {
112
+ const store = t.objectStore(MARKS)
113
+ const g = store.get(id)
114
+ g.onsuccess = () => { const row = g.result; if (row) { Object.assign(row, rest, { updated_at: now() }); store.put(row) } }
115
+ })
116
+ },
117
+ async remove(id: number) {
118
+ await run([MARKS, SHOTS], 'readwrite', (t) => {
119
+ const marks = t.objectStore(MARKS)
120
+ const g = marks.get(id)
121
+ g.onsuccess = () => {
122
+ const row = g.result as Annotation | undefined
123
+ marks.delete(id)
124
+ if (row?.screenshot_path) {
125
+ t.objectStore(SHOTS).delete(row.screenshot_path)
126
+ const u = urlCache.get(row.screenshot_path); if (u && canObjectUrl) URL.revokeObjectURL(u)
127
+ urlCache.delete(row.screenshot_path)
128
+ }
129
+ }
130
+ })
131
+ },
132
+ async addReply(id: number, note: string) {
133
+ await run([MARKS], 'readwrite', (t) => {
134
+ const store = t.objectStore(MARKS)
135
+ const g = store.get(id)
136
+ g.onsuccess = () => {
137
+ const row = g.result as Annotation | undefined
138
+ if (row) {
139
+ const reply: AnnReply = { note: note.slice(0, 2000), author: me.name ?? me.email ?? '', at: now() }
140
+ row.replies = [...(Array.isArray(row.replies) ? row.replies : []), reply]
141
+ row.updated_at = now()
142
+ store.put(row)
143
+ }
144
+ }
145
+ })
146
+ },
147
+ async clearResolved() {
148
+ const removed = await run<number>([MARKS, SHOTS], 'readwrite', (t, out) => {
149
+ const marks = t.objectStore(MARKS)
150
+ const shots = t.objectStore(SHOTS)
151
+ const ga = marks.getAll()
152
+ ga.onsuccess = () => {
153
+ const all = (ga.result || []) as Annotation[]
154
+ let n = 0
155
+ const referenced = new Set<string>()
156
+ for (const r of all) {
157
+ if (r.status === 'done' || r.status === 'dismissed') {
158
+ marks.delete(r.id); n++
159
+ if (r.screenshot_path) { const u = urlCache.get(r.screenshot_path); if (u && canObjectUrl) URL.revokeObjectURL(u); urlCache.delete(r.screenshot_path) }
160
+ } else if (r.screenshot_path) referenced.add(r.screenshot_path)
161
+ }
162
+ // reclaim every shot no longer referenced by a surviving mark (incl. abandoned uploads)
163
+ const gk = shots.getAllKeys()
164
+ gk.onsuccess = () => {
165
+ for (const k of (gk.result || []) as IDBValidKey[]) {
166
+ const key = String(k)
167
+ if (!referenced.has(key)) { shots.delete(k); const u = urlCache.get(key); if (u && canObjectUrl) URL.revokeObjectURL(u); urlCache.delete(key) }
168
+ }
169
+ }
170
+ out.value = n
171
+ }
172
+ })
173
+ return { message: `Cleared ${removed ?? 0} resolved` }
174
+ },
175
+ async uploadScreenshot(file: File, _opts?: { visibility?: AnnVisibility }) {
176
+ const path = `shot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
177
+ await run([SHOTS], 'readwrite', (t) => { t.objectStore(SHOTS).put(file, path) })
178
+ return { path }
179
+ },
180
+ }
181
+ }