@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/README.md +404 -0
- package/bin/cli.js +335 -0
- package/dist/assets/index-DPysqH1p.js +2 -0
- package/dist/assets/index-nl9v2RuJ.css +1 -0
- package/dist/index.html +19 -0
- package/package.json +75 -0
- package/schema.json +104 -0
- package/skills/mdprobe/SKILL.md +358 -0
- package/src/anchoring.js +262 -0
- package/src/annotations.js +504 -0
- package/src/cli-utils.js +58 -0
- package/src/config.js +76 -0
- package/src/export.js +211 -0
- package/src/handler.js +229 -0
- package/src/hash.js +51 -0
- package/src/renderer.js +247 -0
- package/src/server.js +849 -0
- package/src/ui/app.jsx +152 -0
- package/src/ui/components/AnnotationForm.jsx +72 -0
- package/src/ui/components/Content.jsx +334 -0
- package/src/ui/components/ExportMenu.jsx +62 -0
- package/src/ui/components/LeftPanel.jsx +99 -0
- package/src/ui/components/Popover.jsx +94 -0
- package/src/ui/components/ReplyThread.jsx +28 -0
- package/src/ui/components/RightPanel.jsx +171 -0
- package/src/ui/components/SectionApproval.jsx +31 -0
- package/src/ui/components/ThemePicker.jsx +18 -0
- package/src/ui/hooks/useAnnotations.js +160 -0
- package/src/ui/hooks/useClientLibs.js +97 -0
- package/src/ui/hooks/useKeyboard.js +128 -0
- package/src/ui/hooks/useTheme.js +57 -0
- package/src/ui/hooks/useWebSocket.js +126 -0
- package/src/ui/index.html +19 -0
- package/src/ui/state/store.js +76 -0
- package/src/ui/styles/themes.css +1243 -0
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
|
+
}
|