@henryavila/mdprobe 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/src/ui/app.jsx ADDED
@@ -0,0 +1,152 @@
1
+ import { render } from 'preact'
2
+ import { useState, useEffect, useCallback } from 'preact/hooks'
3
+ import { useWebSocket } from './hooks/useWebSocket.js'
4
+ import { useKeyboard } from './hooks/useKeyboard.js'
5
+ import { useTheme } from './hooks/useTheme.js'
6
+ import { useAnnotations } from './hooks/useAnnotations.js'
7
+ import { useClientLibs } from './hooks/useClientLibs.js'
8
+ import { files, currentFile, currentHtml, currentToc, author, reviewMode,
9
+ leftPanelOpen, rightPanelOpen, openAnnotations, sectionStats, driftWarning } from './state/store.js'
10
+ import { LeftPanel } from './components/LeftPanel.jsx'
11
+ import { RightPanel } from './components/RightPanel.jsx'
12
+ import { Content } from './components/Content.jsx'
13
+ import { ThemePicker } from './components/ThemePicker.jsx'
14
+ import { ExportMenu } from './components/ExportMenu.jsx'
15
+ import './styles/themes.css'
16
+
17
+ function App() {
18
+ const [showHelp, setShowHelp] = useState(false)
19
+ const ws = useWebSocket()
20
+ const { setTheme, themes } = useTheme()
21
+ const annotationOps = useAnnotations()
22
+ useClientLibs()
23
+
24
+ useKeyboard({ onShowHelp: () => setShowHelp(v => !v) })
25
+
26
+ // Close help modal on Escape
27
+ useEffect(() => {
28
+ if (!showHelp) return
29
+ function handleEsc(e) {
30
+ if (e.key === 'Escape') {
31
+ e.preventDefault()
32
+ setShowHelp(false)
33
+ }
34
+ }
35
+ document.addEventListener('keydown', handleEsc)
36
+ return () => document.removeEventListener('keydown', handleEsc)
37
+ }, [showHelp])
38
+
39
+ // Fetch initial data
40
+ useEffect(() => {
41
+ fetch('/api/files').then(r => r.json()).then(data => {
42
+ files.value = data
43
+ if (data.length > 0) {
44
+ const first = data[0].path || data[0]
45
+ currentFile.value = first
46
+ // Fetch rendered content
47
+ fetch(`/api/file?path=${encodeURIComponent(first)}`).then(r => r.json()).then(d => {
48
+ currentHtml.value = d.html
49
+ currentToc.value = d.toc || []
50
+ })
51
+ // Fetch annotations
52
+ annotationOps.fetchAnnotations(first)
53
+ }
54
+ })
55
+
56
+ fetch('/api/config').then(r => r.json()).then(data => {
57
+ author.value = data.author || 'anonymous'
58
+ })
59
+
60
+ // Check review mode
61
+ fetch('/api/review/status').then(r => r.json()).then(data => {
62
+ if (data.mode === 'once') reviewMode.value = true
63
+ }).catch(() => {})
64
+ }, [])
65
+
66
+ function handleFileSelect(filePath) {
67
+ currentFile.value = filePath
68
+ fetch(`/api/file?path=${encodeURIComponent(filePath)}`).then(r => r.json()).then(d => {
69
+ currentHtml.value = d.html
70
+ currentToc.value = d.toc || []
71
+ })
72
+ annotationOps.fetchAnnotations(filePath)
73
+ }
74
+
75
+ return (
76
+ <>
77
+ {/* Header */}
78
+ <header class="header">
79
+ <h1>mdprobe</h1>
80
+ <span class="header-file">{currentFile.value || 'No file selected'}</span>
81
+ <div style="flex: 1" />
82
+ {sectionStats.value.total > 0 && (reviewMode.value || sectionStats.value.reviewed > 0) && (
83
+ <div class="progress-info">
84
+ <span>{sectionStats.value.reviewed}/{sectionStats.value.total} sections reviewed</span>
85
+ <div class="progress-bar" style="width: 100px">
86
+ <div class="fill" style={`width: ${(sectionStats.value.reviewed / sectionStats.value.total) * 100}%`} />
87
+ </div>
88
+ </div>
89
+ )}
90
+ <ExportMenu />
91
+ <ThemePicker themes={themes} onSelect={setTheme} />
92
+ {reviewMode.value && <button class="btn btn-primary btn-sm" onClick={async () => {
93
+ try {
94
+ await fetch('/api/review/finish', { method: 'POST' })
95
+ } catch { /* server will close */ }
96
+ }}>Finish Review</button>}
97
+ </header>
98
+
99
+ {/* Drift warning banner */}
100
+ {driftWarning.value && (
101
+ <div class="drift-banner">
102
+ Arquivo modificado desde a ultima revisao. Algumas anotacoes podem estar desalinhadas.
103
+ <button class="btn btn-sm" style="margin-left: 8px" onClick={() => driftWarning.value = false}>Dismiss</button>
104
+ </div>
105
+ )}
106
+
107
+ {/* Left Panel */}
108
+ <LeftPanel onFileSelect={handleFileSelect} />
109
+
110
+ {/* Content */}
111
+ <Content annotationOps={annotationOps} />
112
+
113
+ {/* Right Panel */}
114
+ <RightPanel annotationOps={annotationOps} />
115
+
116
+ {/* Status Bar */}
117
+ <footer class="status-bar">
118
+ <span>{openAnnotations.value.length} open</span>
119
+ <span>Author: {author.value}</span>
120
+ <span>Press ? for shortcuts</span>
121
+ </footer>
122
+
123
+ {/* Keyboard Shortcut Modal */}
124
+ {showHelp && (
125
+ <>
126
+ <div class="shortcut-modal overlay" onClick={() => setShowHelp(false)} />
127
+ <div class="shortcut-modal">
128
+ <h3 style="margin-bottom: 12px">Keyboard Shortcuts</h3>
129
+ {[
130
+ ['[', 'Toggle left panel (Files/TOC)'],
131
+ [']', 'Toggle right panel (Annotations)'],
132
+ ['\\', 'Toggle both panels (focus mode)'],
133
+ ['j', 'Next annotation'],
134
+ ['k', 'Previous annotation'],
135
+ ['r', 'Resolve selected annotation'],
136
+ ['e', 'Edit selected annotation'],
137
+ ['?', 'Show/hide this help'],
138
+ ].map(([key, desc]) => (
139
+ <div class="shortcut-row" key={key}>
140
+ <span>{desc}</span>
141
+ <span class="shortcut-key">{key}</span>
142
+ </div>
143
+ ))}
144
+ <button class="btn btn-sm" style="margin-top: 12px" onClick={() => setShowHelp(false)}>Close</button>
145
+ </div>
146
+ </>
147
+ )}
148
+ </>
149
+ )
150
+ }
151
+
152
+ render(<App />, document.getElementById('app'))
@@ -0,0 +1,72 @@
1
+ import { useState } from 'preact/hooks'
2
+
3
+ const TAGS = [
4
+ { value: 'question', label: 'Question' },
5
+ { value: 'bug', label: 'Bug' },
6
+ { value: 'suggestion', label: 'Suggestion' },
7
+ { value: 'nitpick', label: 'Nitpick' },
8
+ ]
9
+
10
+ export function AnnotationForm({ annotation, selectors, exact, onSave, onCancel }) {
11
+ const isEdit = !!annotation
12
+ const [comment, setComment] = useState(annotation?.comment || '')
13
+ const [tag, setTag] = useState(annotation?.tag || 'question')
14
+
15
+ function handleSubmit(e) {
16
+ e.preventDefault()
17
+ if (!comment.trim()) return
18
+
19
+ if (isEdit) {
20
+ onSave({ comment: comment.trim(), tag })
21
+ } else {
22
+ onSave({ selectors, comment: comment.trim(), tag })
23
+ }
24
+ }
25
+
26
+ function handleKeyDown(e) {
27
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
28
+ handleSubmit(e)
29
+ }
30
+ if (e.key === 'Escape') {
31
+ onCancel()
32
+ }
33
+ }
34
+
35
+ return (
36
+ <form class="annotation-form" onSubmit={handleSubmit} onKeyDown={handleKeyDown} onClick={e => e.stopPropagation()}>
37
+ {exact && (
38
+ <div class="annotation-form__quote">
39
+ {exact}
40
+ </div>
41
+ )}
42
+
43
+ <div class="annotation-form__tags">
44
+ {TAGS.map(t => (
45
+ <button
46
+ key={t.value}
47
+ type="button"
48
+ class={`tag-pill tag-pill--${t.value}${tag === t.value ? ' tag-pill--active' : ''}`}
49
+ onClick={() => setTag(t.value)}
50
+ >
51
+ {t.label}
52
+ </button>
53
+ ))}
54
+ </div>
55
+
56
+ <textarea
57
+ value={comment}
58
+ onInput={e => setComment(e.target.value)}
59
+ placeholder="Add your comment... (Ctrl+Enter to save)"
60
+ autoFocus
61
+ />
62
+
63
+ <div class="annotation-form__actions">
64
+ <span class="annotation-form__hint">Ctrl+Enter to save · Esc to close</span>
65
+ <button type="button" class="btn btn--ghost" onClick={onCancel}>Cancel</button>
66
+ <button type="submit" class="btn btn--primary" disabled={!comment.trim()}>
67
+ {isEdit ? 'Save' : 'Annotate'}
68
+ </button>
69
+ </div>
70
+ </form>
71
+ )
72
+ }
@@ -0,0 +1,334 @@
1
+ import { useRef, useEffect, useState } from 'preact/hooks'
2
+ import { currentHtml, selectedAnnotationId, annotations, showResolved } from '../state/store.js'
3
+ import { Popover } from './Popover.jsx'
4
+ import { SectionApproval } from './SectionApproval.jsx'
5
+ import { sections } from '../state/store.js'
6
+
7
+ export function Content({ annotationOps }) {
8
+ const contentRef = useRef(null)
9
+ const [popover, setPopover] = useState(null) // { x, y, selectors }
10
+
11
+ // Inject annotation highlights into DOM after HTML renders
12
+ useEffect(() => {
13
+ const el = contentRef.current
14
+ if (!el) return
15
+
16
+ // Remove previous highlights
17
+ el.querySelectorAll('mark[data-highlight-id]').forEach(mark => {
18
+ const parent = mark.parentNode
19
+ while (mark.firstChild) parent.insertBefore(mark.firstChild, mark)
20
+ parent.removeChild(mark)
21
+ })
22
+
23
+ // Get visible annotations
24
+ const visibleAnns = showResolved.value
25
+ ? annotations.value
26
+ : annotations.value.filter(a => a.status === 'open')
27
+
28
+ for (const ann of visibleAnns) {
29
+ const startLine = ann.selectors?.position?.startLine
30
+ if (!startLine) continue
31
+
32
+ const sourceEl = el.querySelector(`[data-source-line="${startLine}"]`)
33
+ if (!sourceEl) continue
34
+
35
+ const exact = ann.selectors?.quote?.exact
36
+ if (!exact) continue
37
+
38
+ const markClass = `annotation-highlight tag-${ann.tag}${ann.status === 'resolved' ? ' resolved' : ''}${selectedAnnotationId.value === ann.id ? ' selected' : ''}`
39
+
40
+ // Strategy 1: Try single-element exact match (fast path)
41
+ if (trySingleElementHighlight(sourceEl, exact, ann.id, markClass)) continue
42
+
43
+ // Strategy 2: Cross-element match — walk text nodes from sourceEl onwards
44
+ const endLine = ann.selectors?.position?.endLine || startLine
45
+ if (tryCrossElementHighlight(el, sourceEl, endLine, exact, ann.id, markClass)) continue
46
+
47
+ // Strategy 3: Fallback — highlight all text in line range
48
+ if (startLine !== endLine) {
49
+ highlightLineRange(el, startLine, endLine, ann.id, markClass)
50
+ }
51
+ }
52
+ }, [currentHtml.value, annotations.value, showResolved.value, selectedAnnotationId.value])
53
+
54
+ // Inject SectionApproval buttons next to h2 headings
55
+ useEffect(() => {
56
+ const el = contentRef.current
57
+ if (!el || sections.value.length === 0) return
58
+
59
+ // Remove previous section-approval containers
60
+ el.querySelectorAll('.section-approval-injected').forEach(n => n.remove())
61
+
62
+ // Inject approval buttons on ALL heading levels that have a matching section
63
+ el.querySelectorAll(':is(h1,h2,h3,h4,h5,h6)[data-source-line]').forEach(hEl => {
64
+ const heading = hEl.textContent.trim()
65
+ const section = sections.value.find(s => s.heading === heading)
66
+ if (!section) return
67
+
68
+ const computed = section.computed || section.status
69
+
70
+ const container = document.createElement('span')
71
+ container.className = 'section-approval-injected'
72
+
73
+ const approveBtn = document.createElement('button')
74
+ const approveClass = computed === 'approved' ? ' section-status approved'
75
+ : computed === 'indeterminate' ? ' section-status indeterminate' : ''
76
+ approveBtn.className = `btn btn-sm${approveClass}`
77
+ approveBtn.textContent = computed === 'indeterminate' ? '\u2500' : '\u2713'
78
+ approveBtn.title = computed === 'indeterminate' ? 'Partially approved — click to approve all' : 'Approve section'
79
+ approveBtn.onclick = (e) => {
80
+ e.stopPropagation()
81
+ section.status === 'approved'
82
+ ? annotationOps.resetSection(heading)
83
+ : annotationOps.approveSection(heading)
84
+ }
85
+
86
+ const rejectBtn = document.createElement('button')
87
+ rejectBtn.className = `btn btn-sm${computed === 'rejected' ? ' section-status rejected' : ''}`
88
+ rejectBtn.textContent = '\u2717'
89
+ rejectBtn.title = 'Reject section'
90
+ rejectBtn.onclick = (e) => {
91
+ e.stopPropagation()
92
+ section.status === 'rejected'
93
+ ? annotationOps.resetSection(heading)
94
+ : annotationOps.rejectSection(heading)
95
+ }
96
+
97
+ const statusLabel = computed !== 'pending' ? computed : ''
98
+ if (statusLabel) {
99
+ const statusSpan = document.createElement('span')
100
+ statusSpan.className = `section-status-label ${computed}`
101
+ statusSpan.style.cssText = 'font-size: 10px; margin-left: 4px'
102
+ statusSpan.textContent = statusLabel
103
+ container.appendChild(statusSpan)
104
+ }
105
+
106
+ container.style.cssText = 'display: inline-flex; gap: 4px; margin-left: 8px; vertical-align: middle'
107
+ container.insertBefore(rejectBtn, container.firstChild)
108
+ container.insertBefore(approveBtn, container.firstChild)
109
+ hEl.appendChild(container)
110
+ })
111
+ }, [currentHtml.value, sections.value])
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Highlight helper functions
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /** Try to highlight exact text within a single element. Returns true on success. */
118
+ function trySingleElementHighlight(sourceEl, exact, id, className) {
119
+ const walker = document.createTreeWalker(sourceEl, NodeFilter.SHOW_TEXT)
120
+ let node
121
+ while ((node = walker.nextNode())) {
122
+ const idx = node.textContent.indexOf(exact)
123
+ if (idx === -1) continue
124
+ const range = document.createRange()
125
+ range.setStart(node, idx)
126
+ range.setEnd(node, idx + exact.length)
127
+ const mark = document.createElement('mark')
128
+ mark.setAttribute('data-highlight-id', id)
129
+ mark.className = className
130
+ try {
131
+ range.surroundContents(mark)
132
+ } catch {
133
+ return false
134
+ }
135
+ return true
136
+ }
137
+ return false
138
+ }
139
+
140
+ /**
141
+ * Highlight text that spans multiple elements.
142
+ * Walk text nodes from sourceEl to endLine, concatenate, find match,
143
+ * then wrap each matching portion in its own <mark>.
144
+ */
145
+ function tryCrossElementHighlight(contentEl, sourceEl, endLine, exact, id, className) {
146
+ // Collect text nodes from sourceEl through endLine
147
+ const textNodes = []
148
+ const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT)
149
+ let node
150
+ let collecting = false
151
+ while ((node = walker.nextNode())) {
152
+ if (!collecting && sourceEl.contains(node)) collecting = true
153
+ if (!collecting) continue
154
+ textNodes.push(node)
155
+ // Stop after passing endLine element
156
+ const parent = findSourceLineParent(node, contentEl)
157
+ if (parent) {
158
+ const line = parseInt(parent.getAttribute('data-source-line'))
159
+ if (line > endLine) break
160
+ }
161
+ }
162
+ if (textNodes.length === 0) return false
163
+
164
+ // Build concatenated text with separator tracking
165
+ // Browser selections across block elements include \n between them
166
+ let concat = ''
167
+ const nodeMap = [] // { node, startInConcat, endInConcat }
168
+ for (let i = 0; i < textNodes.length; i++) {
169
+ // Add a newline between text nodes from different block parents
170
+ if (i > 0) {
171
+ const prevParent = textNodes[i - 1].parentElement?.closest('[data-source-line]')
172
+ const currParent = textNodes[i].parentElement?.closest('[data-source-line]')
173
+ if (prevParent !== currParent) concat += '\n'
174
+ }
175
+ const start = concat.length
176
+ concat += textNodes[i].textContent
177
+ nodeMap.push({ node: textNodes[i], startInConcat: start, endInConcat: concat.length })
178
+ }
179
+
180
+ // Try exact match in the concatenated text
181
+ let matchIdx = concat.indexOf(exact)
182
+
183
+ // If not found, try with whitespace normalization
184
+ if (matchIdx === -1) {
185
+ const normalizedConcat = concat.replace(/\s+/g, ' ')
186
+ const normalizedExact = exact.replace(/\s+/g, ' ')
187
+ const normIdx = normalizedConcat.indexOf(normalizedExact)
188
+ if (normIdx === -1) return false
189
+
190
+ // Map normalized index back: highlight all nodes in range
191
+ // Since normalization collapses whitespace, exact mapping is unreliable.
192
+ // Fall back to highlighting all matching text nodes.
193
+ for (const nm of nodeMap) {
194
+ wrapTextNode(nm.node, 0, nm.node.textContent.length, id, className)
195
+ }
196
+ return true
197
+ }
198
+
199
+ const matchEnd = matchIdx + exact.length
200
+
201
+ // Wrap matching portions in each text node
202
+ for (const nm of nodeMap) {
203
+ const overlapStart = Math.max(matchIdx, nm.startInConcat)
204
+ const overlapEnd = Math.min(matchEnd, nm.endInConcat)
205
+ if (overlapStart >= overlapEnd) continue
206
+
207
+ const nodeStart = overlapStart - nm.startInConcat
208
+ const nodeEnd = overlapEnd - nm.startInConcat
209
+ wrapTextNode(nm.node, nodeStart, nodeEnd, id, className)
210
+ }
211
+ return true
212
+ }
213
+
214
+ /** Highlight all text nodes within elements between startLine and endLine. */
215
+ function highlightLineRange(contentEl, startLine, endLine, id, className) {
216
+ const els = contentEl.querySelectorAll('[data-source-line]')
217
+ for (const e of els) {
218
+ const line = parseInt(e.getAttribute('data-source-line'))
219
+ if (line < startLine || line > endLine) continue
220
+ const walker = document.createTreeWalker(e, NodeFilter.SHOW_TEXT)
221
+ let node
222
+ while ((node = walker.nextNode())) {
223
+ if (node.textContent.trim() === '') continue
224
+ wrapTextNode(node, 0, node.textContent.length, id, className)
225
+ }
226
+ }
227
+ }
228
+
229
+ /** Wrap a portion of a text node in a <mark> element. */
230
+ function wrapTextNode(textNode, start, end, id, className) {
231
+ if (start >= end || start >= textNode.textContent.length) return
232
+ try {
233
+ const range = document.createRange()
234
+ range.setStart(textNode, start)
235
+ range.setEnd(textNode, Math.min(end, textNode.textContent.length))
236
+ const mark = document.createElement('mark')
237
+ mark.setAttribute('data-highlight-id', id)
238
+ mark.className = className
239
+ range.surroundContents(mark)
240
+ } catch {
241
+ // surroundContents can fail if range crosses element boundaries
242
+ }
243
+ }
244
+
245
+ /** Find the closest ancestor with data-source-line. */
246
+ function findSourceLineParent(node, root) {
247
+ let current = node.nodeType === 3 ? node.parentElement : node
248
+ while (current && current !== root) {
249
+ if (current.hasAttribute?.('data-source-line')) return current
250
+ current = current.parentElement
251
+ }
252
+ return null
253
+ }
254
+
255
+ // Handle text selection for creating annotations
256
+ function handleMouseUp(e) {
257
+ const selection = window.getSelection()
258
+ if (!selection || selection.isCollapsed || selection.toString().trim() === '') {
259
+ return
260
+ }
261
+
262
+ const text = selection.toString()
263
+ const range = selection.getRangeAt(0)
264
+ const rect = range.getBoundingClientRect()
265
+
266
+ // Find source line/column from data attributes
267
+ const startNode = findSourceNode(range.startContainer)
268
+ const endNode = findSourceNode(range.endContainer)
269
+
270
+ if (startNode) {
271
+ const startLine = parseInt(startNode.getAttribute('data-source-line'))
272
+ const startCol = parseInt(startNode.getAttribute('data-source-col') || '1')
273
+ const endLine = endNode ? parseInt(endNode.getAttribute('data-source-line')) : startLine
274
+ const endCol = endNode ? parseInt(endNode.getAttribute('data-source-col') || '1') : startCol + text.length
275
+
276
+ setPopover({
277
+ x: rect.left + rect.width / 2,
278
+ y: rect.bottom + 8,
279
+ exact: text,
280
+ selectors: {
281
+ position: { startLine, startColumn: startCol, endLine, endColumn: endCol },
282
+ quote: { exact: text, prefix: '', suffix: '' }
283
+ }
284
+ })
285
+ }
286
+ }
287
+
288
+ function findSourceNode(node) {
289
+ let current = node.nodeType === 3 ? node.parentElement : node
290
+ while (current && current !== contentRef.current) {
291
+ if (current.hasAttribute?.('data-source-line')) return current
292
+ current = current.parentElement
293
+ }
294
+ return null
295
+ }
296
+
297
+ // Click on annotation highlight -> select it
298
+ function handleContentClick(e) {
299
+ const highlight = e.target.closest('[data-highlight-id]')
300
+ if (highlight) {
301
+ selectedAnnotationId.value = highlight.getAttribute('data-highlight-id')
302
+ }
303
+ }
304
+
305
+ return (
306
+ <main class="content-area-wrapper">
307
+ <div
308
+ class="content-area"
309
+ ref={contentRef}
310
+ onClick={handleContentClick}
311
+ onMouseUp={handleMouseUp}
312
+ dangerouslySetInnerHTML={{ __html: currentHtml.value || '' }}
313
+ />
314
+
315
+ {popover && (
316
+ <Popover
317
+ x={popover.x}
318
+ y={popover.y}
319
+ exact={popover.exact}
320
+ selectors={popover.selectors}
321
+ onSave={(data) => {
322
+ annotationOps.createAnnotation(data)
323
+ setPopover(null)
324
+ window.getSelection()?.removeAllRanges()
325
+ }}
326
+ onCancel={() => {
327
+ setPopover(null)
328
+ window.getSelection()?.removeAllRanges()
329
+ }}
330
+ />
331
+ )}
332
+ </main>
333
+ )
334
+ }
@@ -0,0 +1,62 @@
1
+ import { useState } from 'preact/hooks'
2
+ import { currentFile } from '../state/store.js'
3
+
4
+ export function ExportMenu() {
5
+ const [open, setOpen] = useState(false)
6
+
7
+ async function handleExport(format) {
8
+ setOpen(false)
9
+ const file = currentFile.value
10
+ if (!file) return
11
+
12
+ try {
13
+ const res = await fetch(`/api/export?path=${encodeURIComponent(file)}&format=${format}`)
14
+ if (!res.ok) throw new Error(await res.text())
15
+
16
+ if (format === 'report') {
17
+ // Open in new tab
18
+ const text = await res.text()
19
+ const blob = new Blob([text], { type: 'text/markdown' })
20
+ window.open(URL.createObjectURL(blob))
21
+ } else {
22
+ // Download file
23
+ const blob = await res.blob()
24
+ const ext = { inline: '.reviewed.md', json: '.annotations.json', sarif: '.annotations.sarif' }[format]
25
+ const a = document.createElement('a')
26
+ a.href = URL.createObjectURL(blob)
27
+ a.download = file.replace('.md', ext)
28
+ a.click()
29
+ }
30
+ } catch (err) {
31
+ console.error('Export failed:', err)
32
+ alert(`Export failed: ${err.message}`)
33
+ }
34
+ }
35
+
36
+ return (
37
+ <div style="position: relative">
38
+ <button class="btn btn-sm" onClick={() => setOpen(!open)}>
39
+ Export
40
+ </button>
41
+ {open && (
42
+ <>
43
+ <div style="position: fixed; inset: 0; z-index: 90" onClick={() => setOpen(false)} />
44
+ <div style="position: absolute; right: 0; top: 100%; margin-top: 4px; z-index: 100; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); min-width: 180px; overflow: hidden">
45
+ <button class="export-option" onClick={() => handleExport('report')}>
46
+ Review Report (.md)
47
+ </button>
48
+ <button class="export-option" onClick={() => handleExport('inline')}>
49
+ Inline Comments (.md)
50
+ </button>
51
+ <button class="export-option" onClick={() => handleExport('json')}>
52
+ JSON (.json)
53
+ </button>
54
+ <button class="export-option" onClick={() => handleExport('sarif')}>
55
+ SARIF (.sarif)
56
+ </button>
57
+ </div>
58
+ </>
59
+ )}
60
+ </div>
61
+ )
62
+ }