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
package/src/Annotate.tsx
ADDED
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
import { Component, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type CSSProperties, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { domToBlob } from 'modern-screenshot'
|
|
4
|
+
import {
|
|
5
|
+
X, Trash2, Copy, Download, Crosshair, MessageSquarePlus, Camera, ListChecks, CornerDownRight, Target,
|
|
6
|
+
Eraser, Check, MapPin, Square, ArrowUpRight, Circle, Spline, Undo2, Eye, EyeOff, GripVertical, Maximize2,
|
|
7
|
+
Lock, Globe, Settings, Pencil, Keyboard, Send, Filter, Monitor, TerminalSquare, Trash, Highlighter,
|
|
8
|
+
} from 'lucide-react'
|
|
9
|
+
import type { StorageAdapter } from './adapter'
|
|
10
|
+
import {
|
|
11
|
+
ANN_TYPES, ANN_PRIORITIES, ANN_STATUSES, OPEN_STATUSES, ANN_MODES, ANN_COLORS, annDot, annTypeLabel,
|
|
12
|
+
annStatusLabel, annShapeLabel,
|
|
13
|
+
} from './constants'
|
|
14
|
+
import { buildDevPrompt } from './prompt'
|
|
15
|
+
import { sanitizeSnapshot } from './snapshot'
|
|
16
|
+
import { ScreenshotEditor } from './ScreenshotEditor'
|
|
17
|
+
import type { Delivery } from './delivery'
|
|
18
|
+
import type {
|
|
19
|
+
Annotation, AnnType, AnnPriority, AnnStatus, AnnShape, AnnPoint, AnnVisibility, AnnMeta, AnnLog, AnnNetLog, AnnotateAccess,
|
|
20
|
+
} from './types'
|
|
21
|
+
|
|
22
|
+
export type AnnotateProps = {
|
|
23
|
+
/** Your backend binding — see `annotate-kit/supabase` and `annotate-kit/firebase`, or write your own. */
|
|
24
|
+
adapter: StorageAdapter
|
|
25
|
+
/** Current route path. Pass your router's pathname so marks are page-aware in an SPA.
|
|
26
|
+
* Omit to read `window.location.pathname` (updates on browser back/forward only). */
|
|
27
|
+
route?: string
|
|
28
|
+
/** Navigate to a path (used by "find on page" across routes). Default: full-page `window.location.assign`.
|
|
29
|
+
* Pass your router's navigate for smooth SPA jumps. */
|
|
30
|
+
onNavigate?: (path: string) => void
|
|
31
|
+
/** Override the current user (else taken from the adapter's access.me). Used for author attribution. */
|
|
32
|
+
user?: { email?: string; name?: string; role?: string }
|
|
33
|
+
/** Hook the widget's toasts into your app's notifications. Omit for a built-in mini-toast. */
|
|
34
|
+
onToast?: (kind: 'success' | 'error', message: string) => void
|
|
35
|
+
/** How often (ms) to refresh marks while the tool/panel is open. Default 15000. */
|
|
36
|
+
pollMs?: number
|
|
37
|
+
/** Capture a rolling console/JS-error buffer with each mark (secrets may appear in logs).
|
|
38
|
+
* Default false — opt in only if your team needs console context for bug reports. */
|
|
39
|
+
captureConsole?: boolean
|
|
40
|
+
/** Store the full URL (incl. query string + hash) instead of just origin + pathname.
|
|
41
|
+
* Default false — query strings often carry tokens/PII. */
|
|
42
|
+
captureFullUrl?: boolean
|
|
43
|
+
/** Capture on-screen element text (target text + surrounding labels) with each mark.
|
|
44
|
+
* Default true; set false for apps that display sensitive data. */
|
|
45
|
+
captureText?: boolean
|
|
46
|
+
/** Enable bare single-key tool shortcuts (P/B/A/C/D). Default true. Set false to avoid
|
|
47
|
+
* collisions with the host app's own single-key shortcuts. */
|
|
48
|
+
singleKeyShortcuts?: boolean
|
|
49
|
+
/** Capture a rolling buffer of failed/slow network requests (fetch + XHR) with each mark.
|
|
50
|
+
* Default false — URLs are trimmed + redacted, but opt in deliberately. */
|
|
51
|
+
captureNetwork?: boolean
|
|
52
|
+
/** Capture a sanitised HTML snapshot of the marked element (scripts/handlers stripped, password
|
|
53
|
+
* values blanked, truncated). Default false — the DOM can contain sensitive content. */
|
|
54
|
+
captureDomSnapshot?: boolean
|
|
55
|
+
/** Blur password fields and any `[data-annot-redact]` element in screenshots. Default true. */
|
|
56
|
+
redactScreenshots?: boolean
|
|
57
|
+
/** Colour theme for the widget's own panels/dock: 'light' (default), 'dark', or 'auto'
|
|
58
|
+
* (follows the OS via prefers-color-scheme). Does not affect the host page. */
|
|
59
|
+
theme?: 'light' | 'dark' | 'auto'
|
|
60
|
+
/** Override the brand accent colour (any valid CSS colour, e.g. '#0ea5e9'). Themes buttons, active
|
|
61
|
+
* tools, links, and highlights. Invalid values are ignored. */
|
|
62
|
+
accent?: string
|
|
63
|
+
/** Redact secrets/PII from captured text + console before it is stored. Return the cleaned
|
|
64
|
+
* string. Default masks JWTs, bearer tokens, emails, and long digit runs (cards/SSNs). */
|
|
65
|
+
redact?: (text: string) => string
|
|
66
|
+
/** Optional "send to…" targets shown in the Send panel (GitHub issue / webhook / Slack / your own).
|
|
67
|
+
* Off by default — see the `githubIssueDelivery` / `createWebhookDelivery` / `createSlackDelivery` helpers. */
|
|
68
|
+
deliveries?: Delivery[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Tool = null | 'pin' | AnnShape
|
|
72
|
+
const MODE_ICON: Record<string, typeof MapPin> = { pin: MapPin, rect: Square, arrow: ArrowUpRight, circle: Circle, freehand: Spline }
|
|
73
|
+
const cn = (...a: (string | false | null | undefined)[]): string => a.filter(Boolean).join(' ')
|
|
74
|
+
const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e))
|
|
75
|
+
// Only ever render a URL we trust into href/src (blocks javascript:/vbscript: stored-XSS).
|
|
76
|
+
const safeUrl = (u?: string | null): string | undefined => (u && /^(https?:|blob:|data:image\/(png|jpe?g|gif|webp|avif))/i.test(u) ? u : undefined)
|
|
77
|
+
// A stored route is only safe to navigate to if it's a same-origin absolute path (not '//evil.com').
|
|
78
|
+
const isSafePath = (p?: string | null): p is string => !!p && /^\/(?!\/)/.test(p)
|
|
79
|
+
// A brand accent must be a plain CSS colour before we inject it into a <style> (prevents CSS injection).
|
|
80
|
+
const safeColor = (c?: string): string | undefined => (c && /^(#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla)\([\d%.,\s/]+\))$/.test(c.trim()) ? c.trim() : undefined)
|
|
81
|
+
// Default redactor: mask common secrets/PII in captured text + console before it is persisted.
|
|
82
|
+
const defaultRedact = (s: string): string => String(s ?? '')
|
|
83
|
+
.replace(/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g, '[redacted-jwt]')
|
|
84
|
+
.replace(/\b(bearer|token|api[_-]?key|secret|password|authorization)\b\s*[:=]?\s*[A-Za-z0-9._~+/=-]{6,}/gi, '$1 [redacted]')
|
|
85
|
+
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '[redacted-email]')
|
|
86
|
+
.replace(/\b(?:\d[ -]?){13,19}\b/g, '[redacted-number]')
|
|
87
|
+
// Trap Tab focus inside an open dialog (a11y): focus the first control, cycle Tab within, restore on close.
|
|
88
|
+
function useFocusTrap(active: boolean) {
|
|
89
|
+
const ref = useRef<HTMLDivElement | null>(null)
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!active) return
|
|
92
|
+
const el = ref.current
|
|
93
|
+
if (!el) return
|
|
94
|
+
const SEL = 'a[href],button:not([disabled]),input:not([disabled]),textarea:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])'
|
|
95
|
+
const list = () => Array.from(el.querySelectorAll<HTMLElement>(SEL)).filter((x) => x.offsetParent !== null)
|
|
96
|
+
const restore = document.activeElement as HTMLElement | null
|
|
97
|
+
const items = list()
|
|
98
|
+
;(items[0] ?? el).focus()
|
|
99
|
+
const onKey = (e: KeyboardEvent) => {
|
|
100
|
+
if (e.key !== 'Tab') return
|
|
101
|
+
const f = list()
|
|
102
|
+
if (!f.length) return
|
|
103
|
+
const first = f[0], last = f[f.length - 1]
|
|
104
|
+
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
|
|
105
|
+
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
|
|
106
|
+
}
|
|
107
|
+
el.addEventListener('keydown', onKey)
|
|
108
|
+
return () => { el.removeEventListener('keydown', onKey); restore?.focus?.() }
|
|
109
|
+
}, [active])
|
|
110
|
+
return ref
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// If the overlay itself throws while rendering a bad row, degrade to nothing rather than crash the host.
|
|
114
|
+
class OverlayBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
|
|
115
|
+
state = { failed: false }
|
|
116
|
+
static getDerivedStateFromError() { return { failed: true } }
|
|
117
|
+
componentDidCatch(e: unknown) { console.warn('[annotate] overlay render error', e) }
|
|
118
|
+
render() { return this.state.failed ? null : this.props.children }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- module-level capture: environment + a rolling console/JS-error buffer (bug-report context) ----
|
|
122
|
+
function captureEnv(fullUrl: boolean): AnnMeta {
|
|
123
|
+
const ua = navigator.userAgent
|
|
124
|
+
const browser = /edg/i.test(ua) ? 'Edge' : /opr|opera/i.test(ua) ? 'Opera' : /chrome/i.test(ua) ? 'Chrome' : /firefox/i.test(ua) ? 'Firefox' : /safari/i.test(ua) ? 'Safari' : 'Browser'
|
|
125
|
+
const os = /windows/i.test(ua) ? 'Windows' : /mac os|macintosh/i.test(ua) ? 'macOS' : /android/i.test(ua) ? 'Android' : /iphone|ipad|ios/i.test(ua) ? 'iOS' : /linux/i.test(ua) ? 'Linux' : 'OS'
|
|
126
|
+
const device = /mobi/i.test(ua) ? 'Mobile' : /tablet|ipad/i.test(ua) ? 'Tablet' : 'Desktop'
|
|
127
|
+
return { browser, os, device, viewport: `${window.innerWidth}×${window.innerHeight}`, screen: `${window.screen?.width}×${window.screen?.height}`, dpr: Math.round((window.devicePixelRatio || 1) * 100) / 100, url: fullUrl ? window.location.href : window.location.origin + window.location.pathname, ua: ua.slice(0, 200) }
|
|
128
|
+
}
|
|
129
|
+
const _logBuf: AnnLog[] = []
|
|
130
|
+
let _logHooked = false
|
|
131
|
+
// Install the console/JS-error capture — ONLY when the tool is active and the integrator opts in.
|
|
132
|
+
// Returns an uninstall fn that restores the original console (no permanent global monkeypatch at import).
|
|
133
|
+
function hookConsole(redact: (s: string) => string): () => void {
|
|
134
|
+
if (_logHooked || typeof window === 'undefined') return () => {}
|
|
135
|
+
_logHooked = true
|
|
136
|
+
const fmt = (args: unknown[]) => redact(args.map((a) => typeof a === 'string' ? a : a instanceof Error ? (a.stack || a.message) : (() => { try { return JSON.stringify(a) } catch { return String(a) } })()).join(' ')).slice(0, 500)
|
|
137
|
+
const push = (level: string, args: unknown[]) => { try { _logBuf.push({ level, text: fmt(args), at: new Date().toISOString() }); if (_logBuf.length > 50) _logBuf.shift() } catch { /* ignore */ } }
|
|
138
|
+
const orig = { error: console.error, warn: console.warn }
|
|
139
|
+
const onErr = (e: ErrorEvent) => push('error', [e.message + (e.filename ? ` @ ${e.filename}:${e.lineno}:${e.colno}` : '')])
|
|
140
|
+
const onRej = (e: PromiseRejectionEvent) => push('error', ['Unhandled promise rejection: ' + String(e.reason)])
|
|
141
|
+
const errOverride = (...a: unknown[]) => { push('error', a); orig.error(...a) }
|
|
142
|
+
const warnOverride = (...a: unknown[]) => { push('warn', a); orig.warn(...a) }
|
|
143
|
+
console.error = errOverride
|
|
144
|
+
console.warn = warnOverride
|
|
145
|
+
window.addEventListener('error', onErr)
|
|
146
|
+
window.addEventListener('unhandledrejection', onRej)
|
|
147
|
+
return () => {
|
|
148
|
+
_logHooked = false
|
|
149
|
+
_logBuf.length = 0
|
|
150
|
+
if (console.error === errOverride) console.error = orig.error // don't clobber a wrapper installed after us
|
|
151
|
+
if (console.warn === warnOverride) console.warn = orig.warn
|
|
152
|
+
window.removeEventListener('error', onErr)
|
|
153
|
+
window.removeEventListener('unhandledrejection', onRej)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const recentLogs = () => _logBuf.slice(-8)
|
|
157
|
+
|
|
158
|
+
// ---- module-level network capture (opt-in): rolling buffer of failed/slow fetch + XHR calls ----
|
|
159
|
+
const _netBuf: AnnNetLog[] = []
|
|
160
|
+
let _netHooked = false
|
|
161
|
+
function hookNetwork(redact: (s: string) => string): () => void {
|
|
162
|
+
if (_netHooked || typeof window === 'undefined' || typeof window.fetch !== 'function') return () => {}
|
|
163
|
+
_netHooked = true
|
|
164
|
+
const trim = (u: string) => { try { const x = new URL(u, location.href); return redact(x.origin + x.pathname) } catch { return redact(String(u).slice(0, 200)) } }
|
|
165
|
+
const record = (method: string, url: string, status: number | undefined, ms: number, ok: boolean) => {
|
|
166
|
+
if (ok && status !== undefined && status < 400 && ms < 2000) return // keep only errors / failures / slow calls
|
|
167
|
+
try { _netBuf.push({ method, url: trim(url), status, ms: Math.round(ms), ok, at: new Date().toISOString() }); if (_netBuf.length > 50) _netBuf.shift() } catch { /* ignore */ }
|
|
168
|
+
}
|
|
169
|
+
const origFetch = window.fetch
|
|
170
|
+
const boundFetch = origFetch.bind(window)
|
|
171
|
+
const wrapped = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
172
|
+
const start = Date.now()
|
|
173
|
+
const method = (init?.method || (input instanceof Request ? input.method : undefined) || 'GET').toUpperCase()
|
|
174
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url
|
|
175
|
+
try { const res = await boundFetch(input, init); record(method, url, res.status, Date.now() - start, res.ok); return res }
|
|
176
|
+
catch (e) { record(method, url, undefined, Date.now() - start, false); throw e }
|
|
177
|
+
}
|
|
178
|
+
const wrappedFetch = wrapped as typeof window.fetch
|
|
179
|
+
window.fetch = wrappedFetch
|
|
180
|
+
const XHR = window.XMLHttpRequest
|
|
181
|
+
const origOpen = XHR.prototype.open
|
|
182
|
+
const origSend = XHR.prototype.send
|
|
183
|
+
const openOverride = function (this: XMLHttpRequest, method: string, url: string | URL, ...rest: any[]) { // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
184
|
+
;(this as any).__annot = { method: String(method).toUpperCase(), url: String(url), start: 0 } // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
185
|
+
return (origOpen as any).call(this, method, url, ...rest) // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
186
|
+
} as typeof XHR.prototype.open
|
|
187
|
+
const sendOverride = function (this: XMLHttpRequest, ...args: any[]) { // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
188
|
+
const info = (this as any).__annot // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
189
|
+
if (info) { info.start = Date.now(); this.addEventListener('loadend', () => record(info.method, info.url, this.status || undefined, Date.now() - info.start, this.status >= 200 && this.status < 400), { once: true }) }
|
|
190
|
+
return (origSend as any).apply(this, args) // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
191
|
+
} as typeof XHR.prototype.send
|
|
192
|
+
XHR.prototype.open = openOverride
|
|
193
|
+
XHR.prototype.send = sendOverride
|
|
194
|
+
return () => {
|
|
195
|
+
_netHooked = false
|
|
196
|
+
_netBuf.length = 0
|
|
197
|
+
if (window.fetch === wrappedFetch) window.fetch = origFetch // don't clobber a wrapper installed after us
|
|
198
|
+
if (XHR.prototype.open === openOverride) XHR.prototype.open = origOpen
|
|
199
|
+
if (XHR.prototype.send === sendOverride) XHR.prototype.send = origSend
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const recentNet = () => _netBuf.slice(-8)
|
|
203
|
+
|
|
204
|
+
// Temporarily blur password fields + [data-annot-redact] elements while a screenshot is captured.
|
|
205
|
+
function maskForCapture(): () => void {
|
|
206
|
+
const els = Array.from(document.querySelectorAll<HTMLElement>('input[type="password" i], [data-annot-redact]'))
|
|
207
|
+
const prev = els.map((el) => el.style.filter)
|
|
208
|
+
els.forEach((el) => { el.style.filter = 'blur(10px)' })
|
|
209
|
+
return () => els.forEach((el, i) => { el.style.filter = prev[i] })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
type Draft = {
|
|
213
|
+
kind: 'pin' | 'shape'; shape_type?: AnnShape
|
|
214
|
+
page_route: string; page_title: string; target_selector: string; target_text: string
|
|
215
|
+
pos_x: number; pos_y: number; x2?: number; y2?: number; points?: AnnPoint[]
|
|
216
|
+
viewport_w: number; viewport_h: number
|
|
217
|
+
note: string; type: AnnType; priority: AnnPriority; color: string; visibility: AnnVisibility; shot: boolean
|
|
218
|
+
styles?: Record<string, string> | null; el_context?: string[] | null; meta?: AnnMeta; console_logs?: AnnLog[]; network_logs?: AnnNetLog[]; dom_snapshot?: string | null
|
|
219
|
+
cx: number; cy: number
|
|
220
|
+
}
|
|
221
|
+
type Drawing = { shape_type: AnnShape; x: number; y: number; x2: number; y2: number; points: AnnPoint[] } | null
|
|
222
|
+
|
|
223
|
+
// ---- element identity: a resilient selector + a re-finder so a pin can be relocated on revisit ----
|
|
224
|
+
function cssSelector(el: Element | null): string {
|
|
225
|
+
if (!el || el === document.body) return 'body'
|
|
226
|
+
const parts: string[] = []
|
|
227
|
+
let node: Element | null = el
|
|
228
|
+
let depth = 0
|
|
229
|
+
while (node && node.nodeType === 1 && node !== document.body && depth < 6) {
|
|
230
|
+
const h = node as HTMLElement
|
|
231
|
+
if (h.id) { parts.unshift(`#${CSS.escape(h.id)}`); break }
|
|
232
|
+
const stable = h.getAttribute('data-testid') || h.getAttribute('name') || h.getAttribute('aria-label')
|
|
233
|
+
if (stable) { parts.unshift(`${node.tagName.toLowerCase()}[${h.getAttribute('data-testid') ? 'data-testid' : h.getAttribute('name') ? 'name' : 'aria-label'}="${CSS.escape(stable)}"]`); break }
|
|
234
|
+
let part = node.tagName.toLowerCase()
|
|
235
|
+
const cls = (node.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2).map((c) => CSS.escape(c))
|
|
236
|
+
if (cls.length) part += '.' + cls.join('.')
|
|
237
|
+
const parent: Element | null = node.parentElement
|
|
238
|
+
if (parent) {
|
|
239
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node!.tagName)
|
|
240
|
+
if (sibs.length > 1) part += `:nth-of-type(${sibs.indexOf(node) + 1})`
|
|
241
|
+
}
|
|
242
|
+
parts.unshift(part)
|
|
243
|
+
node = node.parentElement
|
|
244
|
+
depth++
|
|
245
|
+
}
|
|
246
|
+
return parts.join(' > ')
|
|
247
|
+
}
|
|
248
|
+
const isVisible = (el: Element | null): el is HTMLElement => {
|
|
249
|
+
if (!el) return false
|
|
250
|
+
const r = (el as HTMLElement).getBoundingClientRect()
|
|
251
|
+
return r.width > 0 && r.height > 0
|
|
252
|
+
}
|
|
253
|
+
function findEl(a: { target_selector?: string; target_text?: string }): HTMLElement | null {
|
|
254
|
+
if (a.target_selector) { try { const el = document.querySelector(a.target_selector); if (isVisible(el)) return el as HTMLElement } catch { /* invalid selector */ } }
|
|
255
|
+
const txt = (a.target_text || '').trim()
|
|
256
|
+
if (txt && txt.length >= 2 && txt.length <= 80) {
|
|
257
|
+
const els = Array.from(document.querySelectorAll('button, a, h1, h2, h3, span, p, td, th, label, div'))
|
|
258
|
+
for (const el of els) { if ((el.textContent || '').replace(/\s+/g, ' ').trim() === txt && isVisible(el) && !el.closest('[data-annot-ui]')) return el as HTMLElement }
|
|
259
|
+
}
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
const elLabel = (el: HTMLElement): string => {
|
|
263
|
+
const tag = el.tagName.toLowerCase()
|
|
264
|
+
const txt = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 36)
|
|
265
|
+
return txt ? `${tag} · “${txt}”` : tag
|
|
266
|
+
}
|
|
267
|
+
const rgbToHex = (v: string): string => {
|
|
268
|
+
const m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(v || '')
|
|
269
|
+
if (!m) return v || ''
|
|
270
|
+
if (m[4] !== undefined && Number(m[4]) === 0) return 'transparent'
|
|
271
|
+
const h = (n: string) => Number(n).toString(16).padStart(2, '0')
|
|
272
|
+
return `#${h(m[1])}${h(m[2])}${h(m[3])}`.toUpperCase()
|
|
273
|
+
}
|
|
274
|
+
function captureStyles(el: HTMLElement): Record<string, string> | null {
|
|
275
|
+
try {
|
|
276
|
+
const cs = getComputedStyle(el); const r = el.getBoundingClientRect()
|
|
277
|
+
return { color: rgbToHex(cs.color), background: rgbToHex(cs.backgroundColor), fontSize: cs.fontSize, fontWeight: cs.fontWeight, radius: cs.borderRadius, padding: cs.padding, size: `${Math.round(r.width)}×${Math.round(r.height)}` }
|
|
278
|
+
} catch { return null }
|
|
279
|
+
}
|
|
280
|
+
function captureContext(el: HTMLElement): string[] {
|
|
281
|
+
const out: string[] = []; let node = el.parentElement; let i = 0
|
|
282
|
+
const own = (el.textContent || '').trim()
|
|
283
|
+
while (node && node.tagName !== 'BODY' && out.length < 3 && i < 12) {
|
|
284
|
+
if (!node.closest('[data-annot-ui]')) {
|
|
285
|
+
const aria = node.getAttribute('aria-label')
|
|
286
|
+
let lbl = aria || ''
|
|
287
|
+
if (!lbl) { const first = (node.textContent || '').trim().split('\n')[0].trim(); if (first.length >= 3 && first.length <= 52) lbl = first }
|
|
288
|
+
if (lbl && lbl !== own && !out.includes(lbl)) out.push(lbl)
|
|
289
|
+
}
|
|
290
|
+
node = node.parentElement; i++
|
|
291
|
+
}
|
|
292
|
+
return out
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function Highlight({ el, tone = '#6366f1', label }: { el: HTMLElement | null; tone?: string; label?: string }) {
|
|
296
|
+
if (!el) return null
|
|
297
|
+
const r = el.getBoundingClientRect()
|
|
298
|
+
if (r.width === 0 && r.height === 0) return null
|
|
299
|
+
return (
|
|
300
|
+
<div data-annot-ui className="pointer-events-none fixed z-[9996] rounded-md" style={{ left: r.left - 3, top: r.top - 3, width: r.width + 6, height: r.height + 6, boxShadow: `0 0 0 2px ${tone}, 0 0 0 6px ${tone}22`, transition: 'all .08s' }}>
|
|
301
|
+
{label && <span className="absolute -top-5 left-0 whitespace-nowrap rounded bg-fg px-1.5 py-0.5 text-[10px] font-medium text-white">{label}</span>}
|
|
302
|
+
</div>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function ShapeSvg({ s, dash }: { s: { shape_type?: string | null; pos_x: number; pos_y: number; x2?: number | null; y2?: number | null; points?: AnnPoint[] | null; color?: string | null }; dash?: boolean }) {
|
|
307
|
+
const stroke = s.color || '#6366f1'; const sw = 2.5; const d = dash ? '5 4' : undefined
|
|
308
|
+
const x = s.pos_x, y = s.pos_y, x2 = s.x2 ?? s.pos_x, y2 = s.y2 ?? s.pos_y
|
|
309
|
+
if (s.shape_type === 'rect') return <rect x={Math.min(x, x2)} y={Math.min(y, y2)} width={Math.abs(x2 - x)} height={Math.abs(y2 - y)} rx="5" fill="none" stroke={stroke} strokeWidth={sw} strokeDasharray={d} />
|
|
310
|
+
if (s.shape_type === 'circle') return <ellipse cx={(x + x2) / 2} cy={(y + y2) / 2} rx={Math.abs(x2 - x) / 2} ry={Math.abs(y2 - y) / 2} fill="none" stroke={stroke} strokeWidth={sw} strokeDasharray={d} />
|
|
311
|
+
if (s.shape_type === 'arrow') {
|
|
312
|
+
const mid = `annarr-${Math.round(x)}-${Math.round(y)}`
|
|
313
|
+
return (<>
|
|
314
|
+
<defs><marker id={mid} markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill={stroke} /></marker></defs>
|
|
315
|
+
<line x1={x} y1={y} x2={x2} y2={y2} stroke={stroke} strokeWidth={sw + 0.5} strokeDasharray={d} markerEnd={`url(#${mid})`} />
|
|
316
|
+
</>)
|
|
317
|
+
}
|
|
318
|
+
return <polyline points={(Array.isArray(s.points) ? s.points : []).map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke={stroke} strokeWidth={sw} strokeLinejoin="round" strokeLinecap="round" strokeDasharray={d} />
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const chip = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium'
|
|
322
|
+
|
|
323
|
+
export function Annotate({ adapter, route, onNavigate, user, onToast, pollMs = 15_000, captureConsole = false, captureFullUrl = false, captureText = true, singleKeyShortcuts = true, captureNetwork = false, captureDomSnapshot = false, redactScreenshots = true, theme = 'light', accent, redact, deliveries = [] }: AnnotateProps) {
|
|
324
|
+
// --- route + navigation (framework-agnostic) ---
|
|
325
|
+
const [browserPath, setBrowserPath] = useState(() => (typeof window !== 'undefined' ? window.location.pathname : '/'))
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
if (route !== undefined) return
|
|
328
|
+
const on = () => setBrowserPath(window.location.pathname)
|
|
329
|
+
window.addEventListener('popstate', on)
|
|
330
|
+
return () => window.removeEventListener('popstate', on)
|
|
331
|
+
}, [route])
|
|
332
|
+
const pathname = route ?? browserPath
|
|
333
|
+
const navigate = useCallback((p: string) => { if (onNavigate) onNavigate(p); else window.location.assign(p) }, [onNavigate])
|
|
334
|
+
const redactFn = useCallback((s: string) => (redact ? redact(s) : defaultRedact(s)), [redact])
|
|
335
|
+
const accentCss = safeColor(accent)
|
|
336
|
+
|
|
337
|
+
// --- built-in mini-toast (used only when onToast is not supplied) ---
|
|
338
|
+
const [toasts, setToasts] = useState<{ id: number; kind: 'success' | 'error'; msg: string }[]>([])
|
|
339
|
+
const toastSeq = useRef(0)
|
|
340
|
+
const toast = useCallback((kind: 'success' | 'error', msg: string) => {
|
|
341
|
+
if (onToast) { onToast(kind, msg); return }
|
|
342
|
+
const id = ++toastSeq.current
|
|
343
|
+
setToasts((t) => [...t, { id, kind, msg }])
|
|
344
|
+
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600)
|
|
345
|
+
}, [onToast])
|
|
346
|
+
|
|
347
|
+
// --- access + identity (from the adapter) ---
|
|
348
|
+
const [access, setAccessData] = useState<AnnotateAccess | null>(null)
|
|
349
|
+
const [accessLoading, setAccessLoading] = useState(true)
|
|
350
|
+
const [accessBusy, setAccessBusy] = useState(false)
|
|
351
|
+
const refreshAccess = useCallback(async () => {
|
|
352
|
+
try { setAccessData(await adapter.getAccess()) } catch (e) { console.warn('[annotate] getAccess failed', e) } finally { setAccessLoading(false) }
|
|
353
|
+
}, [adapter])
|
|
354
|
+
useEffect(() => { refreshAccess() }, [refreshAccess])
|
|
355
|
+
const canUse = !!access?.canUse
|
|
356
|
+
const isAdmin = !!access?.isAdmin
|
|
357
|
+
const seeAll = !!access?.seeAll
|
|
358
|
+
const enabled = !!access?.enabled
|
|
359
|
+
const me = user ?? access?.me ?? {}
|
|
360
|
+
const meEmail = me.email || ''
|
|
361
|
+
const meName = me.name || me.email || 'me'
|
|
362
|
+
|
|
363
|
+
// Console/JS-error capture is installed lazily — only when the tool is usable AND opted in — and
|
|
364
|
+
// torn down on cleanup. No global console monkeypatch happens at import time.
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
if (!canUse || !captureConsole) return
|
|
367
|
+
return hookConsole(redactFn)
|
|
368
|
+
}, [canUse, captureConsole, redactFn])
|
|
369
|
+
|
|
370
|
+
// Network capture — same lazy, opt-in, cleaned-up-on-unmount treatment as the console hook.
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
if (!canUse || !captureNetwork) return
|
|
373
|
+
return hookNetwork(redactFn)
|
|
374
|
+
}, [canUse, captureNetwork, redactFn])
|
|
375
|
+
|
|
376
|
+
const [tool, setTool] = useState<Tool>(null)
|
|
377
|
+
const [color, setColor] = useState(ANN_COLORS[0])
|
|
378
|
+
const [panel, setPanel] = useState(false)
|
|
379
|
+
const [draft, setDraft] = useState<Draft | null>(null)
|
|
380
|
+
const [drawing, setDrawing] = useState<Drawing>(null)
|
|
381
|
+
const [active, setActive] = useState<Annotation | null>(null)
|
|
382
|
+
const [busy, setBusy] = useState(false)
|
|
383
|
+
const [tick, setTick] = useState(0)
|
|
384
|
+
const [flashId, setFlashId] = useState<number | null>(null)
|
|
385
|
+
const [hoverEl, setHoverEl] = useState<HTMLElement | null>(null)
|
|
386
|
+
const [filter, setFilter] = useState<'all' | AnnStatus>('all')
|
|
387
|
+
const [typeFilter, setTypeFilter] = useState<'' | AnnType>('')
|
|
388
|
+
const [prioFilter, setPrioFilter] = useState<'' | AnnPriority>('')
|
|
389
|
+
const [thisPageOnly, setThisPageOnly] = useState(false)
|
|
390
|
+
const [authorFilter, setAuthorFilter] = useState('')
|
|
391
|
+
const [search, setSearch] = useState('')
|
|
392
|
+
const [reply, setReply] = useState('')
|
|
393
|
+
const [peek, setPeek] = useState(false)
|
|
394
|
+
const [shotBusy, setShotBusy] = useState(false)
|
|
395
|
+
const [markupBlob, setMarkupBlob] = useState<Blob | null>(null)
|
|
396
|
+
const [delivering, setDelivering] = useState(false)
|
|
397
|
+
const [sendOpen, setSendOpen] = useState(false)
|
|
398
|
+
const [helpOpen, setHelpOpen] = useState(false)
|
|
399
|
+
const sendTrap = useFocusTrap(sendOpen)
|
|
400
|
+
const helpTrap = useFocusTrap(helpOpen)
|
|
401
|
+
const panelTrap = useFocusTrap(panel)
|
|
402
|
+
const [editRow, setEditRow] = useState<number | null>(null)
|
|
403
|
+
const [hintSeen, setHintSeen] = useState(() => { try { return localStorage.getItem('annot-hint') === '1' } catch { return true } })
|
|
404
|
+
const [pos, setPos] = useState<{ x: number; y: number } | null>(() => {
|
|
405
|
+
try {
|
|
406
|
+
const p = JSON.parse(localStorage.getItem('annot-pos') || 'null')
|
|
407
|
+
if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) {
|
|
408
|
+
const mx = (typeof window !== 'undefined' ? window.innerWidth : 9999) - 64
|
|
409
|
+
const my = (typeof window !== 'undefined' ? window.innerHeight : 9999) - 64
|
|
410
|
+
return { x: Math.min(Math.max(4, p.x), mx), y: Math.min(Math.max(4, p.y), my) }
|
|
411
|
+
}
|
|
412
|
+
} catch { /* ignore */ }
|
|
413
|
+
return null
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
const draftElRef = useRef<HTMLElement | null>(null)
|
|
417
|
+
const hoverRef = useRef<HTMLElement | null>(null)
|
|
418
|
+
const drawRef = useRef<Drawing>(null)
|
|
419
|
+
const undoStackRef = useRef<number[]>([])
|
|
420
|
+
|
|
421
|
+
// --- annotations list (plain state + polling while the tool/panel is open) ---
|
|
422
|
+
const [all, setAll] = useState<Annotation[]>([])
|
|
423
|
+
const refresh = useCallback(async () => {
|
|
424
|
+
try { setAll(await adapter.list()) } catch (e) { console.warn('[annotate] list failed', e) }
|
|
425
|
+
}, [adapter])
|
|
426
|
+
const invalidate = refresh
|
|
427
|
+
useEffect(() => { if (canUse) refresh() }, [canUse, refresh])
|
|
428
|
+
// realtime subscription (adapters that support it) — NOT re-run on tool/panel toggles.
|
|
429
|
+
// Backstop is a refresh when the tab regains focus (no continuous polling while idle).
|
|
430
|
+
useEffect(() => {
|
|
431
|
+
if (!canUse || !adapter.subscribe) return
|
|
432
|
+
const un = adapter.subscribe(() => { refresh() })
|
|
433
|
+
const onVis = () => { if (document.visibilityState === 'visible') refresh() }
|
|
434
|
+
document.addEventListener('visibilitychange', onVis)
|
|
435
|
+
return () => { un(); document.removeEventListener('visibilitychange', onVis) }
|
|
436
|
+
}, [canUse, adapter, refresh])
|
|
437
|
+
// fallback poll while the tool/panel is open (only for adapters without realtime)
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
if (!canUse || adapter.subscribe || !(tool || panel)) return
|
|
440
|
+
const id = setInterval(refresh, pollMs)
|
|
441
|
+
return () => clearInterval(id)
|
|
442
|
+
}, [canUse, tool, panel, pollMs, refresh, adapter])
|
|
443
|
+
|
|
444
|
+
const openCount = all.filter((a) => OPEN_STATUSES.includes(a.status)).length
|
|
445
|
+
|
|
446
|
+
const marksShown = !peek && (!!tool || panel || flashId != null)
|
|
447
|
+
const pageMarks = useMemo(() => all.filter((a) => a.page_route === pathname && (OPEN_STATUSES.includes(a.status) || a.id === flashId)), [all, pathname, flashId])
|
|
448
|
+
// Resolve each visible pin's element ONCE per frame (keyed on scroll/resize tick) instead of
|
|
449
|
+
// re-running findEl (with its querySelectorAll fallback) on every render for every mark.
|
|
450
|
+
const markEls = useMemo(() => {
|
|
451
|
+
const m = new Map<number, HTMLElement | null>()
|
|
452
|
+
for (const a of pageMarks) m.set(a.id, a.kind === 'shape' ? null : findEl(a))
|
|
453
|
+
return m
|
|
454
|
+
}, [pageMarks, tick, pathname]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
455
|
+
|
|
456
|
+
const dismissHint = () => { setHintSeen(true); try { localStorage.setItem('annot-hint', '1') } catch { /* ignore */ } }
|
|
457
|
+
|
|
458
|
+
// ---- PIN tool ----
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
if (tool !== 'pin') { if (tool === null) { setHoverEl(null); hoverRef.current = null } return }
|
|
461
|
+
document.body.style.cursor = 'crosshair'
|
|
462
|
+
const onMove = (e: MouseEvent) => {
|
|
463
|
+
if (draftElRef.current) return
|
|
464
|
+
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null
|
|
465
|
+
const t = el && !el.closest('[data-annot-ui]') ? el : null
|
|
466
|
+
if (t !== hoverRef.current) { hoverRef.current = t; setHoverEl(t) }
|
|
467
|
+
}
|
|
468
|
+
const onClick = (e: MouseEvent) => {
|
|
469
|
+
const t = e.target as Element
|
|
470
|
+
if (t.closest('[data-annot-ui]')) return
|
|
471
|
+
e.preventDefault(); e.stopPropagation()
|
|
472
|
+
const el = t as HTMLElement
|
|
473
|
+
draftElRef.current = el; setHoverEl(el); setActive(null)
|
|
474
|
+
setDraft({
|
|
475
|
+
kind: 'pin', page_route: pathname, page_title: document.title || pathname,
|
|
476
|
+
target_selector: cssSelector(el), target_text: captureText ? redactFn((el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120)) : '',
|
|
477
|
+
pos_x: Math.round(e.pageX), pos_y: Math.round(e.pageY), viewport_w: window.innerWidth, viewport_h: window.innerHeight,
|
|
478
|
+
note: '', type: 'change', priority: 'med', color, visibility: 'private', shot: false,
|
|
479
|
+
styles: captureStyles(el), el_context: captureText ? captureContext(el).map(redactFn) : [], meta: captureEnv(captureFullUrl), console_logs: captureConsole ? recentLogs() : [], network_logs: captureNetwork ? recentNet() : [], dom_snapshot: captureDomSnapshot ? redactFn(sanitizeSnapshot(el.outerHTML)) : null,
|
|
480
|
+
cx: e.clientX, cy: e.clientY,
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
document.addEventListener('mousemove', onMove, true)
|
|
484
|
+
document.addEventListener('click', onClick, true)
|
|
485
|
+
return () => { document.body.style.cursor = ''; document.removeEventListener('mousemove', onMove, true); document.removeEventListener('click', onClick, true) }
|
|
486
|
+
}, [tool, pathname, color, isAdmin, canUse, captureText, captureFullUrl, captureConsole, captureNetwork, captureDomSnapshot, redactFn])
|
|
487
|
+
|
|
488
|
+
// ---- SHAPE tools ----
|
|
489
|
+
useEffect(() => {
|
|
490
|
+
if (!tool || tool === 'pin') return
|
|
491
|
+
document.body.style.cursor = 'crosshair'
|
|
492
|
+
const prevTouch = document.body.style.touchAction
|
|
493
|
+
document.body.style.touchAction = 'none'
|
|
494
|
+
const onDown = (e: PointerEvent) => {
|
|
495
|
+
if ((e.target as Element).closest?.('[data-annot-ui]')) return
|
|
496
|
+
e.preventDefault(); e.stopPropagation()
|
|
497
|
+
const start: Drawing = { shape_type: tool, x: e.pageX, y: e.pageY, x2: e.pageX, y2: e.pageY, points: [{ x: e.pageX, y: e.pageY }] }
|
|
498
|
+
drawRef.current = start; setDrawing(start); setActive(null)
|
|
499
|
+
}
|
|
500
|
+
const onMove = (e: PointerEvent) => {
|
|
501
|
+
const d = drawRef.current; if (!d) return
|
|
502
|
+
e.preventDefault()
|
|
503
|
+
const next: Drawing = tool === 'freehand'
|
|
504
|
+
? { ...d, x2: e.pageX, y2: e.pageY, points: (Math.hypot(e.pageX - d.points[d.points.length - 1].x, e.pageY - d.points[d.points.length - 1].y) > 3 ? [...d.points, { x: e.pageX, y: e.pageY }] : d.points) }
|
|
505
|
+
: { ...d, x2: e.pageX, y2: e.pageY }
|
|
506
|
+
drawRef.current = next; setDrawing(next)
|
|
507
|
+
}
|
|
508
|
+
const onUp = (e: PointerEvent) => {
|
|
509
|
+
const d = drawRef.current; drawRef.current = null; setDrawing(null)
|
|
510
|
+
if (!d) return
|
|
511
|
+
const w = Math.abs(d.x2 - d.x), h = Math.abs(d.y2 - d.y)
|
|
512
|
+
if (d.shape_type === 'freehand' ? d.points.length < 3 : (w < 8 && h < 8)) return
|
|
513
|
+
const anchor = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null
|
|
514
|
+
setDraft({
|
|
515
|
+
kind: 'shape', shape_type: d.shape_type, page_route: pathname, page_title: document.title || pathname,
|
|
516
|
+
target_selector: anchor && !anchor.closest('[data-annot-ui]') ? cssSelector(anchor) : '', target_text: '',
|
|
517
|
+
pos_x: Math.round(d.x), pos_y: Math.round(d.y), x2: Math.round(d.x2), y2: Math.round(d.y2), points: d.shape_type === 'freehand' ? d.points : undefined,
|
|
518
|
+
viewport_w: window.innerWidth, viewport_h: window.innerHeight,
|
|
519
|
+
note: '', type: 'change', priority: 'med', color, visibility: 'private', shot: false,
|
|
520
|
+
meta: captureEnv(captureFullUrl), console_logs: captureConsole ? recentLogs() : [], network_logs: captureNetwork ? recentNet() : [], cx: e.clientX, cy: e.clientY,
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
document.addEventListener('pointerdown', onDown, true)
|
|
524
|
+
document.addEventListener('pointermove', onMove, { capture: true, passive: false })
|
|
525
|
+
document.addEventListener('pointerup', onUp, true)
|
|
526
|
+
document.addEventListener('pointercancel', onUp, true)
|
|
527
|
+
return () => {
|
|
528
|
+
document.body.style.cursor = ''; document.body.style.touchAction = prevTouch
|
|
529
|
+
document.removeEventListener('pointerdown', onDown, true)
|
|
530
|
+
document.removeEventListener('pointermove', onMove, { capture: true } as EventListenerOptions)
|
|
531
|
+
document.removeEventListener('pointerup', onUp, true)
|
|
532
|
+
document.removeEventListener('pointercancel', onUp, true)
|
|
533
|
+
drawRef.current = null; setDrawing(null)
|
|
534
|
+
}
|
|
535
|
+
}, [tool, pathname, color, isAdmin, canUse, captureText, captureFullUrl, captureConsole, captureNetwork, captureDomSnapshot, redactFn])
|
|
536
|
+
|
|
537
|
+
useEffect(() => {
|
|
538
|
+
if (!canUse) return
|
|
539
|
+
let raf = 0
|
|
540
|
+
const on = () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; setTick((x) => x + 1) }) }
|
|
541
|
+
window.addEventListener('scroll', on, true); window.addEventListener('resize', on)
|
|
542
|
+
return () => { if (raf) cancelAnimationFrame(raf); window.removeEventListener('scroll', on, true); window.removeEventListener('resize', on) }
|
|
543
|
+
}, [canUse])
|
|
544
|
+
|
|
545
|
+
useEffect(() => {
|
|
546
|
+
if (!canUse) return // never attach global key handlers for a viewer who can't use the tool
|
|
547
|
+
const h = (e: KeyboardEvent) => {
|
|
548
|
+
const typing = /input|textarea|select/i.test((e.target as Element)?.tagName || '') || (e.target as HTMLElement)?.isContentEditable
|
|
549
|
+
if (e.key === 'Escape') { if (helpOpen) setHelpOpen(false); else if (sendOpen) setSendOpen(false); else if (draft) closeDraft(); else if (panel) setPanel(false); else if (tool) setTool(null); return }
|
|
550
|
+
if (typing) return
|
|
551
|
+
const meta = e.ctrlKey || e.metaKey
|
|
552
|
+
if (meta && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undoLast(); return }
|
|
553
|
+
if (e.key === '?') { e.preventDefault(); setHelpOpen((v) => !v); return }
|
|
554
|
+
if (e.shiftKey && e.key.toLowerCase() === 'h') { e.preventDefault(); setPeek((v) => !v); return }
|
|
555
|
+
if (e.shiftKey && e.key.toLowerCase() === 'a') { e.preventDefault(); setTool((m) => (m === 'pin' ? null : 'pin')); return }
|
|
556
|
+
if (!singleKeyShortcuts) return // bare-letter tool hotkeys are opt-out (avoid host-app collisions)
|
|
557
|
+
const map: Record<string, Tool> = { p: 'pin', b: 'rect', a: 'arrow', c: 'circle', d: 'freehand' }
|
|
558
|
+
const t = map[e.key.toLowerCase()]
|
|
559
|
+
if (t && !e.shiftKey && !meta) { e.preventDefault(); setTool((cur) => (cur === t ? null : t)) }
|
|
560
|
+
}
|
|
561
|
+
document.addEventListener('keydown', h)
|
|
562
|
+
return () => document.removeEventListener('keydown', h)
|
|
563
|
+
}, [tool, draft, helpOpen, sendOpen, panel, canUse, singleKeyShortcuts]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
564
|
+
|
|
565
|
+
useEffect(() => { if (active) { const f = all.find((a) => a.id === active.id); if (f && f !== active) setActive(f) } }, [all]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
566
|
+
|
|
567
|
+
async function uploadShot(el: HTMLElement, visibility: AnnVisibility): Promise<string | null> {
|
|
568
|
+
const unmask = redactScreenshots ? maskForCapture() : () => {}
|
|
569
|
+
let blob: Blob | null = null
|
|
570
|
+
try { blob = await domToBlob(el, { backgroundColor: '#ffffff', scale: 2, quality: 0.95, style: { margin: '0' } }) }
|
|
571
|
+
catch (e) { console.warn('[annotate] screenshot failed', e) }
|
|
572
|
+
finally { unmask() }
|
|
573
|
+
try {
|
|
574
|
+
if (!blob) return null
|
|
575
|
+
const shot = new File([blob], 'shot.png', { type: blob.type || 'image/png' })
|
|
576
|
+
const { path } = await adapter.uploadScreenshot(shot, { visibility })
|
|
577
|
+
return path
|
|
578
|
+
} catch (e) { console.warn('[annotate] screenshot failed', e); return null }
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function captureBlob(full: boolean): Promise<Blob | null> {
|
|
582
|
+
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 40)))
|
|
583
|
+
const target = full ? document.body : document.documentElement
|
|
584
|
+
const pageW = full ? Math.max(document.documentElement.scrollWidth, window.innerWidth) : window.innerWidth
|
|
585
|
+
const pageH = full ? Math.max(document.documentElement.scrollHeight, window.innerHeight) : window.innerHeight
|
|
586
|
+
let scale = Math.min(2, window.devicePixelRatio || 1)
|
|
587
|
+
// stay under browser canvas limits — cap total area AND each dimension (~16k px hard cap)
|
|
588
|
+
while (scale > 0.5 && (pageW * pageH * scale * scale > 24_000_000 || pageW * scale > 16000 || pageH * scale > 16000)) scale -= 0.5
|
|
589
|
+
const unmask = redactScreenshots ? maskForCapture() : () => {}
|
|
590
|
+
try {
|
|
591
|
+
return await domToBlob(target, {
|
|
592
|
+
backgroundColor: '#ffffff', scale,
|
|
593
|
+
width: full ? undefined : window.innerWidth, height: full ? undefined : window.innerHeight,
|
|
594
|
+
filter: (n: Node) => !(n instanceof HTMLElement && n.hasAttribute('data-annot-ui')),
|
|
595
|
+
})
|
|
596
|
+
} finally { unmask() }
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function startMarkup() {
|
|
600
|
+
if (shotBusy) return
|
|
601
|
+
setShotBusy(true)
|
|
602
|
+
try {
|
|
603
|
+
const blob = await captureBlob(false)
|
|
604
|
+
if (!blob) throw new Error('capture failed')
|
|
605
|
+
setMarkupBlob(blob)
|
|
606
|
+
} catch (e) { toast('error', errMsg(e) || 'Capture failed') } finally { setShotBusy(false) }
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function capturePage(full: boolean) {
|
|
610
|
+
if (shotBusy) return
|
|
611
|
+
setShotBusy(true)
|
|
612
|
+
try {
|
|
613
|
+
const blob = await captureBlob(full)
|
|
614
|
+
if (!blob) throw new Error('capture failed')
|
|
615
|
+
const canClip = !!navigator.clipboard && typeof window.ClipboardItem === 'function'
|
|
616
|
+
if (canClip) { try { await navigator.clipboard.write([new window.ClipboardItem({ 'image/png': blob })]); toast('success', `${full ? 'Full page' : 'Viewport'} copied — paste anywhere`); return } catch { /* download */ } }
|
|
617
|
+
const url = URL.createObjectURL(blob); const a = document.createElement('a')
|
|
618
|
+
a.href = url; a.download = `screenshot-${full ? 'fullpage' : 'viewport'}-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`; a.click()
|
|
619
|
+
setTimeout(() => URL.revokeObjectURL(url), 500)
|
|
620
|
+
toast('success', 'Screenshot downloaded')
|
|
621
|
+
} catch (e) { toast('error', errMsg(e) || 'Screenshot failed') } finally { setShotBusy(false) }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const submitDraft = async () => {
|
|
625
|
+
if (!draft) return
|
|
626
|
+
if (draft.kind === 'pin' && !draft.note.trim()) return
|
|
627
|
+
setBusy(true)
|
|
628
|
+
try {
|
|
629
|
+
let screenshot_path: string | null = null
|
|
630
|
+
if (draft.shot && draftElRef.current) { screenshot_path = await uploadShot(draftElRef.current, draft.visibility); if (!screenshot_path) toast('error', 'Screenshot failed — saved without it') }
|
|
631
|
+
const { cx: _cx, cy: _cy, shot: _shot, ...body } = draft // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
632
|
+
const saved = await adapter.save({ ...body, screenshot_path })
|
|
633
|
+
if (saved?.id) { undoStackRef.current.push(saved.id); if (undoStackRef.current.length > 30) undoStackRef.current.shift() }
|
|
634
|
+
invalidate(); closeDraft(); dismissHint(); toast('success', 'Saved ✓')
|
|
635
|
+
} catch (e) { toast('error', errMsg(e)) } finally { setBusy(false) }
|
|
636
|
+
}
|
|
637
|
+
const closeDraft = () => { setDraft(null); draftElRef.current = null; setHoverEl(null) }
|
|
638
|
+
|
|
639
|
+
const doUpdate = (patch: { id: number } & Record<string, unknown>) => adapter.update(patch as never).then(invalidate).catch((e) => toast('error', errMsg(e)))
|
|
640
|
+
const doDelete = (id: number) => adapter.remove(id).then(() => { invalidate(); setActive(null); toast('success', 'Deleted') }).catch((e) => toast('error', errMsg(e)))
|
|
641
|
+
const doAddReply = (id: number, note: string) => adapter.addReply(id, note).then(() => { invalidate(); setReply(''); toast('success', 'Reply added') }).catch((e) => toast('error', errMsg(e)))
|
|
642
|
+
const doClearResolved = () => adapter.clearResolved().then((r) => { invalidate(); toast('success', r?.message || 'Cleared') }).catch((e) => toast('error', errMsg(e)))
|
|
643
|
+
const toggleAccess = async (next: boolean) => {
|
|
644
|
+
setAccessBusy(true)
|
|
645
|
+
try { const r = await adapter.setAccess(next); await refreshAccess(); toast('success', r?.message || 'Saved') }
|
|
646
|
+
catch (e) { toast('error', errMsg(e)) } finally { setAccessBusy(false) }
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const undoLast = () => { const id = undoStackRef.current.pop(); if (id) doDelete(id) }
|
|
650
|
+
|
|
651
|
+
const jumpTo = (a: Annotation) => {
|
|
652
|
+
const go = () => {
|
|
653
|
+
const el = findEl(a)
|
|
654
|
+
if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); setHoverEl(el) }
|
|
655
|
+
else window.scrollTo({ top: Math.max(0, a.pos_y - 140), behavior: 'smooth' })
|
|
656
|
+
setActive(a); setFlashId(a.id); setTimeout(() => setFlashId(null), 4000)
|
|
657
|
+
}
|
|
658
|
+
if (a.page_route !== pathname && isSafePath(a.page_route)) { setPanel(false); navigate(a.page_route); setTimeout(go, 400) } else go()
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const copyPrompt = async (items: Annotation[]) => { try { await navigator.clipboard.writeText(buildDevPrompt(items)); toast('success', `Copied ${items.length} mark(s) — paste to your developer`) } catch { toast('error', 'Clipboard blocked — use Download') } }
|
|
662
|
+
const downloadMd = (items: Annotation[]) => {
|
|
663
|
+
const blob = new Blob([buildDevPrompt(items)], { type: 'text/markdown;charset=utf-8' })
|
|
664
|
+
const url = URL.createObjectURL(blob); const a = document.createElement('a')
|
|
665
|
+
a.href = url; a.download = `annotations-${new Date().toISOString().slice(0, 10)}.md`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
666
|
+
}
|
|
667
|
+
const doDeliver = async (d: Delivery) => {
|
|
668
|
+
if (delivering || !d.run) return
|
|
669
|
+
setDelivering(true)
|
|
670
|
+
try { await d.run(openItems); toast('success', `Sent to ${d.label}`); setSendOpen(false) }
|
|
671
|
+
catch (e) { toast('error', errMsg(e)) } finally { setDelivering(false) }
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const canEdit = (a: Annotation) => seeAll || a.created_by === meEmail || a.created_by === meName
|
|
675
|
+
const listed = useMemo(() => {
|
|
676
|
+
let r = all
|
|
677
|
+
if (thisPageOnly) r = r.filter((a) => a.page_route === pathname)
|
|
678
|
+
if (filter !== 'all') r = r.filter((a) => a.status === filter)
|
|
679
|
+
if (typeFilter) r = r.filter((a) => a.type === typeFilter)
|
|
680
|
+
if (prioFilter) r = r.filter((a) => a.priority === prioFilter)
|
|
681
|
+
if (authorFilter) r = r.filter((a) => (a.created_by || '') === authorFilter)
|
|
682
|
+
const q = search.trim().toLowerCase()
|
|
683
|
+
if (q) r = r.filter((a) => (a.note + a.page_route + a.target_text + (a.created_by_name || '')).toLowerCase().includes(q))
|
|
684
|
+
return [...r].sort((a, b) => b.id - a.id)
|
|
685
|
+
}, [all, thisPageOnly, filter, typeFilter, prioFilter, authorFilter, search, pathname])
|
|
686
|
+
const grouped = useMemo(() => {
|
|
687
|
+
const m = new Map<string, Annotation[]>()
|
|
688
|
+
for (const a of listed) { const k = a.page_route; if (!m.has(k)) m.set(k, []); m.get(k)!.push(a) }
|
|
689
|
+
return [...m.entries()]
|
|
690
|
+
}, [listed])
|
|
691
|
+
const openItems = all.filter((a) => OPEN_STATUSES.includes(a.status))
|
|
692
|
+
const authors = useMemo(() => { const m = new Map<string, string>(); for (const a of all) if (a.created_by) m.set(a.created_by, a.created_by_name || a.created_by); return [...m.entries()] }, [all])
|
|
693
|
+
const anyFilter = filter !== 'all' || !!typeFilter || !!prioFilter || !!authorFilter || thisPageOnly || !!search.trim()
|
|
694
|
+
|
|
695
|
+
const beginDrag = (e: ReactPointerEvent, onTap?: () => void) => {
|
|
696
|
+
e.preventDefault()
|
|
697
|
+
const el = (e.currentTarget as HTMLElement); const rect = el.getBoundingClientRect()
|
|
698
|
+
const offX = e.clientX - rect.left, offY = e.clientY - rect.top, sx = e.clientX, sy = e.clientY
|
|
699
|
+
let moved = false
|
|
700
|
+
const onMove = (ev: PointerEvent) => { if (!moved && Math.hypot(ev.clientX - sx, ev.clientY - sy) < 5) return; moved = true; setPos({ x: Math.min(Math.max(4, ev.clientX - offX), window.innerWidth - 64), y: Math.min(Math.max(4, ev.clientY - offY), window.innerHeight - 64) }) }
|
|
701
|
+
const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); if (moved) setPos((p) => { if (p) { try { localStorage.setItem('annot-pos', JSON.stringify(p)) } catch { /* ignore */ } } return p }); else onTap?.() }
|
|
702
|
+
document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (accessLoading || !canUse) return null
|
|
706
|
+
|
|
707
|
+
const highlightEl = draft?.kind === 'pin' ? draftElRef.current : (hoverEl || (active && active.page_route === pathname ? (markEls.get(active.id) ?? findEl(active)) : null))
|
|
708
|
+
const highlightTone = draft ? annDot(draft.type) : active ? annDot(active.type) : '#6366f1'
|
|
709
|
+
const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth)
|
|
710
|
+
const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight)
|
|
711
|
+
const fabStyle: CSSProperties = pos ? { position: 'fixed', left: pos.x, top: pos.y } : { position: 'fixed', right: 20, bottom: 20 }
|
|
712
|
+
|
|
713
|
+
return createPortal(
|
|
714
|
+
<OverlayBoundary key={pathname}>
|
|
715
|
+
{accentCss && <style data-annot-ui>{`[data-annot-ui],[data-annot-ui][data-annot-theme="dark"],[data-annot-theme="dark"] [data-annot-ui]{--color-brand:${accentCss};--color-brand-strong:${accentCss};--color-brand-tint:color-mix(in srgb, ${accentCss} 14%, transparent);--color-brand-hairline:color-mix(in srgb, ${accentCss} 32%, transparent);}`}</style>}
|
|
716
|
+
<div data-annot-ui data-annot-theme={theme}>
|
|
717
|
+
<Highlight el={highlightEl} tone={highlightTone} label={highlightEl ? elLabel(highlightEl) : undefined} />
|
|
718
|
+
|
|
719
|
+
{marksShown && (
|
|
720
|
+
<svg width={docW} height={docH} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', zIndex: 9994, pointerEvents: 'none' }}>
|
|
721
|
+
{pageMarks.filter((m) => m.kind === 'shape').map((s) => <ShapeSvg key={s.id} s={s} />)}
|
|
722
|
+
{draft?.kind === 'shape' && <ShapeSvg s={{ ...draft, color: draft.color }} dash />}
|
|
723
|
+
</svg>
|
|
724
|
+
)}
|
|
725
|
+
{drawing && (
|
|
726
|
+
<svg width={docW} height={docH} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', zIndex: 9994, pointerEvents: 'none' }}>
|
|
727
|
+
<ShapeSvg s={{ shape_type: drawing.shape_type, pos_x: drawing.x, pos_y: drawing.y, x2: drawing.x2, y2: drawing.y2, points: drawing.points, color }} dash />
|
|
728
|
+
</svg>
|
|
729
|
+
)}
|
|
730
|
+
|
|
731
|
+
{marksShown && pageMarks.map((a, i) => {
|
|
732
|
+
const el = markEls.get(a.id) ?? null
|
|
733
|
+
const r = el?.getBoundingClientRect()
|
|
734
|
+
const left = r ? r.left : a.pos_x - window.scrollX
|
|
735
|
+
const top = r ? r.top : a.pos_y - window.scrollY
|
|
736
|
+
const lost = a.kind !== 'shape' && !el
|
|
737
|
+
return (
|
|
738
|
+
<button key={a.id} type="button" data-annot-ui
|
|
739
|
+
onMouseEnter={() => el && setHoverEl(el)} onMouseLeave={() => tool !== 'pin' && setHoverEl(null)}
|
|
740
|
+
onClick={(e) => { e.stopPropagation(); setActive(a); closeDraft() }}
|
|
741
|
+
title={`${a.note || annShapeLabel(a.shape_type)}${lost ? ' (element moved)' : ''}`}
|
|
742
|
+
className={cn('fixed z-[9997] grid h-6 w-6 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full text-[11px] font-bold text-white shadow-md ring-2 ring-white', a.id === flashId && 'animate-bounce ring-4 ring-warning', lost && 'opacity-70')}
|
|
743
|
+
style={{ left, top, background: a.color || annDot(a.type) }}>{i + 1}</button>
|
|
744
|
+
)
|
|
745
|
+
})}
|
|
746
|
+
|
|
747
|
+
{/* ---------------- floating tool dock ---------------- */}
|
|
748
|
+
<div style={fabStyle} className="z-[9998]">
|
|
749
|
+
{tool === null && !panel ? (
|
|
750
|
+
<div className="flex flex-col items-end gap-2">
|
|
751
|
+
{!hintSeen && (
|
|
752
|
+
<div data-annot-ui className="max-w-[220px] rounded-card bg-fg px-3 py-2 text-[11px] text-white shadow-e4">
|
|
753
|
+
<b>Feedback tool</b> — tap here, then click any element or draw to leave a note for the developer.
|
|
754
|
+
<button type="button" onClick={dismissHint} className="mt-1 block text-[10px] font-semibold text-brand-strong">Got it</button>
|
|
755
|
+
</div>
|
|
756
|
+
)}
|
|
757
|
+
<button type="button" data-annot-ui aria-label="Open feedback tool" onPointerDown={(e) => beginDrag(e, () => setTool('pin'))} title="Annotate · drag to move · Shift+A" style={{ touchAction: 'none' }}
|
|
758
|
+
className="relative grid h-12 w-12 place-items-center rounded-full bg-fg text-white shadow-e4 transition hover:bg-ink-hover cursor-grab active:cursor-grabbing">
|
|
759
|
+
<MessageSquarePlus size={20} />
|
|
760
|
+
{openCount > 0 && <span className="absolute -right-1 -top-1 grid h-5 min-w-5 place-items-center rounded-full bg-brand px-1 text-[10px] font-bold text-white ring-2 ring-white">{openCount}</span>}
|
|
761
|
+
</button>
|
|
762
|
+
</div>
|
|
763
|
+
) : (
|
|
764
|
+
<div data-annot-ui className="w-[252px] overflow-hidden rounded-card border border-line bg-surface shadow-e4">
|
|
765
|
+
<div className="flex items-center gap-1 bg-fg px-2 py-1.5 text-white">
|
|
766
|
+
<button type="button" onPointerDown={(e) => beginDrag(e)} title="Drag" style={{ touchAction: 'none' }} className="cursor-grab p-1 text-fg-disabled hover:text-white active:cursor-grabbing"><GripVertical size={14} /></button>
|
|
767
|
+
<span className="flex-1 truncate text-[12px] font-bold">Feedback ({all.length})</span>
|
|
768
|
+
<button type="button" onClick={() => setPeek((v) => !v)} title={peek ? 'Show marks (Shift+H)' : 'Hide marks (Shift+H)'} className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white">{peek ? <EyeOff size={15} /> : <Eye size={15} />}</button>
|
|
769
|
+
<button type="button" onClick={undoLast} title="Undo (Ctrl+Z)" className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white"><Undo2 size={15} /></button>
|
|
770
|
+
<button type="button" onClick={() => capturePage(false)} disabled={shotBusy} title="Viewport screenshot" className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white disabled:opacity-40"><Camera size={15} className={shotBusy ? 'animate-pulse' : ''} /></button>
|
|
771
|
+
<button type="button" onClick={() => capturePage(true)} disabled={shotBusy} title="Full-page screenshot" className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white disabled:opacity-40"><Maximize2 size={15} className={shotBusy ? 'animate-pulse' : ''} /></button>
|
|
772
|
+
<button type="button" onClick={startMarkup} disabled={shotBusy} title="Screenshot & mark up" className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white disabled:opacity-40"><Highlighter size={15} className={shotBusy ? 'animate-pulse' : ''} /></button>
|
|
773
|
+
<button type="button" onClick={() => setHelpOpen(true)} title="Shortcuts (?)" className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white"><Keyboard size={15} /></button>
|
|
774
|
+
<button type="button" onClick={() => { setTool(null); closeDraft() }} title="Close" className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white"><X size={15} /></button>
|
|
775
|
+
</div>
|
|
776
|
+
<div className="space-y-2 p-2">
|
|
777
|
+
<div className="grid grid-cols-5 gap-1">
|
|
778
|
+
{ANN_MODES.map((m) => {
|
|
779
|
+
const Ico = MODE_ICON[m.key]; const on = tool === m.key
|
|
780
|
+
return (
|
|
781
|
+
<button key={m.key} type="button" title={`${m.label} (${m.kbd})`} onClick={() => setTool(on ? null : (m.key as Tool))}
|
|
782
|
+
className={cn('flex flex-col items-center gap-0.5 rounded-input border py-1.5 transition', on ? 'border-brand bg-brand text-white' : 'border-line text-fg-muted hover:bg-row-alt')}>
|
|
783
|
+
<Ico size={15} /><span className="text-[9px] font-bold">{m.label}</span>
|
|
784
|
+
</button>
|
|
785
|
+
)
|
|
786
|
+
})}
|
|
787
|
+
</div>
|
|
788
|
+
<div className="flex items-center gap-1.5">
|
|
789
|
+
{ANN_COLORS.map((c) => (<button key={c} type="button" onClick={() => setColor(c)} style={{ background: c }} title="Colour" className={cn('h-5 w-5 rounded-full border-2 transition', color === c ? 'scale-110 border-fg-secondary' : 'border-line')} />))}
|
|
790
|
+
{peek && <span className="ml-auto rounded bg-warning-tint px-1.5 py-0.5 text-[10px] font-bold text-warning-text ring-1 ring-warning-hairline">Peek</span>}
|
|
791
|
+
</div>
|
|
792
|
+
{tool && (<div className="flex items-center gap-1.5 rounded-input bg-brand-tint px-2 py-1 text-[11px] font-semibold text-brand-strong ring-1 ring-brand-hairline"><Crosshair size={12} /> {tool === 'pin' ? 'Hover an element & click' : 'Drag to draw'} <span className="ml-auto text-brand-strong">Esc</span></div>)}
|
|
793
|
+
<div className="flex gap-1.5">
|
|
794
|
+
<button type="button" onClick={() => setPanel(true)} className="inline-flex flex-1 items-center justify-center gap-1 rounded-input border border-line py-1.5 text-[12px] font-bold text-fg-secondary hover:bg-row-alt"><ListChecks size={14} /> Review</button>
|
|
795
|
+
<button type="button" onClick={() => setSendOpen(true)} disabled={!openItems.length} className="inline-flex flex-1 items-center justify-center gap-1 rounded-input bg-fg py-1.5 text-[12px] font-bold text-white hover:bg-ink-hover disabled:opacity-40"><Send size={14} /> Send</button>
|
|
796
|
+
</div>
|
|
797
|
+
</div>
|
|
798
|
+
</div>
|
|
799
|
+
)}
|
|
800
|
+
</div>
|
|
801
|
+
|
|
802
|
+
{/* ---------------- draft (create) popover ---------------- */}
|
|
803
|
+
{draft && (
|
|
804
|
+
<div data-annot-ui className="fixed z-[9999] w-[19rem] rounded-card border border-line bg-surface p-3.5 shadow-e4"
|
|
805
|
+
style={{ left: Math.min(draft.cx, window.innerWidth - 320), top: Math.min(Math.max(12, draft.cy + 14), window.innerHeight - 380) }}>
|
|
806
|
+
<div className="mb-2 flex items-center justify-between">
|
|
807
|
+
<span className="text-sm font-bold text-fg-secondary">{draft.kind === 'shape' ? `New ${annShapeLabel(draft.shape_type)} mark` : 'New annotation'}</span>
|
|
808
|
+
<button type="button" onClick={closeDraft} className="text-fg-muted hover:text-fg-secondary"><X size={16} /></button>
|
|
809
|
+
</div>
|
|
810
|
+
{(draft.target_text || draft.target_selector) && (
|
|
811
|
+
<p className="mb-1.5 flex items-center gap-1 truncate rounded-input bg-brand-tint px-2 py-1 text-[11px] font-medium text-brand-strong" title={draft.target_selector}><Target size={12} className="shrink-0" /> {draft.target_text ? `“${draft.target_text.slice(0, 38)}”` : draft.target_selector}</p>
|
|
812
|
+
)}
|
|
813
|
+
{/* captured context chip */}
|
|
814
|
+
<div className="mb-2 flex flex-wrap gap-1 text-[10px] text-fg-muted">
|
|
815
|
+
{draft.styles?.size && <span className={cn(chip, 'bg-sunken')}>{draft.styles.size}px</span>}
|
|
816
|
+
{draft.styles?.color && <span className={cn(chip, 'bg-sunken')}><span className="h-2 w-2 rounded-full" style={{ background: draft.styles.color }} /> text</span>}
|
|
817
|
+
{draft.meta && <span className={cn(chip, 'bg-sunken')}><Monitor size={10} /> {draft.meta.browser} · {draft.meta.viewport}</span>}
|
|
818
|
+
{!!draft.console_logs?.length && <span className={cn(chip, 'bg-danger-tint text-danger-text')}><TerminalSquare size={10} /> {draft.console_logs.length} log</span>}
|
|
819
|
+
{!!draft.network_logs?.length && <span className={cn(chip, 'bg-warning-tint text-warning-text')}><Globe size={10} /> {draft.network_logs.length} net</span>}
|
|
820
|
+
</div>
|
|
821
|
+
<textarea autoFocus rows={3} value={draft.note} onChange={(e) => setDraft({ ...draft, note: e.target.value })}
|
|
822
|
+
onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submitDraft() }}
|
|
823
|
+
placeholder={draft.kind === 'shape' ? 'What about this area? (optional)' : 'What should change here?'}
|
|
824
|
+
className="w-full resize-none rounded-input border border-line px-3 py-2 text-sm outline-none focus:border-brand focus:ring-4 focus:ring-brand/25" />
|
|
825
|
+
<div className="mt-2 flex flex-wrap gap-1">
|
|
826
|
+
{ANN_TYPES.map((t) => (<button key={t.key} type="button" onClick={() => setDraft({ ...draft, type: t.key })} className={cn('flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 transition', draft.type === t.key ? 'bg-fg text-white ring-fg' : 'bg-surface text-fg-muted ring-line hover:bg-row-alt')}><span className="h-1.5 w-1.5 rounded-full" style={{ background: t.dot }} />{t.label}</button>))}
|
|
827
|
+
</div>
|
|
828
|
+
<div className="mt-2 flex items-center justify-between">
|
|
829
|
+
<div className="flex gap-1">
|
|
830
|
+
{ANN_PRIORITIES.map((pr) => (<button key={pr.key} type="button" onClick={() => setDraft({ ...draft, priority: pr.key })} className={cn('rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1 transition', draft.priority === pr.key ? (pr.key === 'high' ? 'bg-danger text-white ring-danger' : pr.key === 'med' ? 'bg-warning text-white ring-warning' : 'bg-fg-disabled text-white ring-fg-disabled') : 'bg-surface text-fg-muted ring-line hover:bg-row-alt')}>{pr.label}</button>))}
|
|
831
|
+
</div>
|
|
832
|
+
<div className="flex gap-1">
|
|
833
|
+
<button type="button" onClick={() => setDraft({ ...draft, visibility: draft.visibility === 'private' ? 'public' : 'private' })} title={draft.visibility === 'private' ? 'Only you see this' : 'Shared'} className={cn('flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1', draft.visibility === 'private' ? 'bg-surface text-fg-muted ring-line' : 'bg-success-tint text-success-text ring-success-hairline')}>{draft.visibility === 'private' ? <Lock size={11} /> : <Globe size={11} />}{draft.visibility === 'private' ? 'Private' : 'Public'}</button>
|
|
834
|
+
{draft.kind === 'pin' && <button type="button" onClick={() => setDraft({ ...draft, shot: !draft.shot })} title="Attach element screenshot" className={cn('flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1', draft.shot ? 'bg-success-tint text-success-text ring-success-hairline' : 'bg-surface text-fg-muted ring-line')}><Camera size={11} />{draft.shot ? 'Shot' : 'No shot'}</button>}
|
|
835
|
+
</div>
|
|
836
|
+
</div>
|
|
837
|
+
<div className="mt-3 flex items-center justify-between">
|
|
838
|
+
<span className="text-[10px] text-fg-disabled">⌘/Ctrl+Enter</span>
|
|
839
|
+
<div className="flex gap-2">
|
|
840
|
+
<button type="button" onClick={closeDraft} className="rounded-input px-3 py-1.5 text-xs font-medium text-fg-muted hover:bg-sunken">Cancel</button>
|
|
841
|
+
<button type="button" onClick={submitDraft} disabled={busy || (draft.kind === 'pin' && !draft.note.trim())} className="inline-flex items-center gap-1 rounded-input bg-brand px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50">{busy ? 'Saving…' : <><Check size={13} /> Save</>}</button>
|
|
842
|
+
</div>
|
|
843
|
+
</div>
|
|
844
|
+
</div>
|
|
845
|
+
)}
|
|
846
|
+
|
|
847
|
+
{/* ---------------- detail popover ---------------- */}
|
|
848
|
+
{active && (
|
|
849
|
+
<div data-annot-ui className="fixed bottom-24 right-5 z-[9999] max-h-[75vh] w-[22rem] overflow-y-auto rounded-card border border-line bg-surface p-4 shadow-e4">
|
|
850
|
+
<div className="mb-2 flex items-center justify-between">
|
|
851
|
+
<span className="flex items-center gap-2 text-sm font-bold text-fg-secondary"><span className="h-2.5 w-2.5 rounded-full" style={{ background: active.color || annDot(active.type) }} /> {active.kind === 'shape' ? `${annShapeLabel(active.shape_type)} · ` : ''}{annTypeLabel(active.type)}</span>
|
|
852
|
+
<div className="flex items-center gap-1">
|
|
853
|
+
<button type="button" onClick={() => jumpTo(active)} title="Find on page" className="rounded p-1 text-fg-muted hover:bg-brand-tint hover:text-brand-strong"><Target size={15} /></button>
|
|
854
|
+
{canEdit(active) && <button type="button" onClick={() => doDelete(active.id)} title="Delete" className="rounded p-1 text-fg-muted hover:bg-danger-tint hover:text-danger-text"><Trash2 size={15} /></button>}
|
|
855
|
+
<button type="button" onClick={() => setActive(null)} className="rounded p-1 text-fg-muted hover:bg-sunken"><X size={16} /></button>
|
|
856
|
+
</div>
|
|
857
|
+
</div>
|
|
858
|
+
<p className="mb-1 truncate text-[11px] text-fg-muted" title={active.target_selector}>{active.page_route} · {active.target_text || active.target_selector || annShapeLabel(active.shape_type)}</p>
|
|
859
|
+
<p className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px] text-fg-muted">
|
|
860
|
+
<span className="font-semibold text-fg-secondary">{active.created_by_name || active.created_by || '—'}</span>
|
|
861
|
+
{active.created_by_role && <span className="rounded bg-sunken px-1.5 text-[10px] font-medium text-fg-muted">{active.created_by_role}</span>}
|
|
862
|
+
{active.visibility === 'private' && <Lock size={11} className="text-fg-muted" />}
|
|
863
|
+
{active.meta && <span className={cn(chip, 'bg-sunken')}><Monitor size={10} /> {active.meta.browser} · {active.meta.os} · {active.meta.viewport}</span>}
|
|
864
|
+
</p>
|
|
865
|
+
{canEdit(active)
|
|
866
|
+
? <textarea rows={2} defaultValue={active.note} key={active.id} onBlur={(e) => { if (e.target.value !== active.note) doUpdate({ id: active.id, note: e.target.value }) }} placeholder="Add a note…" className="w-full resize-none rounded-input border border-line px-3 py-2 text-sm outline-none focus:border-brand" />
|
|
867
|
+
: <p className="rounded-input bg-sunken px-3 py-2 text-sm text-fg-secondary">{active.note || <span className="italic text-fg-muted">(no note)</span>}</p>}
|
|
868
|
+
{canEdit(active) && (
|
|
869
|
+
<div className="mt-2 flex flex-wrap gap-1">
|
|
870
|
+
{ANN_STATUSES.map((st) => (<button key={st.key} type="button" onClick={() => doUpdate({ id: active.id, status: st.key })} className={cn('rounded-full px-2.5 py-1 text-[11px] font-semibold ring-1 transition', active.status === st.key ? (st.key === 'done' ? 'bg-success text-white ring-success' : st.key === 'dismissed' ? 'bg-fg-disabled text-white ring-fg-disabled' : st.key === 'in_progress' ? 'bg-warning text-white ring-warning' : 'bg-brand text-white ring-brand') : 'bg-surface text-fg-muted ring-line hover:bg-row-alt')}>{st.label}</button>))}
|
|
871
|
+
</div>
|
|
872
|
+
)}
|
|
873
|
+
{!!active.console_logs?.length && (
|
|
874
|
+
<details className="mt-2 rounded-input bg-fg p-2 text-[10px] text-white">
|
|
875
|
+
<summary className="cursor-pointer font-semibold text-rose-300">Console ({active.console_logs.length})</summary>
|
|
876
|
+
<div className="mt-1 max-h-28 space-y-0.5 overflow-y-auto font-mono">{active.console_logs.slice(-8).map((l, i) => <div key={i} className="truncate" title={l.text}><span className="text-rose-400">[{l.level}]</span> {l.text}</div>)}</div>
|
|
877
|
+
</details>
|
|
878
|
+
)}
|
|
879
|
+
{!!active.network_logs?.length && (
|
|
880
|
+
<details className="mt-2 rounded-input bg-fg p-2 text-[10px] text-white">
|
|
881
|
+
<summary className="cursor-pointer font-semibold text-amber-300">Network ({active.network_logs.length})</summary>
|
|
882
|
+
<div className="mt-1 max-h-28 space-y-0.5 overflow-y-auto font-mono">{active.network_logs.slice(-8).map((nl, i) => <div key={i} className="truncate" title={nl.url}><span className="text-amber-400">{nl.method}</span> {nl.url} → {nl.status ?? 'ERR'}</div>)}</div>
|
|
883
|
+
</details>
|
|
884
|
+
)}
|
|
885
|
+
{active.dom_snapshot && (
|
|
886
|
+
<details className="mt-2 rounded-input bg-fg p-2 text-[10px] text-white">
|
|
887
|
+
<summary className="cursor-pointer font-semibold text-sky-300">DOM snapshot</summary>
|
|
888
|
+
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap font-mono text-[9px] leading-snug">{active.dom_snapshot}</pre>
|
|
889
|
+
</details>
|
|
890
|
+
)}
|
|
891
|
+
{safeUrl(active.screenshot_url) && <a href={safeUrl(active.screenshot_url)} target="_blank" rel="noreferrer" className="mt-2 block"><img src={safeUrl(active.screenshot_url)} alt="screenshot" className="max-h-44 w-full rounded-input border border-line object-contain" /></a>}
|
|
892
|
+
{(active.replies ?? []).length > 0 && (
|
|
893
|
+
<div className="mt-2 space-y-1 border-t border-line pt-2">{(active.replies ?? []).map((r, i) => <p key={i} className="flex items-start gap-1 text-xs text-fg-muted"><CornerDownRight size={12} className="mt-0.5 shrink-0 text-fg-disabled" /> <span><b className="text-fg-muted">{r.author || ''}</b> {r.note}</span></p>)}</div>
|
|
894
|
+
)}
|
|
895
|
+
<div className="mt-2 flex items-center gap-2">
|
|
896
|
+
<input value={reply} onChange={(e) => setReply(e.target.value)} placeholder="Add a follow-up…" onKeyDown={(e) => { if (e.key === 'Enter' && reply.trim()) doAddReply(active.id, reply.trim()) }} className="flex-1 rounded-input border border-line px-2.5 py-1.5 text-xs outline-none focus:border-brand" />
|
|
897
|
+
<button type="button" onClick={() => reply.trim() && doAddReply(active.id, reply.trim())} className="rounded-input bg-sunken p-1.5 text-fg-secondary hover:bg-row-alt"><MessageSquarePlus size={15} /></button>
|
|
898
|
+
</div>
|
|
899
|
+
</div>
|
|
900
|
+
)}
|
|
901
|
+
|
|
902
|
+
{/* ---------------- review drawer ---------------- */}
|
|
903
|
+
{panel && (
|
|
904
|
+
<>
|
|
905
|
+
<div data-annot-ui className="fixed inset-0 z-[9998] bg-fg/20" onClick={() => setPanel(false)} />
|
|
906
|
+
<div ref={panelTrap} tabIndex={-1} data-annot-ui role="dialog" aria-modal="true" aria-label="Annotations" className="fixed right-0 top-0 z-[9999] flex h-full w-[27rem] max-w-[94vw] flex-col border-l border-line bg-surface shadow-e4 outline-none">
|
|
907
|
+
<div className="flex items-center justify-between border-b border-line px-4 py-3">
|
|
908
|
+
<span className="flex items-center gap-2 font-bold text-fg-secondary"><ListChecks size={18} className="text-brand-strong" /> Annotations <span className="rounded-full bg-sunken px-2 text-xs text-fg-muted">{anyFilter ? `${listed.length}/${all.length}` : all.length}</span></span>
|
|
909
|
+
<button type="button" onClick={() => setPanel(false)} className="text-fg-muted hover:text-fg-secondary"><X size={18} /></button>
|
|
910
|
+
</div>
|
|
911
|
+
<div className="flex items-center gap-2 border-b border-line px-3 py-2">
|
|
912
|
+
<button type="button" onClick={() => setSendOpen(true)} disabled={!openItems.length} className="inline-flex flex-1 items-center justify-center gap-1 rounded-input bg-brand px-2.5 py-2 text-xs font-semibold text-white disabled:opacity-40"><Send size={13} /> Send to developer ({openItems.length})</button>
|
|
913
|
+
<button type="button" onClick={() => downloadMd(openItems)} disabled={!openItems.length} title="Download .md" className="inline-flex items-center gap-1 rounded-input border border-line px-2.5 py-2 text-xs text-fg-muted disabled:opacity-40"><Download size={13} /></button>
|
|
914
|
+
<button type="button" onClick={() => setHelpOpen(true)} title="Shortcuts" className="inline-flex items-center gap-1 rounded-input border border-line px-2.5 py-2 text-xs text-fg-muted"><Keyboard size={13} /></button>
|
|
915
|
+
</div>
|
|
916
|
+
{isAdmin && (
|
|
917
|
+
<button type="button" onClick={() => toggleAccess(!enabled)} disabled={accessBusy} className={cn('flex items-center gap-2 border-b border-line px-3 py-2 text-left text-[11.5px] font-semibold', enabled ? 'text-success-text' : 'text-fg-muted')}>
|
|
918
|
+
<Settings size={13} /> {enabled ? 'Visible to ALL users (testing) — click to make ADMIN-only' : 'ADMIN-only — click to enable for all users'}
|
|
919
|
+
</button>
|
|
920
|
+
)}
|
|
921
|
+
{/* filters */}
|
|
922
|
+
<div className="space-y-1.5 border-b border-line px-3 py-2">
|
|
923
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
924
|
+
<Filter size={12} className="text-fg-muted" />
|
|
925
|
+
{(['all', ...ANN_STATUSES.map((s) => s.key)] as const).map((f) => (<button key={f} type="button" onClick={() => setFilter(f as 'all' | AnnStatus)} className={cn('rounded-full px-2 py-0.5 text-[11px] font-medium', filter === f ? 'bg-brand-tint text-brand-strong ring-1 ring-brand-hairline' : 'text-fg-muted hover:bg-sunken')}>{f === 'all' ? 'All' : annStatusLabel(f)}</button>))}
|
|
926
|
+
</div>
|
|
927
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
928
|
+
{ANN_TYPES.map((t) => (<button key={t.key} type="button" onClick={() => setTypeFilter((v) => v === t.key ? '' : t.key)} className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1', typeFilter === t.key ? 'bg-fg text-white ring-fg' : 'bg-surface text-fg-muted ring-line hover:bg-row-alt')}><span className="h-1.5 w-1.5 rounded-full" style={{ background: t.dot }} />{t.label}</button>))}
|
|
929
|
+
</div>
|
|
930
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
931
|
+
{ANN_PRIORITIES.map((pr) => (<button key={pr.key} type="button" onClick={() => setPrioFilter((v) => v === pr.key ? '' : pr.key)} className={cn('rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1', prioFilter === pr.key ? 'bg-ink-hover text-white ring-fg-secondary' : 'bg-surface text-fg-muted ring-line hover:bg-row-alt')}>{pr.label}</button>))}
|
|
932
|
+
<label className="ml-1 inline-flex cursor-pointer items-center gap-1 text-[11px] font-medium text-fg-muted"><input type="checkbox" checked={thisPageOnly} onChange={(e) => setThisPageOnly(e.target.checked)} className="h-3 w-3" /> This page</label>
|
|
933
|
+
{seeAll && <button type="button" onClick={() => doClearResolved()} title="Delete done/dismissed" className="ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] text-danger-text hover:bg-danger-tint"><Eraser size={11} /> Clear resolved</button>}
|
|
934
|
+
</div>
|
|
935
|
+
<div className="flex items-center gap-2">
|
|
936
|
+
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search…" className="flex-1 rounded-input border border-line px-2.5 py-1.5 text-xs outline-none focus:border-brand" />
|
|
937
|
+
{seeAll && authors.length > 1 && (<select value={authorFilter} onChange={(e) => setAuthorFilter(e.target.value)} className="max-w-[9rem] rounded-input border border-line px-2 py-1.5 text-xs text-fg-muted"><option value="">Everyone</option>{authors.map(([id, name]) => <option key={id} value={id}>{name}</option>)}</select>)}
|
|
938
|
+
</div>
|
|
939
|
+
</div>
|
|
940
|
+
{/* list grouped by page */}
|
|
941
|
+
<div className="flex-1 overflow-y-auto p-2">
|
|
942
|
+
{listed.length === 0 ? (
|
|
943
|
+
<p className="p-6 text-center text-sm text-fg-muted">{all.length ? 'Nothing matches these filters.' : 'No annotations yet. Open the dock, pick Pin or a shape, and mark anything.'}</p>
|
|
944
|
+
) : grouped.map(([page, items]) => (
|
|
945
|
+
<div key={page} className="mb-2">
|
|
946
|
+
<p className="mb-1 flex items-center gap-1 px-1 text-[10px] font-mono font-bold text-fg-muted"><span className={cn('h-1.5 w-1.5 rounded-full', page === pathname ? 'bg-success' : 'bg-fg-disabled')} /><span className="truncate">{page}</span><span className="text-fg-disabled">· {items.length}</span></p>
|
|
947
|
+
{items.map((a) => {
|
|
948
|
+
const editing = editRow === a.id
|
|
949
|
+
return (
|
|
950
|
+
<div key={a.id} onMouseEnter={() => a.page_route === pathname && a.kind !== 'shape' && setHoverEl(findEl(a))} onMouseLeave={() => setHoverEl(null)} className="mb-1.5 rounded-card border border-line hover:bg-row-alt">
|
|
951
|
+
<div className="flex items-start gap-2 p-2.5">
|
|
952
|
+
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full text-[10px] font-bold text-white" style={{ background: a.color || annDot(a.type) }}>{a.kind === 'shape' ? annShapeLabel(a.shape_type)[0] : annTypeLabel(a.type)[0]}</span>
|
|
953
|
+
<button type="button" onClick={() => jumpTo(a)} className="min-w-0 flex-1 text-left">
|
|
954
|
+
<span className="block truncate text-sm font-medium text-fg-secondary">{a.note || <span className="italic text-fg-muted">{annShapeLabel(a.shape_type)} mark</span>}</span>
|
|
955
|
+
<span className="mt-0.5 flex flex-wrap items-center gap-1 text-[10.5px] text-fg-muted">
|
|
956
|
+
<span className="font-semibold text-fg-muted">{a.created_by_name || a.created_by}</span>
|
|
957
|
+
{a.created_by_role && <span className="rounded bg-sunken px-1 text-fg-muted">{a.created_by_role}</span>}
|
|
958
|
+
<span className="uppercase">· {a.priority}</span>
|
|
959
|
+
<span className={cn('rounded px-1', a.status === 'done' ? 'bg-success-tint text-success-text' : a.status === 'dismissed' ? 'bg-sunken text-fg-muted' : a.status === 'in_progress' ? 'bg-warning-tint text-warning-text' : 'bg-brand-tint text-brand-strong')}>{annStatusLabel(a.status)}</span>
|
|
960
|
+
{a.visibility === 'private' && <Lock size={10} />}{a.screenshot_url && <Camera size={10} />}{!!a.console_logs?.length && <TerminalSquare size={10} className="text-danger-text" />}{(a.replies ?? []).length > 0 && <span>💬{(a.replies ?? []).length}</span>}
|
|
961
|
+
</span>
|
|
962
|
+
</button>
|
|
963
|
+
{/* per-row actions — no need to open the mark */}
|
|
964
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
965
|
+
<button type="button" onClick={() => jumpTo(a)} title="Find on page" className="rounded p-1 text-fg-disabled hover:bg-brand-tint hover:text-brand-strong"><Target size={14} /></button>
|
|
966
|
+
{canEdit(a) && <button type="button" onClick={() => setEditRow(editing ? null : a.id)} title="Edit" className={cn('rounded p-1 hover:bg-sunken', editing ? 'bg-brand-tint text-brand-strong' : 'text-fg-disabled hover:text-fg-secondary')}><Pencil size={14} /></button>}
|
|
967
|
+
{canEdit(a) && a.status !== 'done' && <button type="button" onClick={() => doUpdate({ id: a.id, status: 'done' })} title="Mark done" className="rounded p-1 text-fg-disabled hover:bg-success-tint hover:text-success-text"><Check size={14} /></button>}
|
|
968
|
+
{canEdit(a) && <button type="button" onClick={() => doDelete(a.id)} title="Delete" className="rounded p-1 text-fg-disabled hover:bg-danger-tint hover:text-danger-text"><Trash size={14} /></button>}
|
|
969
|
+
</div>
|
|
970
|
+
</div>
|
|
971
|
+
{editing && canEdit(a) && (
|
|
972
|
+
<div className="space-y-2 border-t border-line p-2.5">
|
|
973
|
+
<textarea rows={2} defaultValue={a.note} onBlur={(e) => { if (e.target.value !== a.note) doUpdate({ id: a.id, note: e.target.value }) }} placeholder="Edit note…" className="w-full resize-none rounded-input border border-line px-2.5 py-1.5 text-sm outline-none focus:border-brand" />
|
|
974
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
975
|
+
{ANN_STATUSES.map((st) => (<button key={st.key} type="button" onClick={() => doUpdate({ id: a.id, status: st.key })} className={cn('rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1', a.status === st.key ? (st.key === 'done' ? 'bg-success text-white ring-success' : st.key === 'dismissed' ? 'bg-fg-disabled text-white ring-fg-disabled' : st.key === 'in_progress' ? 'bg-warning text-white ring-warning' : 'bg-brand text-white ring-brand') : 'bg-surface text-fg-muted ring-line')}>{st.label}</button>))}
|
|
976
|
+
</div>
|
|
977
|
+
<div className="flex items-center gap-1.5">
|
|
978
|
+
<select value={a.type} onChange={(e) => doUpdate({ id: a.id, type: e.target.value })} className="flex-1 rounded-input border border-line px-2 py-1 text-[11px]">{ANN_TYPES.map((t) => <option key={t.key} value={t.key}>{t.label}</option>)}</select>
|
|
979
|
+
<select value={a.priority} onChange={(e) => doUpdate({ id: a.id, priority: e.target.value })} className="flex-1 rounded-input border border-line px-2 py-1 text-[11px]">{ANN_PRIORITIES.map((pr) => <option key={pr.key} value={pr.key}>{pr.label}</option>)}</select>
|
|
980
|
+
<button type="button" onClick={() => setEditRow(null)} className="rounded-input bg-fg px-2.5 py-1 text-[11px] font-semibold text-white">Done</button>
|
|
981
|
+
</div>
|
|
982
|
+
</div>
|
|
983
|
+
)}
|
|
984
|
+
</div>
|
|
985
|
+
)
|
|
986
|
+
})}
|
|
987
|
+
</div>
|
|
988
|
+
))}
|
|
989
|
+
</div>
|
|
990
|
+
</div>
|
|
991
|
+
</>
|
|
992
|
+
)}
|
|
993
|
+
|
|
994
|
+
{/* ---------------- send preview modal ---------------- */}
|
|
995
|
+
{sendOpen && (
|
|
996
|
+
<div data-annot-ui className="fixed inset-0 z-[10000] grid place-items-center bg-fg/50 p-4" onClick={() => setSendOpen(false)}>
|
|
997
|
+
<div ref={sendTrap} tabIndex={-1} role="dialog" aria-modal="true" aria-label="Send to developer" className="flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-modal bg-surface shadow-e4 outline-none" onClick={(e) => e.stopPropagation()}>
|
|
998
|
+
<div className="flex items-center gap-2 border-b border-line px-4 py-3">
|
|
999
|
+
<Send size={16} className="text-fg-secondary" /><span className="flex-1 text-sm font-bold text-fg-secondary">Send to developer — {openItems.length} open item(s)</span>
|
|
1000
|
+
<button type="button" onClick={() => setSendOpen(false)} className="text-fg-muted hover:text-fg-secondary"><X size={18} /></button>
|
|
1001
|
+
</div>
|
|
1002
|
+
<p className="px-4 pt-3 text-[12px] text-fg-muted">This exact markdown reaches your developer — each mark's element, note, environment, and console are included. Copy, then paste in the chat.</p>
|
|
1003
|
+
<pre className="m-4 flex-1 overflow-auto whitespace-pre-wrap rounded-input bg-fg p-3 font-mono text-[11.5px] leading-relaxed text-white">{buildDevPrompt(openItems) || '(no open marks)'}</pre>
|
|
1004
|
+
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line p-3">
|
|
1005
|
+
{deliveries.map((d, i) => d.href
|
|
1006
|
+
? <a key={i} href={safeUrl(d.href(openItems))} target="_blank" rel="noreferrer" onClick={() => setSendOpen(false)} className="inline-flex items-center gap-1.5 rounded-input border border-line-strong px-3 py-2 text-[12.5px] font-bold text-fg-secondary hover:bg-sunken"><Send size={14} /> {d.label}</a>
|
|
1007
|
+
: <button key={i} type="button" disabled={delivering} onClick={() => doDeliver(d)} className="inline-flex items-center gap-1.5 rounded-input border border-line-strong px-3 py-2 text-[12.5px] font-bold text-fg-secondary hover:bg-sunken disabled:opacity-40"><Send size={14} /> {d.label}</button>)}
|
|
1008
|
+
<button type="button" onClick={() => downloadMd(openItems)} className="inline-flex items-center gap-1.5 rounded-input border border-line-strong px-4 py-2 text-[12.5px] font-bold text-fg-secondary hover:bg-sunken"><Download size={15} /> Download .md</button>
|
|
1009
|
+
<button type="button" onClick={() => { copyPrompt(openItems); setSendOpen(false) }} className="inline-flex items-center gap-1.5 rounded-input bg-fg px-4 py-2 text-[12.5px] font-bold text-white hover:bg-ink-hover"><Copy size={15} /> Copy — paste to developer</button>
|
|
1010
|
+
</div>
|
|
1011
|
+
</div>
|
|
1012
|
+
</div>
|
|
1013
|
+
)}
|
|
1014
|
+
|
|
1015
|
+
{/* ---------------- keyboard cheat-sheet ---------------- */}
|
|
1016
|
+
{helpOpen && (
|
|
1017
|
+
<div data-annot-ui className="fixed inset-0 z-[10000] grid place-items-center bg-fg/50 p-4" onClick={() => setHelpOpen(false)}>
|
|
1018
|
+
<div ref={helpTrap} tabIndex={-1} role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="w-full max-w-sm rounded-modal bg-surface p-4 shadow-e4 outline-none" onClick={(e) => e.stopPropagation()}>
|
|
1019
|
+
<div className="mb-2 flex items-center gap-2"><Keyboard size={16} className="text-brand-strong" /><span className="flex-1 text-sm font-bold text-fg-secondary">Shortcuts</span><button type="button" onClick={() => setHelpOpen(false)} className="text-fg-muted hover:text-fg-secondary"><X size={16} /></button></div>
|
|
1020
|
+
<div className="grid grid-cols-2 gap-y-1.5 text-[12px] text-fg-muted">
|
|
1021
|
+
{[['Shift+A', 'Pin tool'], ['P / B / A / C / D', 'Pin · Box · Arrow · Circle · Draw'], ['Esc', 'Cancel / exit'], ['⌘/Ctrl+Enter', 'Save note'], ['Ctrl+Z', 'Undo last mark'], ['Shift+H', 'Hide / show marks'], ['?', 'This help']].map(([k, v]) => (
|
|
1022
|
+
<div key={k} className="contents"><kbd className="w-fit rounded bg-sunken px-1.5 py-0.5 font-mono text-[11px] font-semibold text-fg-secondary">{k}</kbd><span>{v}</span></div>
|
|
1023
|
+
))}
|
|
1024
|
+
</div>
|
|
1025
|
+
</div>
|
|
1026
|
+
</div>
|
|
1027
|
+
)}
|
|
1028
|
+
|
|
1029
|
+
{markupBlob && <ScreenshotEditor blob={markupBlob} onToast={toast} onClose={() => setMarkupBlob(null)} />}
|
|
1030
|
+
|
|
1031
|
+
{/* ---------------- built-in mini-toast (skipped when onToast is provided) ---------------- */}
|
|
1032
|
+
{toasts.length > 0 && (
|
|
1033
|
+
<div data-annot-ui className="fixed bottom-6 left-1/2 z-[10001] flex -translate-x-1/2 flex-col items-center gap-2">
|
|
1034
|
+
{toasts.map((t) => (
|
|
1035
|
+
<div key={t.id} className={cn('rounded-input px-3 py-2 text-xs font-semibold shadow-e4 ring-1', t.kind === 'success' ? 'bg-success-tint text-success-text ring-success-hairline' : 'bg-danger-tint text-danger-text ring-danger-hairline')}>{t.msg}</div>
|
|
1036
|
+
))}
|
|
1037
|
+
</div>
|
|
1038
|
+
)}
|
|
1039
|
+
</div>
|
|
1040
|
+
</OverlayBoundary>,
|
|
1041
|
+
document.body,
|
|
1042
|
+
)
|
|
1043
|
+
}
|