md-annotator 0.9.0 → 0.11.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 +2 -0
- package/client/dist/index.html +863 -579
- package/client/src/utils/crossFileSearch.js +53 -0
- package/client/src/utils/export.js +3 -1
- package/client/src/utils/parser.js +46 -7
- package/client/src/utils/quickLabels.js +57 -0
- package/client/src/utils/searchHighlight.js +121 -0
- package/client/src/utils/storage.js +40 -0
- package/package.json +2 -1
- package/server/feedback.js +2 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-based search across multiple file contents.
|
|
3
|
+
* Returns results grouped by file with line numbers and context snippets.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const CONTEXT_CHARS = 40
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {{ path: string, content: string }[]} files
|
|
10
|
+
* @param {string} query
|
|
11
|
+
* @returns {{ fileIndex: number, filePath: string, matches: { line: number, offset: number, context: string }[] }[]}
|
|
12
|
+
*/
|
|
13
|
+
export function searchAcrossFiles(files, query) {
|
|
14
|
+
if (!query || !files?.length) { return [] }
|
|
15
|
+
|
|
16
|
+
const lowerQuery = query.toLowerCase()
|
|
17
|
+
const results = []
|
|
18
|
+
|
|
19
|
+
for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
|
|
20
|
+
const file = files[fileIndex]
|
|
21
|
+
const content = file.content || ''
|
|
22
|
+
const lowerContent = content.toLowerCase()
|
|
23
|
+
const matches = []
|
|
24
|
+
let searchIdx = 0
|
|
25
|
+
|
|
26
|
+
while ((searchIdx = lowerContent.indexOf(lowerQuery, searchIdx)) !== -1) {
|
|
27
|
+
const line = content.slice(0, searchIdx).split('\n').length
|
|
28
|
+
const lineStart = content.lastIndexOf('\n', searchIdx - 1) + 1
|
|
29
|
+
const lineEnd = content.indexOf('\n', searchIdx)
|
|
30
|
+
const lineText = content.slice(lineStart, lineEnd === -1 ? content.length : lineEnd)
|
|
31
|
+
|
|
32
|
+
const offsetInLine = searchIdx - lineStart
|
|
33
|
+
const contextStart = Math.max(0, offsetInLine - CONTEXT_CHARS)
|
|
34
|
+
const contextEnd = Math.min(lineText.length, offsetInLine + query.length + CONTEXT_CHARS)
|
|
35
|
+
const prefix = contextStart > 0 ? '...' : ''
|
|
36
|
+
const suffix = contextEnd < lineText.length ? '...' : ''
|
|
37
|
+
const context = prefix + lineText.slice(contextStart, contextEnd) + suffix
|
|
38
|
+
|
|
39
|
+
matches.push({ line, offset: searchIdx, context })
|
|
40
|
+
searchIdx += lowerQuery.length
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (matches.length > 0) {
|
|
44
|
+
results.push({
|
|
45
|
+
fileIndex,
|
|
46
|
+
filePath: file.path,
|
|
47
|
+
matches,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return results
|
|
53
|
+
}
|
|
@@ -80,7 +80,8 @@ export function formatAnnotationsForExport(annotations, blocks, filePath) {
|
|
|
80
80
|
output += `Remove (${lineRef})\n\n`
|
|
81
81
|
output += `\`\`\`\n${ann.originalText}\n\`\`\`\n\n`
|
|
82
82
|
} else if (ann.type === 'COMMENT') {
|
|
83
|
-
|
|
83
|
+
const labelTag = ann.label ? ` [${ann.label.emoji} ${ann.label.text}]` : ''
|
|
84
|
+
output += `Comment (${lineRef})${labelTag}\n\n`
|
|
84
85
|
output += `\`\`\`\n${ann.originalText}\n\`\`\`\n\n`
|
|
85
86
|
output += `> ${ann.text}\n\n`
|
|
86
87
|
} else if (ann.type === 'INSERTION') {
|
|
@@ -159,6 +160,7 @@ export function formatAnnotationsForJsonExport(annotations, filePath, contentHas
|
|
|
159
160
|
endMeta: ann.endMeta
|
|
160
161
|
}
|
|
161
162
|
if (ann.targetType) { base.targetType = ann.targetType }
|
|
163
|
+
if (ann.label) { base.label = ann.label }
|
|
162
164
|
if (ann.imageAlt !== undefined) { base.imageAlt = ann.imageAlt }
|
|
163
165
|
if (ann.imageSrc !== undefined) { base.imageSrc = ann.imageSrc }
|
|
164
166
|
if (ann.afterContext !== undefined) { base.afterContext = ann.afterContext }
|
|
@@ -22,7 +22,7 @@ const HTML_MIXED_CONTENT_TAGS = new Set([
|
|
|
22
22
|
* Simplified markdown parser that splits content into linear blocks.
|
|
23
23
|
* Designed for predictable text-anchoring (not AST-based).
|
|
24
24
|
*/
|
|
25
|
-
export function parseMarkdownToBlocks(markdown) {
|
|
25
|
+
export function parseMarkdownToBlocks(markdown, { allowFrontmatter = true } = {}) {
|
|
26
26
|
const lines = markdown.split('\n')
|
|
27
27
|
const blocks = []
|
|
28
28
|
let currentId = 0
|
|
@@ -30,6 +30,42 @@ export function parseMarkdownToBlocks(markdown) {
|
|
|
30
30
|
let currentType = 'paragraph'
|
|
31
31
|
const currentLevel = 0
|
|
32
32
|
let bufferStartLine = 1
|
|
33
|
+
let startIndex = 0
|
|
34
|
+
|
|
35
|
+
// YAML frontmatter: must start at line 0 with exactly '---'
|
|
36
|
+
if (allowFrontmatter && lines[0]?.trim() === '---') {
|
|
37
|
+
let closeIndex = -1
|
|
38
|
+
for (let j = 1; j < lines.length; j++) {
|
|
39
|
+
if (lines[j].trim() === '---') {
|
|
40
|
+
closeIndex = j
|
|
41
|
+
break
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (closeIndex > 1) {
|
|
45
|
+
const fmLines = lines.slice(1, closeIndex)
|
|
46
|
+
const entries = []
|
|
47
|
+
for (const fmLine of fmLines) {
|
|
48
|
+
const colonIdx = fmLine.indexOf(':')
|
|
49
|
+
if (colonIdx > 0) {
|
|
50
|
+
entries.push({
|
|
51
|
+
key: fmLine.slice(0, colonIdx).trim(),
|
|
52
|
+
value: fmLine.slice(colonIdx + 1).trim()
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (entries.length > 0) {
|
|
57
|
+
blocks.push({
|
|
58
|
+
id: `block-${currentId++}`,
|
|
59
|
+
type: 'frontmatter',
|
|
60
|
+
content: fmLines.join('\n'),
|
|
61
|
+
entries,
|
|
62
|
+
order: currentId,
|
|
63
|
+
startLine: 1
|
|
64
|
+
})
|
|
65
|
+
startIndex = closeIndex + 1
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
33
69
|
|
|
34
70
|
const flush = () => {
|
|
35
71
|
if (buffer.length > 0) {
|
|
@@ -46,7 +82,7 @@ export function parseMarkdownToBlocks(markdown) {
|
|
|
46
82
|
}
|
|
47
83
|
}
|
|
48
84
|
|
|
49
|
-
for (let i =
|
|
85
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
50
86
|
const line = lines[i]
|
|
51
87
|
const trimmed = line.trim()
|
|
52
88
|
const currentLineNum = i + 1
|
|
@@ -142,14 +178,17 @@ export function parseMarkdownToBlocks(markdown) {
|
|
|
142
178
|
continue
|
|
143
179
|
}
|
|
144
180
|
|
|
145
|
-
// Code blocks
|
|
146
|
-
|
|
181
|
+
// Code blocks (supports nested fences per CommonMark: closing fence must have >= opening backtick count)
|
|
182
|
+
const fenceMatch = trimmed.match(/^(`{3,})/)
|
|
183
|
+
if (fenceMatch) {
|
|
147
184
|
flush()
|
|
148
185
|
const codeStartLine = currentLineNum
|
|
149
|
-
const
|
|
186
|
+
const fenceLen = fenceMatch[1].length
|
|
187
|
+
const language = trimmed.slice(fenceLen).trim() || undefined
|
|
188
|
+
const closingPattern = new RegExp(`^\`{${fenceLen},}$`)
|
|
150
189
|
const codeContent = []
|
|
151
190
|
i++
|
|
152
|
-
while (i < lines.length && !lines[i].trim()
|
|
191
|
+
while (i < lines.length && !closingPattern.test(lines[i].trim())) {
|
|
153
192
|
codeContent.push(lines[i])
|
|
154
193
|
i++
|
|
155
194
|
}
|
|
@@ -253,7 +292,7 @@ export function parseMarkdownToBlocks(markdown) {
|
|
|
253
292
|
startLine: htmlStartLine
|
|
254
293
|
})
|
|
255
294
|
// Recursively parse inner content as markdown
|
|
256
|
-
for (const inner of parseMarkdownToBlocks(innerLines.join('\n'))) {
|
|
295
|
+
for (const inner of parseMarkdownToBlocks(innerLines.join('\n'), { allowFrontmatter: false })) {
|
|
257
296
|
blocks.push({ ...inner, id: `block-${currentId++}`, order: currentId, startLine: htmlStartLine + inner.startLine })
|
|
258
297
|
}
|
|
259
298
|
// Emit closing tag
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quick annotation labels for fast categorization.
|
|
3
|
+
* Alt+1–0 shortcuts apply a label directly to the current selection.
|
|
4
|
+
*/
|
|
5
|
+
import { getItem } from './storage.js'
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'md-annotator-quick-labels'
|
|
8
|
+
|
|
9
|
+
export const LABEL_COLORS = {
|
|
10
|
+
yellow: { bg: 'rgb(235 203 139 / 18%)', text: '#ebcb8b', darkText: '#ebcb8b' },
|
|
11
|
+
blue: { bg: 'rgb(94 129 172 / 15%)', text: '#5e81ac', darkText: '#88c0d0' },
|
|
12
|
+
red: { bg: 'rgb(191 97 106 / 12%)', text: '#bf616a', darkText: '#bf616a' },
|
|
13
|
+
green: { bg: 'rgb(163 190 140 / 15%)', text: '#a3be8c', darkText: '#a3be8c' },
|
|
14
|
+
orange: { bg: 'rgb(208 135 112 / 12%)', text: '#d08770', darkText: '#d08770' },
|
|
15
|
+
purple: { bg: 'rgb(180 142 173 / 15%)', text: '#b48ead', darkText: '#b48ead' },
|
|
16
|
+
cyan: { bg: 'rgb(136 192 208 / 15%)', text: '#88c0d0', darkText: '#88c0d0' },
|
|
17
|
+
teal: { bg: 'rgb(143 188 187 / 15%)', text: '#8fbcbb', darkText: '#8fbcbb' },
|
|
18
|
+
pink: { bg: 'rgb(180 142 173 / 18%)', text: '#b48ead', darkText: '#d196d0' },
|
|
19
|
+
amber: { bg: 'rgb(235 203 139 / 22%)', text: '#c9a227', darkText: '#ebcb8b' },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_LABELS = [
|
|
23
|
+
{ id: 'unclear', emoji: '\u2753', text: 'Unclear', color: 'yellow' },
|
|
24
|
+
{ id: 'rephrase', emoji: '\u270F\uFE0F', text: 'Rephrase', color: 'blue' },
|
|
25
|
+
{ id: 'missing-context', emoji: '\uD83D\uDCDD', text: 'Missing Context', color: 'orange' },
|
|
26
|
+
{ id: 'factual-error', emoji: '\u274C', text: 'Factual Error', color: 'red' },
|
|
27
|
+
{ id: 'restructure', emoji: '\uD83D\uDD04', text: 'Restructure', color: 'purple' },
|
|
28
|
+
{ id: 'expand', emoji: '\u2795', text: 'Expand', color: 'cyan' },
|
|
29
|
+
{ id: 'shorten', emoji: '\u2796', text: 'Shorten', color: 'teal' },
|
|
30
|
+
{ id: 'suggestion', emoji: '\uD83D\uDCA1', text: 'Suggestion', color: 'amber' },
|
|
31
|
+
{ id: 'good', emoji: '\u2705', text: 'Good', color: 'green' },
|
|
32
|
+
{ id: 'reference', emoji: '\uD83D\uDD17', text: 'Reference', color: 'pink' },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
export function getQuickLabels() {
|
|
36
|
+
try {
|
|
37
|
+
const stored = getItem(STORAGE_KEY)
|
|
38
|
+
if (stored) {
|
|
39
|
+
const parsed = JSON.parse(stored)
|
|
40
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
41
|
+
return parsed
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch { /* ignore */ }
|
|
45
|
+
return DEFAULT_LABELS
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getLabelColors(colorName) {
|
|
49
|
+
const entry = LABEL_COLORS[colorName]
|
|
50
|
+
if (!entry) { return { bg: 'rgb(94 129 172 / 15%)', text: '#5e81ac' } }
|
|
51
|
+
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
|
|
52
|
+
return { bg: entry.bg, text: isDark ? entry.darkText : entry.text }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatLabelText(label) {
|
|
56
|
+
return `${label.emoji} ${label.text}`
|
|
57
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM-based search highlighting.
|
|
3
|
+
* Injects/removes <mark> elements for text search matches.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MATCH_CLASS = 'search-match'
|
|
7
|
+
const ACTIVE_CLASS = 'search-match-active'
|
|
8
|
+
|
|
9
|
+
// Selectors for containers whose text content should not be searched
|
|
10
|
+
// (rendered diagrams, hidden source blocks, UI chrome, line numbers, etc.)
|
|
11
|
+
const SKIP_SELECTOR = [
|
|
12
|
+
'svg',
|
|
13
|
+
'.source-line-number',
|
|
14
|
+
'.annotation-toolbar',
|
|
15
|
+
'.comment-popover',
|
|
16
|
+
'.diagram-render-area',
|
|
17
|
+
'.diagram-source',
|
|
18
|
+
'.diagram-controls',
|
|
19
|
+
'.code-copy-btn',
|
|
20
|
+
].join(', ')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Walk all text nodes in container, find case-insensitive matches for query,
|
|
24
|
+
* and wrap them in <mark class="search-match">.
|
|
25
|
+
* Skips text nodes inside <mark> elements (avoids double-wrapping).
|
|
26
|
+
* Returns an array of the created <mark> elements.
|
|
27
|
+
*/
|
|
28
|
+
export function highlightMatches(container, query) {
|
|
29
|
+
if (!container || !query) { return [] }
|
|
30
|
+
|
|
31
|
+
const lowerQuery = query.toLowerCase()
|
|
32
|
+
const marks = []
|
|
33
|
+
|
|
34
|
+
// Collect text nodes first to avoid walking newly inserted marks
|
|
35
|
+
const textNodes = []
|
|
36
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
|
37
|
+
acceptNode(node) {
|
|
38
|
+
if (node.parentElement?.closest('mark')) { return NodeFilter.FILTER_REJECT }
|
|
39
|
+
if (node.parentElement?.closest(SKIP_SELECTOR)) { return NodeFilter.FILTER_REJECT }
|
|
40
|
+
return NodeFilter.FILTER_ACCEPT
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
while (walker.nextNode()) {
|
|
44
|
+
textNodes.push(walker.currentNode)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const node of textNodes) {
|
|
48
|
+
const text = node.textContent
|
|
49
|
+
const lowerText = text.toLowerCase()
|
|
50
|
+
const indices = []
|
|
51
|
+
let idx = 0
|
|
52
|
+
|
|
53
|
+
while ((idx = lowerText.indexOf(lowerQuery, idx)) !== -1) {
|
|
54
|
+
indices.push(idx)
|
|
55
|
+
idx += lowerQuery.length
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (indices.length === 0) { continue }
|
|
59
|
+
|
|
60
|
+
const parent = node.parentNode
|
|
61
|
+
const frag = document.createDocumentFragment()
|
|
62
|
+
let lastEnd = 0
|
|
63
|
+
|
|
64
|
+
for (const start of indices) {
|
|
65
|
+
if (start > lastEnd) {
|
|
66
|
+
frag.appendChild(document.createTextNode(text.slice(lastEnd, start)))
|
|
67
|
+
}
|
|
68
|
+
const mark = document.createElement('mark')
|
|
69
|
+
mark.className = MATCH_CLASS
|
|
70
|
+
mark.textContent = text.slice(start, start + query.length)
|
|
71
|
+
frag.appendChild(mark)
|
|
72
|
+
marks.push(mark)
|
|
73
|
+
lastEnd = start + query.length
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (lastEnd < text.length) {
|
|
77
|
+
frag.appendChild(document.createTextNode(text.slice(lastEnd)))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
parent.replaceChild(frag, node)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return marks
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Set the active match class on matches[activeIndex] and scroll it into view.
|
|
88
|
+
* Removes active class from all other matches.
|
|
89
|
+
*/
|
|
90
|
+
export function setActiveMatch(matches, activeIndex) {
|
|
91
|
+
for (let i = 0; i < matches.length; i++) {
|
|
92
|
+
matches[i].classList.toggle(ACTIVE_CLASS, i === activeIndex)
|
|
93
|
+
}
|
|
94
|
+
if (matches[activeIndex]) {
|
|
95
|
+
matches[activeIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remove all search-match <mark> elements, restoring original text nodes.
|
|
101
|
+
* Calls normalize() only on direct parents of removed marks (not the entire container)
|
|
102
|
+
* to avoid disrupting web-highlighter's [data-highlight-id] spans.
|
|
103
|
+
*/
|
|
104
|
+
export function clearSearchHighlights(container) {
|
|
105
|
+
if (!container) { return }
|
|
106
|
+
|
|
107
|
+
const marks = container.querySelectorAll(`mark.${MATCH_CLASS}`)
|
|
108
|
+
const parentsToNormalize = new Set()
|
|
109
|
+
|
|
110
|
+
for (const mark of marks) {
|
|
111
|
+
const parent = mark.parentNode
|
|
112
|
+
if (!parent) { continue }
|
|
113
|
+
const text = document.createTextNode(mark.textContent)
|
|
114
|
+
parent.replaceChild(text, mark)
|
|
115
|
+
parentsToNormalize.add(parent)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const parent of parentsToNormalize) {
|
|
119
|
+
parent.normalize()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie-based storage utility
|
|
3
|
+
*
|
|
4
|
+
* Uses cookies instead of localStorage so settings persist across
|
|
5
|
+
* different ports (each invocation uses a random port).
|
|
6
|
+
* Cookies are scoped by domain, not port, so localhost:54321 and
|
|
7
|
+
* localhost:54322 share the same cookies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365
|
|
11
|
+
|
|
12
|
+
function escapeRegex(str) {
|
|
13
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getItem(key) {
|
|
17
|
+
try {
|
|
18
|
+
const match = document.cookie.match(new RegExp(`(?:^|; )${escapeRegex(key)}=([^;]*)`))
|
|
19
|
+
return match ? decodeURIComponent(match[1]) : null
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function setItem(key, value) {
|
|
26
|
+
try {
|
|
27
|
+
const encoded = encodeURIComponent(value)
|
|
28
|
+
document.cookie = `${key}=${encoded}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax`
|
|
29
|
+
} catch {
|
|
30
|
+
// Cookie not available
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function removeItem(key) {
|
|
35
|
+
try {
|
|
36
|
+
document.cookie = `${key}=; path=/; max-age=0`
|
|
37
|
+
} catch {
|
|
38
|
+
// Cookie not available
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "md-annotator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Browser-based Markdown annotator for AI-assisted review",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"eslint-plugin-react": "^7.37.5",
|
|
55
55
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
56
56
|
"globals": "^17.2.0",
|
|
57
|
+
"jsdom": "^29.0.1",
|
|
57
58
|
"stylelint": "^17.0.0",
|
|
58
59
|
"stylelint-config-standard": "^40.0.0",
|
|
59
60
|
"vite": "^6.0.0",
|
package/server/feedback.js
CHANGED
|
@@ -85,7 +85,8 @@ function formatAnnotation(ann, block, heading) {
|
|
|
85
85
|
output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`
|
|
86
86
|
output += `> User wants this removed from the document.\n`
|
|
87
87
|
} else if (ann.type === 'COMMENT') {
|
|
88
|
-
|
|
88
|
+
const labelTag = ann.label ? ` [${ann.label.emoji} ${ann.label.text}]` : ''
|
|
89
|
+
output += `Comment on (${lineRef})${labelTag}\n`
|
|
89
90
|
output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`
|
|
90
91
|
output += `> ${(ann.text ?? '').replace(/\n/g, '\n> ')}\n`
|
|
91
92
|
} else if (ann.type === 'INSERTION') {
|