@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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'preact/hooks'
|
|
2
|
+
import { currentHtml, theme } from '../state/store.js'
|
|
3
|
+
|
|
4
|
+
const MERMAID_CDN = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js'
|
|
5
|
+
const KATEX_CSS_CDN = 'https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.css'
|
|
6
|
+
const KATEX_JS_CDN = 'https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.js'
|
|
7
|
+
|
|
8
|
+
let mermaidLoaded = false
|
|
9
|
+
let katexLoaded = false
|
|
10
|
+
|
|
11
|
+
function loadScript(src) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
if (document.querySelector(`script[src="${src}"]`)) return resolve()
|
|
14
|
+
const s = document.createElement('script')
|
|
15
|
+
s.src = src
|
|
16
|
+
s.onload = resolve
|
|
17
|
+
s.onerror = reject
|
|
18
|
+
document.head.appendChild(s)
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function loadCSS(href) {
|
|
23
|
+
if (document.querySelector(`link[href="${href}"]`)) return
|
|
24
|
+
const l = document.createElement('link')
|
|
25
|
+
l.rel = 'stylesheet'
|
|
26
|
+
l.href = href
|
|
27
|
+
document.head.appendChild(l)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Initialize Mermaid diagrams and KaTeX math after content renders.
|
|
32
|
+
* Libraries are loaded lazily from CDN on first use.
|
|
33
|
+
*/
|
|
34
|
+
export function useClientLibs() {
|
|
35
|
+
const lastHtml = useRef('')
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (currentHtml.value === lastHtml.current) return
|
|
39
|
+
lastHtml.current = currentHtml.value
|
|
40
|
+
|
|
41
|
+
// Mermaid: render all <pre class="mermaid"> blocks
|
|
42
|
+
const mermaidEls = document.querySelectorAll('pre.mermaid:not([data-processed])')
|
|
43
|
+
if (mermaidEls.length > 0) {
|
|
44
|
+
initMermaid(mermaidEls)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// KaTeX: render all elements with data-math or math class
|
|
48
|
+
const mathEls = document.querySelectorAll('[data-math], .math-inline, .math-display')
|
|
49
|
+
if (mathEls.length > 0) {
|
|
50
|
+
initKaTeX(mathEls)
|
|
51
|
+
}
|
|
52
|
+
}, [currentHtml.value, theme.value])
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function initMermaid(elements) {
|
|
56
|
+
try {
|
|
57
|
+
if (!mermaidLoaded) {
|
|
58
|
+
await loadScript(MERMAID_CDN)
|
|
59
|
+
mermaidLoaded = true
|
|
60
|
+
}
|
|
61
|
+
const isDark = !['latte', 'light'].includes(theme.value)
|
|
62
|
+
window.mermaid.initialize({
|
|
63
|
+
startOnLoad: false,
|
|
64
|
+
theme: isDark ? 'dark' : 'default',
|
|
65
|
+
})
|
|
66
|
+
// Mermaid 11+ uses run() with nodes
|
|
67
|
+
for (const el of elements) {
|
|
68
|
+
el.setAttribute('data-processed', 'true')
|
|
69
|
+
}
|
|
70
|
+
await window.mermaid.run({ nodes: [...elements] })
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.warn('mdprobe: Mermaid rendering failed', err)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function initKaTeX(elements) {
|
|
77
|
+
try {
|
|
78
|
+
if (!katexLoaded) {
|
|
79
|
+
loadCSS(KATEX_CSS_CDN)
|
|
80
|
+
await loadScript(KATEX_JS_CDN)
|
|
81
|
+
katexLoaded = true
|
|
82
|
+
}
|
|
83
|
+
for (const el of elements) {
|
|
84
|
+
if (el.getAttribute('data-katex-rendered')) continue
|
|
85
|
+
const tex = el.textContent
|
|
86
|
+
const displayMode = el.classList.contains('math-display')
|
|
87
|
+
try {
|
|
88
|
+
window.katex.render(tex, el, { displayMode, throwOnError: false })
|
|
89
|
+
el.setAttribute('data-katex-rendered', 'true')
|
|
90
|
+
} catch {
|
|
91
|
+
// Leave raw LaTeX visible
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.warn('mdprobe: KaTeX rendering failed', err)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useEffect } from 'preact/hooks'
|
|
2
|
+
import {
|
|
3
|
+
leftPanelOpen,
|
|
4
|
+
rightPanelOpen,
|
|
5
|
+
selectedAnnotationId,
|
|
6
|
+
filteredAnnotations,
|
|
7
|
+
} from '../state/store.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Registers global keyboard shortcuts for the mdprobe UI.
|
|
11
|
+
* Shortcuts are suppressed when focus is inside a text input, textarea,
|
|
12
|
+
* select, or contentEditable element.
|
|
13
|
+
*
|
|
14
|
+
* Bindings:
|
|
15
|
+
* [ – toggle left panel
|
|
16
|
+
* ] – toggle right panel
|
|
17
|
+
* \ – toggle both panels (focus mode)
|
|
18
|
+
* j – select next annotation
|
|
19
|
+
* k – select previous annotation
|
|
20
|
+
* r – reserved (resolve, handled by parent)
|
|
21
|
+
* e – reserved (edit, handled by parent)
|
|
22
|
+
* ? – show help overlay
|
|
23
|
+
*
|
|
24
|
+
* @param {{ onShowHelp?: () => void }} options
|
|
25
|
+
*/
|
|
26
|
+
export function useKeyboard({ onShowHelp } = {}) {
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
function handleKey(e) {
|
|
29
|
+
// Don't intercept when typing in form controls
|
|
30
|
+
const tag = document.activeElement?.tagName
|
|
31
|
+
if (tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT') return
|
|
32
|
+
if (document.activeElement?.isContentEditable) return
|
|
33
|
+
|
|
34
|
+
switch (e.key) {
|
|
35
|
+
case '[':
|
|
36
|
+
e.preventDefault()
|
|
37
|
+
leftPanelOpen.value = !leftPanelOpen.value
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
case ']':
|
|
41
|
+
e.preventDefault()
|
|
42
|
+
rightPanelOpen.value = !rightPanelOpen.value
|
|
43
|
+
break
|
|
44
|
+
|
|
45
|
+
case '\\': {
|
|
46
|
+
e.preventDefault()
|
|
47
|
+
const bothOpen = leftPanelOpen.value && rightPanelOpen.value
|
|
48
|
+
leftPanelOpen.value = !bothOpen
|
|
49
|
+
rightPanelOpen.value = !bothOpen
|
|
50
|
+
break
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case 'j':
|
|
54
|
+
e.preventDefault()
|
|
55
|
+
navigateAnnotation(1)
|
|
56
|
+
break
|
|
57
|
+
|
|
58
|
+
case 'k':
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
navigateAnnotation(-1)
|
|
61
|
+
break
|
|
62
|
+
|
|
63
|
+
case 'r':
|
|
64
|
+
// Resolve – handled by parent component listener
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
case 'e':
|
|
68
|
+
// Edit – handled by parent component listener
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
case '?':
|
|
72
|
+
e.preventDefault()
|
|
73
|
+
onShowHelp?.()
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
default:
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
document.addEventListener('keydown', handleKey)
|
|
82
|
+
return () => document.removeEventListener('keydown', handleKey)
|
|
83
|
+
}, [onShowHelp])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Internal helpers
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Move selection to the next/previous annotation in the filtered list
|
|
92
|
+
* and scroll both the right-panel card and the content highlight into view.
|
|
93
|
+
*
|
|
94
|
+
* @param {1|-1} direction 1 = next, -1 = previous
|
|
95
|
+
*/
|
|
96
|
+
function navigateAnnotation(direction) {
|
|
97
|
+
const list = filteredAnnotations.value
|
|
98
|
+
if (list.length === 0) return
|
|
99
|
+
|
|
100
|
+
const currentId = selectedAnnotationId.value
|
|
101
|
+
const currentIndex = list.findIndex((a) => a.id === currentId)
|
|
102
|
+
|
|
103
|
+
let nextIndex
|
|
104
|
+
if (currentIndex === -1) {
|
|
105
|
+
// Nothing selected yet – jump to first or last depending on direction
|
|
106
|
+
nextIndex = direction > 0 ? 0 : list.length - 1
|
|
107
|
+
} else {
|
|
108
|
+
nextIndex = currentIndex + direction
|
|
109
|
+
// Wrap around
|
|
110
|
+
if (nextIndex < 0) nextIndex = list.length - 1
|
|
111
|
+
if (nextIndex >= list.length) nextIndex = 0
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const target = list[nextIndex]
|
|
115
|
+
selectedAnnotationId.value = target.id
|
|
116
|
+
|
|
117
|
+
// Scroll the annotation card into view in the right panel
|
|
118
|
+
const card = document.querySelector(
|
|
119
|
+
`[data-annotation-id="${target.id}"]`,
|
|
120
|
+
)
|
|
121
|
+
card?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
122
|
+
|
|
123
|
+
// Scroll the corresponding content highlight into view
|
|
124
|
+
const highlight = document.querySelector(
|
|
125
|
+
`[data-highlight-id="${target.id}"]`,
|
|
126
|
+
)
|
|
127
|
+
highlight?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
128
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect } from 'preact/hooks'
|
|
2
|
+
import { theme } from '../state/store.js'
|
|
3
|
+
|
|
4
|
+
/** Available themes with display metadata. */
|
|
5
|
+
export const THEMES = [
|
|
6
|
+
{ id: 'mocha', label: 'Mocha', color: '#1e1e2e' },
|
|
7
|
+
{ id: 'macchiato', label: 'Macchiato', color: '#24273a' },
|
|
8
|
+
{ id: 'frappe', label: 'Frapp\u00e9', color: '#303446' },
|
|
9
|
+
{ id: 'latte', label: 'Latte', color: '#eff1f5' },
|
|
10
|
+
{ id: 'light', label: 'Light', color: '#ffffff' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const STORAGE_KEY = 'mdprobe-theme'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Theme management hook.
|
|
17
|
+
*
|
|
18
|
+
* - Applies the active theme as a `data-theme` attribute on `<html>`.
|
|
19
|
+
* - Persists the selection to `localStorage`.
|
|
20
|
+
* - Subscribes to signal changes so the DOM stays in sync even when the
|
|
21
|
+
* signal is mutated outside this hook.
|
|
22
|
+
*
|
|
23
|
+
* @returns {{ theme: import('@preact/signals').Signal<string>, setTheme: (id: string) => void, themes: typeof THEMES }}
|
|
24
|
+
*/
|
|
25
|
+
export function useTheme() {
|
|
26
|
+
// Subscribe to the theme signal and keep the DOM + storage in sync.
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// Apply immediately in case the signal already holds a value
|
|
29
|
+
applyTheme(theme.value)
|
|
30
|
+
|
|
31
|
+
const dispose = theme.subscribe((value) => {
|
|
32
|
+
applyTheme(value)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
return dispose
|
|
36
|
+
}, [])
|
|
37
|
+
|
|
38
|
+
/** Set the active theme by id. */
|
|
39
|
+
function setTheme(id) {
|
|
40
|
+
theme.value = id
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { theme, setTheme, themes: THEMES }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Internal helper
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function applyTheme(id) {
|
|
51
|
+
document.documentElement.setAttribute('data-theme', id)
|
|
52
|
+
try {
|
|
53
|
+
localStorage.setItem(STORAGE_KEY, id)
|
|
54
|
+
} catch {
|
|
55
|
+
// Storage may be unavailable (private browsing, quota exceeded, etc.)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useEffect, useRef, useCallback } from 'preact/hooks'
|
|
2
|
+
import {
|
|
3
|
+
currentHtml,
|
|
4
|
+
currentToc,
|
|
5
|
+
currentFile,
|
|
6
|
+
files,
|
|
7
|
+
annotations,
|
|
8
|
+
sections,
|
|
9
|
+
driftWarning,
|
|
10
|
+
} from '../state/store.js'
|
|
11
|
+
|
|
12
|
+
const RECONNECT_DELAY_MS = 2000
|
|
13
|
+
const MAX_RECONNECT_DELAY_MS = 30000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Manages the WebSocket connection to the mdprobe server.
|
|
17
|
+
* Automatically reconnects with exponential back-off on disconnection.
|
|
18
|
+
* Dispatches incoming messages to the appropriate signals in the store.
|
|
19
|
+
*
|
|
20
|
+
* @returns {import('preact/hooks').Ref<WebSocket|null>} ref to the active WebSocket
|
|
21
|
+
*/
|
|
22
|
+
export function useWebSocket() {
|
|
23
|
+
const wsRef = useRef(null)
|
|
24
|
+
const reconnectDelay = useRef(RECONNECT_DELAY_MS)
|
|
25
|
+
const reconnectTimer = useRef(null)
|
|
26
|
+
const unmounted = useRef(false)
|
|
27
|
+
|
|
28
|
+
const connect = useCallback(() => {
|
|
29
|
+
if (unmounted.current) return
|
|
30
|
+
|
|
31
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
32
|
+
const ws = new WebSocket(`${protocol}//${location.host}/ws`)
|
|
33
|
+
wsRef.current = ws
|
|
34
|
+
|
|
35
|
+
ws.onopen = () => {
|
|
36
|
+
// Reset back-off on successful connection
|
|
37
|
+
reconnectDelay.current = RECONNECT_DELAY_MS
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
ws.onmessage = (event) => {
|
|
41
|
+
let msg
|
|
42
|
+
try {
|
|
43
|
+
msg = JSON.parse(event.data)
|
|
44
|
+
} catch {
|
|
45
|
+
console.warn('mdprobe: received non-JSON WebSocket message')
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
switch (msg.type) {
|
|
50
|
+
case 'update': {
|
|
51
|
+
// Preserve scroll position across live reload
|
|
52
|
+
const contentEl = document.querySelector('.content-area')
|
|
53
|
+
const savedScroll = contentEl ? contentEl.scrollTop : 0
|
|
54
|
+
currentHtml.value = msg.html
|
|
55
|
+
currentToc.value = msg.toc || []
|
|
56
|
+
// Restore scroll after DOM update
|
|
57
|
+
if (contentEl) {
|
|
58
|
+
requestAnimationFrame(() => { contentEl.scrollTop = savedScroll })
|
|
59
|
+
}
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case 'file-added':
|
|
64
|
+
// Avoid duplicates
|
|
65
|
+
if (!files.value.some((f) => f.path === msg.file)) {
|
|
66
|
+
files.value = [
|
|
67
|
+
...files.value,
|
|
68
|
+
{ path: msg.file, label: msg.file.replace(/\.md$/, '') },
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
case 'file-removed':
|
|
74
|
+
files.value = files.value.filter((f) => f.path !== msg.file)
|
|
75
|
+
break
|
|
76
|
+
|
|
77
|
+
case 'annotations':
|
|
78
|
+
// Only apply if the broadcast is for the currently viewed file
|
|
79
|
+
if (!msg.file || msg.file === currentFile.value) {
|
|
80
|
+
annotations.value = msg.annotations || []
|
|
81
|
+
sections.value = msg.sections || []
|
|
82
|
+
}
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
case 'drift':
|
|
86
|
+
driftWarning.value = msg.warning || true
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
case 'error':
|
|
90
|
+
// Keep last valid render; surface the warning in the console
|
|
91
|
+
console.warn('mdprobe:', msg.message)
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
ws.onerror = (err) => {
|
|
97
|
+
console.warn('mdprobe: WebSocket error', err)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
ws.onclose = () => {
|
|
101
|
+
wsRef.current = null
|
|
102
|
+
if (unmounted.current) return
|
|
103
|
+
|
|
104
|
+
// Exponential back-off with jitter, capped at MAX_RECONNECT_DELAY_MS
|
|
105
|
+
const delay = reconnectDelay.current
|
|
106
|
+
reconnectDelay.current = Math.min(delay * 2, MAX_RECONNECT_DELAY_MS)
|
|
107
|
+
const jitter = Math.random() * delay * 0.3
|
|
108
|
+
|
|
109
|
+
reconnectTimer.current = setTimeout(connect, delay + jitter)
|
|
110
|
+
}
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
unmounted.current = false
|
|
115
|
+
connect()
|
|
116
|
+
|
|
117
|
+
return () => {
|
|
118
|
+
unmounted.current = true
|
|
119
|
+
clearTimeout(reconnectTimer.current)
|
|
120
|
+
wsRef.current?.close()
|
|
121
|
+
wsRef.current = null
|
|
122
|
+
}
|
|
123
|
+
}, [connect])
|
|
124
|
+
|
|
125
|
+
return wsRef
|
|
126
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>mdprobe</title>
|
|
7
|
+
<script>
|
|
8
|
+
(function() {
|
|
9
|
+
var theme = localStorage.getItem('mdprobe-theme') || 'mocha';
|
|
10
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
11
|
+
})();
|
|
12
|
+
</script>
|
|
13
|
+
<link rel="stylesheet" href="./styles/themes.css" />
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<div id="app"></div>
|
|
17
|
+
<script type="module" src="./app.jsx"></script>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { signal, computed } from '@preact/signals'
|
|
2
|
+
|
|
3
|
+
// Files
|
|
4
|
+
export const files = signal([])
|
|
5
|
+
export const currentFile = signal(null)
|
|
6
|
+
|
|
7
|
+
// Content
|
|
8
|
+
export const currentHtml = signal('')
|
|
9
|
+
export const currentToc = signal([])
|
|
10
|
+
export const frontmatter = signal(null)
|
|
11
|
+
|
|
12
|
+
// Annotations
|
|
13
|
+
export const annotations = signal([])
|
|
14
|
+
export const sections = signal([])
|
|
15
|
+
export const selectedAnnotationId = signal(null)
|
|
16
|
+
export const showResolved = signal(false)
|
|
17
|
+
export const filterTag = signal(null)
|
|
18
|
+
export const filterAuthor = signal(null)
|
|
19
|
+
|
|
20
|
+
// UI state — restore from localStorage
|
|
21
|
+
export const leftPanelOpen = signal(localStorage.getItem('mdprobe-left-panel') !== 'false')
|
|
22
|
+
export const rightPanelOpen = signal(localStorage.getItem('mdprobe-right-panel') !== 'false')
|
|
23
|
+
export const theme = signal(localStorage.getItem('mdprobe-theme') || 'mocha')
|
|
24
|
+
export const driftWarning = signal(false)
|
|
25
|
+
|
|
26
|
+
// Persist panel state on change via effect (subscribed below)
|
|
27
|
+
leftPanelOpen.subscribe(v => localStorage.setItem('mdprobe-left-panel', v))
|
|
28
|
+
rightPanelOpen.subscribe(v => localStorage.setItem('mdprobe-right-panel', v))
|
|
29
|
+
|
|
30
|
+
// Config
|
|
31
|
+
export const author = signal('anonymous')
|
|
32
|
+
export const reviewMode = signal(false)
|
|
33
|
+
|
|
34
|
+
// Computed
|
|
35
|
+
export const openAnnotations = computed(() =>
|
|
36
|
+
annotations.value.filter(a => a.status === 'open')
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
export const resolvedAnnotations = computed(() =>
|
|
40
|
+
annotations.value.filter(a => a.status === 'resolved')
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
export const filteredAnnotations = computed(() => {
|
|
44
|
+
let list = showResolved.value
|
|
45
|
+
? annotations.value
|
|
46
|
+
: annotations.value.filter(a => a.status === 'open')
|
|
47
|
+
|
|
48
|
+
if (filterTag.value) {
|
|
49
|
+
list = list.filter(a => a.tag === filterTag.value)
|
|
50
|
+
}
|
|
51
|
+
if (filterAuthor.value) {
|
|
52
|
+
list = list.filter(a => a.author === filterAuthor.value)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return list
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
export const uniqueTags = computed(() =>
|
|
59
|
+
[...new Set(annotations.value.map(a => a.tag))]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
export const uniqueAuthors = computed(() =>
|
|
63
|
+
[...new Set(annotations.value.map(a => a.author))]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
// Adaptive section level (set from API response)
|
|
67
|
+
export const sectionLevel = signal(2)
|
|
68
|
+
|
|
69
|
+
// Section stats — count at the adaptive section level
|
|
70
|
+
export const sectionStats = computed(() => {
|
|
71
|
+
const lvl = sectionLevel.value
|
|
72
|
+
const atLevel = sections.value.filter(s => s.level === lvl)
|
|
73
|
+
const total = atLevel.length
|
|
74
|
+
const reviewed = atLevel.filter(s => s.status !== 'pending').length
|
|
75
|
+
return { total, reviewed }
|
|
76
|
+
})
|