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.
- package/LICENSE +21 -0
- package/README.md +307 -0
- package/backend/firebase/firestore.indexes.json +22 -0
- package/backend/firebase/firestore.rules +84 -0
- package/backend/firebase/storage.rules +37 -0
- package/backend/supabase/migration.sql +245 -0
- package/dist/adapter-Cyqfwj6F.d.ts +40 -0
- package/dist/annotate-kit.css +2 -0
- package/dist/chunk-G44KPHPN.js +107 -0
- package/dist/delivery.d.ts +31 -0
- package/dist/delivery.js +48 -0
- package/dist/firebase.d.ts +34 -0
- package/dist/firebase.js +200 -0
- package/dist/idb.d.ts +18 -0
- package/dist/idb.js +202 -0
- package/dist/index.d.ts +290 -0
- package/dist/index.js +1856 -0
- package/dist/local.d.ts +18 -0
- package/dist/local.js +115 -0
- package/dist/prompt.d.ts +54 -0
- package/dist/prompt.js +8 -0
- package/dist/rest.d.ts +27 -0
- package/dist/rest.js +63 -0
- package/dist/supabase.d.ts +31 -0
- package/dist/supabase.js +143 -0
- package/dist/types-BLXmj4Oi.d.ts +93 -0
- package/package.json +143 -0
- package/src/Annotate.tsx +1043 -0
- package/src/ScreenshotEditor.tsx +195 -0
- package/src/adapter.ts +31 -0
- package/src/adapters/firebase.ts +197 -0
- package/src/adapters/idb.ts +181 -0
- package/src/adapters/local.ts +114 -0
- package/src/adapters/rest.ts +98 -0
- package/src/adapters/supabase.ts +185 -0
- package/src/constants.ts +35 -0
- package/src/delivery.ts +63 -0
- package/src/index.ts +20 -0
- package/src/prompt.ts +109 -0
- package/src/snapshot.ts +21 -0
- package/src/styles/annotate.css +211 -0
- package/src/types.ts +71 -0
|
@@ -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
|
+
}
|