@fabrika/annotations 0.2.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 +82 -0
- package/index.ts +55 -0
- package/package.json +53 -0
- package/src/AnnotationDraftFooter.tsx +84 -0
- package/src/AnnotationEditor.tsx +393 -0
- package/src/AnnotationList.tsx +214 -0
- package/src/AnnotationProvider.tsx +62 -0
- package/src/Popover.tsx +496 -0
- package/src/annotationContext.ts +130 -0
- package/src/annotationDom.ts +42 -0
- package/src/annotationMarkdown.ts +102 -0
- package/src/annotationTarget.ts +73 -0
- package/src/blockTarget.ts +127 -0
- package/src/highlightRange.ts +262 -0
- package/src/popoverPosition.ts +46 -0
- package/src/regionTarget.ts +179 -0
- package/src/runtime.ts +86 -0
- package/src/styles.css +70 -0
- package/src/testing/dom.ts +74 -0
- package/src/textTarget.ts +31 -0
- package/src/useAnnotationState.ts +114 -0
- package/src/useRegionAnnotation.tsx +190 -0
- package/src/useTextAnnotation.tsx +208 -0
- package/src/utils.ts +33 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { formatSnippetInContext } from './annotationContext'
|
|
2
|
+
import { describeSnippet } from './annotationDom'
|
|
3
|
+
import type { AnnotationAttachment, AnnotationContext } from './runtime'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What rendering one annotation reads off it. Every `Annotation` satisfies this,
|
|
7
|
+
* and so can a record that is not one: how a single annotation reads as markdown
|
|
8
|
+
* is the half of feedback that belongs to no particular document format.
|
|
9
|
+
*/
|
|
10
|
+
export interface RenderableAnnotation {
|
|
11
|
+
snippet: string
|
|
12
|
+
note: string
|
|
13
|
+
/** Set when the annotation points into a file. With `context.lineStart` it
|
|
14
|
+
* switches the quote to numbered source lines. */
|
|
15
|
+
filePath?: string
|
|
16
|
+
context?: AnnotationContext
|
|
17
|
+
/** Attached images as bare paths/URLs — what the local editor writes. */
|
|
18
|
+
images?: string[]
|
|
19
|
+
/** Attached files carried on the wire — what a shared canvas sends back.
|
|
20
|
+
* A host may populate either field; both are rendered. */
|
|
21
|
+
attachments?: AnnotationAttachment[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One annotation as markdown: what was annotated, quoted, then the note and any
|
|
26
|
+
* attachments under it. Ends on a blank line so entries stack.
|
|
27
|
+
*
|
|
28
|
+
* The caller owns everything around it — headings, grouping, ordering.
|
|
29
|
+
*/
|
|
30
|
+
export function renderAnnotation(ann: RenderableAnnotation): string {
|
|
31
|
+
const lines: string[] = []
|
|
32
|
+
const snippet = ann.snippet.trim()
|
|
33
|
+
const ctx = ann.context
|
|
34
|
+
// A snippet that does not read as text — a drawn region — says what it points
|
|
35
|
+
// at in words instead, since quoting its encoding tells an agent nothing.
|
|
36
|
+
const described = describeSnippet(snippet)
|
|
37
|
+
|
|
38
|
+
if (described !== snippet) {
|
|
39
|
+
lines.push(`> ${described}`)
|
|
40
|
+
} else if (ann.filePath && ctx && ctx.lineStart != null) {
|
|
41
|
+
// File annotation with line numbers
|
|
42
|
+
const lineStart = ctx.lineStart
|
|
43
|
+
const lineEnd = ctx.lineEnd ?? lineStart
|
|
44
|
+
const snippetLines = snippet.split('\n')
|
|
45
|
+
const isShort = snippet.length < 30 && lineStart === lineEnd
|
|
46
|
+
|
|
47
|
+
if (isShort && (ctx.before || ctx.after)) {
|
|
48
|
+
// Short snippet on single line — show full line context
|
|
49
|
+
const expanded = formatSnippetInContext(ann)
|
|
50
|
+
lines.push(`> L${lineStart}: ${expanded}`)
|
|
51
|
+
} else if (snippetLines.length <= 6) {
|
|
52
|
+
for (let i = 0; i < snippetLines.length; i++) {
|
|
53
|
+
lines.push(`> ${lineStart + i} | ${snippetLines[i]}`)
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
for (let i = 0; i < 3; i++) {
|
|
57
|
+
lines.push(`> ${lineStart + i} | ${snippetLines[i]}`)
|
|
58
|
+
}
|
|
59
|
+
lines.push(`> ... (${snippetLines.length} lines)`)
|
|
60
|
+
for (let i = snippetLines.length - 3; i < snippetLines.length; i++) {
|
|
61
|
+
lines.push(`> ${lineStart + i} | ${snippetLines[i]}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
// Plan annotations or file annotations without line info
|
|
66
|
+
const context = formatSnippetInContext(ann)
|
|
67
|
+
if (snippet.split('\n').length <= 3) {
|
|
68
|
+
lines.push(`> ${context.split('\n').join('\n> ')}`)
|
|
69
|
+
} else {
|
|
70
|
+
const snippetLines = snippet.split('\n')
|
|
71
|
+
lines.push(`> ${snippetLines[0]}`)
|
|
72
|
+
lines.push(`> ... (${snippetLines.length} lines)`)
|
|
73
|
+
lines.push(`> ${snippetLines[snippetLines.length - 1]}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Comment
|
|
78
|
+
if (ann.note.trim()) {
|
|
79
|
+
lines.push('')
|
|
80
|
+
lines.push(ann.note.trim())
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Attached images. Both fields are read: the local editor writes `images`,
|
|
84
|
+
// a shared canvas's reviewer comes back with `attachments`, and an agent that
|
|
85
|
+
// sees only one of them is looking at feedback with a hole in it.
|
|
86
|
+
const attached = [...(ann.images ?? [])]
|
|
87
|
+
const seen = new Set(attached)
|
|
88
|
+
for (const attachment of ann.attachments ?? []) {
|
|
89
|
+
if (seen.has(attachment.url)) continue
|
|
90
|
+
seen.add(attachment.url)
|
|
91
|
+
attached.push(attachment.url)
|
|
92
|
+
}
|
|
93
|
+
if (attached.length) {
|
|
94
|
+
lines.push('')
|
|
95
|
+
for (const url of attached) {
|
|
96
|
+
lines.push(``)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
lines.push('')
|
|
101
|
+
return lines.join('\n')
|
|
102
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a snippet names something on the page.
|
|
3
|
+
*
|
|
4
|
+
* `Annotation.snippet` is the only field that ties an annotation to a domain —
|
|
5
|
+
* everything else (draft persistence, sidebar, popover, notes, attachments,
|
|
6
|
+
* markdown, submit) works on any annotation whatever it points at. A target is
|
|
7
|
+
* that one tie, pulled out as a strategy: give the kernel a new target and it
|
|
8
|
+
* can annotate a new kind of thing without another line changing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** What a target may read off the annotation it resolves — DOM identity, not feedback. */
|
|
12
|
+
export interface TargetAnnotation {
|
|
13
|
+
id: string
|
|
14
|
+
snippet: string
|
|
15
|
+
context?: { before: string; after: string; hierarchy: string[] }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AnnotationTarget<L extends object> {
|
|
19
|
+
/** Names the strategy. */
|
|
20
|
+
readonly kind: string
|
|
21
|
+
/** Read a snippet this target wrote. Null means the snippet is not its own. */
|
|
22
|
+
parse(snippet: string): L | null
|
|
23
|
+
/** Write the locator back into the snippet key. The exact inverse of `parse`. */
|
|
24
|
+
format(locator: L): string
|
|
25
|
+
/**
|
|
26
|
+
* The element the locator names, searched inside `root`. Targets whose
|
|
27
|
+
* annotation lives in DOM this target itself drew — an inline mark, an
|
|
28
|
+
* overlay box — return null here and are resolved by their annotation id
|
|
29
|
+
* instead; see findAnnotationElement.
|
|
30
|
+
*/
|
|
31
|
+
find(locator: L, root: ParentNode): HTMLElement | null
|
|
32
|
+
/**
|
|
33
|
+
* Put this annotation's own decoration back into `root`, if it is not there
|
|
34
|
+
* already. Called after every render, for every annotation, so it must be
|
|
35
|
+
* idempotent and cheap on a snippet it does not own.
|
|
36
|
+
*/
|
|
37
|
+
restore?(locator: L, root: HTMLElement, ann: TargetAnnotation): void
|
|
38
|
+
/** A one-line label a reader (or a coding agent) can act on. */
|
|
39
|
+
describe?(locator: L): string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** An AnnotationTarget with its locator type sealed inside, so targets whose
|
|
43
|
+
* locators have nothing in common can share one list. */
|
|
44
|
+
export interface SealedAnnotationTarget {
|
|
45
|
+
readonly kind: string
|
|
46
|
+
owns(snippet: string): boolean
|
|
47
|
+
find(snippet: string, root: ParentNode): HTMLElement | null
|
|
48
|
+
restore(root: HTMLElement, ann: TargetAnnotation): void
|
|
49
|
+
describe(snippet: string): string | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Close over the locator type, so parse and find can never be called with
|
|
53
|
+
* a locator the other one did not produce. */
|
|
54
|
+
export function sealTarget<L extends object>(target: AnnotationTarget<L>): SealedAnnotationTarget {
|
|
55
|
+
return {
|
|
56
|
+
kind: target.kind,
|
|
57
|
+
owns: (snippet) => target.parse(snippet) !== null,
|
|
58
|
+
find: (snippet, root) => {
|
|
59
|
+
const locator = target.parse(snippet)
|
|
60
|
+
return locator === null ? null : target.find(locator, root)
|
|
61
|
+
},
|
|
62
|
+
restore: (root, ann) => {
|
|
63
|
+
const locator = target.parse(ann.snippet)
|
|
64
|
+
if (locator === null) return
|
|
65
|
+
target.restore?.(locator, root, ann)
|
|
66
|
+
},
|
|
67
|
+
describe: (snippet) => {
|
|
68
|
+
const locator = target.parse(snippet)
|
|
69
|
+
if (locator === null || !target.describe) return null
|
|
70
|
+
return target.describe(locator)
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { AnnotationTarget } from './annotationTarget'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A whole canvas block, identified by the text a reader sees in it.
|
|
5
|
+
*
|
|
6
|
+
* The hard-won invariant lives here: the selector that decides where an
|
|
7
|
+
* annotation may be minted and the selector the lookup scans must be one
|
|
8
|
+
* selector. An annotation minted on a block the lookup skips can never be
|
|
9
|
+
* resolved again. Both now come off one table, so they cannot drift: a kind is
|
|
10
|
+
* annotatable exactly when it can mint a snippet, and `getBlockSnippet` refuses
|
|
11
|
+
* every block outside that set.
|
|
12
|
+
*/
|
|
13
|
+
interface BlockKind {
|
|
14
|
+
/** CSS matching this kind, as the component emits it. */
|
|
15
|
+
selector: string
|
|
16
|
+
/** The key a block of this kind is annotated under, or null when it is
|
|
17
|
+
* missing the attribute that identifies it. */
|
|
18
|
+
snippet(block: HTMLElement): string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A block's text as a reader sees it. Decorative markup — the callout icon,
|
|
23
|
+
* anything else marked aria-hidden — is skipped, so the glyph does not ride
|
|
24
|
+
* along into the sidebar label and into feedback.md.
|
|
25
|
+
*/
|
|
26
|
+
function readableText(block: HTMLElement): string {
|
|
27
|
+
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT)
|
|
28
|
+
let text = ''
|
|
29
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
30
|
+
const hidden = node.parentElement?.closest("[aria-hidden='true']")
|
|
31
|
+
if (hidden && block.contains(hidden)) continue
|
|
32
|
+
text += node.textContent ?? ''
|
|
33
|
+
}
|
|
34
|
+
return text.trim()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A block named by one of its attributes, e.g. `[Item] Ship the beta`. */
|
|
38
|
+
function byAttribute(selector: string, prefix: string, attribute: string): BlockKind {
|
|
39
|
+
return {
|
|
40
|
+
selector,
|
|
41
|
+
snippet: (block) => {
|
|
42
|
+
const value = block.getAttribute(attribute)
|
|
43
|
+
return value ? `${prefix} ${value}` : null
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Every block an annotation can be minted on. */
|
|
49
|
+
const ANNOTATABLE_KINDS: BlockKind[] = [
|
|
50
|
+
byAttribute("[data-md='item']", '[Item]', 'data-md-label'),
|
|
51
|
+
byAttribute("[data-md='section']", '[Section]', 'data-md-title'),
|
|
52
|
+
byAttribute("[data-md='checklist-item']", '[Checklist]', 'data-md-label'),
|
|
53
|
+
byAttribute("[data-md='image']", '[Image]', 'data-md-src'),
|
|
54
|
+
{
|
|
55
|
+
selector: "[data-md='callout']",
|
|
56
|
+
snippet: (block) => {
|
|
57
|
+
const type = block.getAttribute('data-md-type') || 'info'
|
|
58
|
+
return `[Callout:${type}] ${readableText(block).slice(0, 60) || 'Callout'}`
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
selector: "[data-md='note']",
|
|
63
|
+
snippet: (block) => `[Note] ${readableText(block).slice(0, 60) || 'Note'}`,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
// Only rows of a table the canvas emitted: a table inside a markdown block
|
|
67
|
+
// is not navigable, so a row annotated there could never be reached again.
|
|
68
|
+
selector: "[data-md='table'] tbody tr",
|
|
69
|
+
snippet: (block) => {
|
|
70
|
+
const cells = Array.from(block.querySelectorAll('td')).map((td) => td.textContent?.trim()).filter(Boolean)
|
|
71
|
+
return cells.length ? `[Row] ${cells.join(' | ')}` : null
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
/** Blocks the arrows walk but no annotation can land on — interactive controls
|
|
77
|
+
* carry the reader's answer, not the author's text. */
|
|
78
|
+
const NAVIGABLE_ONLY_SELECTORS = [
|
|
79
|
+
"[data-md='choice-option']",
|
|
80
|
+
"[data-md='multichoice-option']",
|
|
81
|
+
"[data-md='userinput']",
|
|
82
|
+
"[data-md='rangeinput']",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The blocks that can carry a block annotation — a strict subset of
|
|
87
|
+
* BLOCK_SELECTOR, since interactive controls are navigable but not annotatable.
|
|
88
|
+
*/
|
|
89
|
+
export const ANNOTATABLE_SELECTOR = ANNOTATABLE_KINDS.map((kind) => kind.selector).join(', ')
|
|
90
|
+
|
|
91
|
+
/** Every block the keyboard arrows walk through. */
|
|
92
|
+
export const BLOCK_SELECTOR = [
|
|
93
|
+
...ANNOTATABLE_KINDS.map((kind) => kind.selector),
|
|
94
|
+
...NAVIGABLE_ONLY_SELECTORS,
|
|
95
|
+
].join(', ')
|
|
96
|
+
|
|
97
|
+
/** The snippet identifier for a block, or null if nothing can be annotated here. */
|
|
98
|
+
export function getBlockSnippet(block: HTMLElement): string | null {
|
|
99
|
+
for (const kind of ANNOTATABLE_KINDS) {
|
|
100
|
+
if (block.matches(kind.selector)) return kind.snippet(block)
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A block is its own locator: the snippet is the text that identifies it. */
|
|
106
|
+
export interface BlockLocator {
|
|
107
|
+
snippet: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const blockTarget: AnnotationTarget<BlockLocator> = {
|
|
111
|
+
kind: 'block',
|
|
112
|
+
|
|
113
|
+
// Every block snippet opens with its kind in brackets. Text that merely looks
|
|
114
|
+
// like one is not stranded by claiming it: `find` compares against snippets
|
|
115
|
+
// blocks actually mint, so a lookalike simply matches nothing.
|
|
116
|
+
parse: (snippet) => (snippet.startsWith('[') ? { snippet } : null),
|
|
117
|
+
|
|
118
|
+
format: (locator) => locator.snippet,
|
|
119
|
+
|
|
120
|
+
find: (locator, root) => {
|
|
121
|
+
for (const el of root.querySelectorAll(ANNOTATABLE_SELECTOR)) {
|
|
122
|
+
if (!(el instanceof HTMLElement)) continue
|
|
123
|
+
if (getBlockSnippet(el) === locator.snippet) return el
|
|
124
|
+
}
|
|
125
|
+
return null
|
|
126
|
+
},
|
|
127
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps the current browser Range in <mark> elements with the given annotation ID.
|
|
3
|
+
* Works for multiline / cross-element selections.
|
|
4
|
+
*/
|
|
5
|
+
export function wrapRangeWithMark(range: Range, annotationId: string): HTMLElement[] {
|
|
6
|
+
const marks: HTMLElement[] = []
|
|
7
|
+
|
|
8
|
+
// Collect all text nodes within the range
|
|
9
|
+
const textNodes = getTextNodesInRange(range)
|
|
10
|
+
|
|
11
|
+
for (const tn of textNodes) {
|
|
12
|
+
const nodeRange = document.createRange()
|
|
13
|
+
nodeRange.selectNodeContents(tn)
|
|
14
|
+
|
|
15
|
+
// Clamp to the actual selection range
|
|
16
|
+
if (tn === range.startContainer) {
|
|
17
|
+
nodeRange.setStart(tn, range.startOffset)
|
|
18
|
+
}
|
|
19
|
+
if (tn === range.endContainer) {
|
|
20
|
+
nodeRange.setEnd(tn, range.endOffset)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const text = nodeRange.toString()
|
|
24
|
+
if (!text || !text.trim()) continue
|
|
25
|
+
|
|
26
|
+
// Wrap this portion in a <mark>
|
|
27
|
+
const mark = document.createElement('mark')
|
|
28
|
+
mark.setAttribute('data-annotation-id', annotationId)
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
nodeRange.surroundContents(mark)
|
|
32
|
+
} catch {
|
|
33
|
+
// surroundContents fails if range crosses element boundaries within a single text node
|
|
34
|
+
// (shouldn't happen for a single text node, but handle gracefully)
|
|
35
|
+
const fragment = nodeRange.extractContents()
|
|
36
|
+
mark.appendChild(fragment)
|
|
37
|
+
nodeRange.insertNode(mark)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
marks.push(mark)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return marks
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether the annotation index carries this text. Line-number gutters and text
|
|
48
|
+
* that already belongs to another annotation are outside it. Everything that
|
|
49
|
+
* records a snippet or its context is matched back against that index later, so
|
|
50
|
+
* all of them have to skip exactly the same runs.
|
|
51
|
+
*/
|
|
52
|
+
export function isIndexedText(node: Text): boolean {
|
|
53
|
+
const parent = node.parentElement
|
|
54
|
+
if (parent?.classList.contains('select-none')) return false
|
|
55
|
+
if (parent?.closest('[data-annotation-id]')) return false
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getTextNodesInRange(range: Range): Text[] {
|
|
60
|
+
const nodes: Text[] = []
|
|
61
|
+
const ancestor = range.commonAncestorContainer
|
|
62
|
+
|
|
63
|
+
if (ancestor.nodeType === Node.TEXT_NODE) {
|
|
64
|
+
nodes.push(ancestor as Text)
|
|
65
|
+
return nodes
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const walker = document.createTreeWalker(ancestor, NodeFilter.SHOW_TEXT)
|
|
69
|
+
let node: Text | null
|
|
70
|
+
while ((node = walker.nextNode() as Text | null)) {
|
|
71
|
+
if (!isIndexedText(node)) continue
|
|
72
|
+
if (range.intersectsNode(node)) {
|
|
73
|
+
nodes.push(node)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return nodes
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The part of a text node that falls inside the range. */
|
|
81
|
+
function clampToRange(node: Text, range: Range): string {
|
|
82
|
+
const nodeRange = document.createRange()
|
|
83
|
+
nodeRange.selectNodeContents(node)
|
|
84
|
+
if (node === range.startContainer) nodeRange.setStart(node, range.startOffset)
|
|
85
|
+
if (node === range.endContainer) nodeRange.setEnd(node, range.endOffset)
|
|
86
|
+
return nodeRange.toString()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A selection's text as the annotation index will read it back. The raw
|
|
91
|
+
* selection is not that: it carries the gutter and any text already inside
|
|
92
|
+
* another annotation, none of which the index holds — so a snippet taken from
|
|
93
|
+
* it names something that cannot be found again.
|
|
94
|
+
*/
|
|
95
|
+
export function rangeIndexText(range: Range): string {
|
|
96
|
+
let text = ''
|
|
97
|
+
for (const node of getTextNodesInRange(range)) {
|
|
98
|
+
text += clampToRange(node, range)
|
|
99
|
+
}
|
|
100
|
+
return text
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Update the visual state of marks for a given annotation ID.
|
|
105
|
+
*/
|
|
106
|
+
export function setMarkActive(annotationId: string, active: boolean) {
|
|
107
|
+
const marks = document.querySelectorAll(`[data-annotation-id="${annotationId}"]`)
|
|
108
|
+
for (const mark of marks) {
|
|
109
|
+
;(mark as HTMLElement).classList.toggle('mark-active', active)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Update active state for annotation marks.
|
|
115
|
+
* Only touches marks for the previous and current active IDs to avoid full DOM scans.
|
|
116
|
+
*/
|
|
117
|
+
export function updateAllMarkStates(activeId: string | null, prevActiveId?: string | null) {
|
|
118
|
+
if (prevActiveId != null && prevActiveId !== activeId) {
|
|
119
|
+
setMarkActive(prevActiveId, false)
|
|
120
|
+
}
|
|
121
|
+
if (activeId != null) {
|
|
122
|
+
setMarkActive(activeId, true)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Rename annotation ID on existing marks (e.g. temp → real ID).
|
|
128
|
+
*/
|
|
129
|
+
export function renameMarkId(oldId: string, newId: string) {
|
|
130
|
+
const marks = document.querySelectorAll(`[data-annotation-id="${oldId}"]`)
|
|
131
|
+
for (const mark of marks) {
|
|
132
|
+
mark.setAttribute('data-annotation-id', newId)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Restore marks for persisted annotations by finding their snippet text in the DOM.
|
|
138
|
+
* Uses before/after context for disambiguation when the same text appears multiple times.
|
|
139
|
+
*/
|
|
140
|
+
export function restoreMarks(
|
|
141
|
+
container: HTMLElement,
|
|
142
|
+
annotations: { id: string; snippet: string; filePath?: string; context?: { before: string; after: string; hierarchy: string[] } }[],
|
|
143
|
+
) {
|
|
144
|
+
for (const ann of annotations) {
|
|
145
|
+
// A canvas can be rendered more than once (overview, compare, hidden
|
|
146
|
+
// source panes), so restore decisions must be scoped to this container.
|
|
147
|
+
if (container.querySelector(`[data-annotation-id="${ann.id}"]`)) continue
|
|
148
|
+
|
|
149
|
+
const snippet = ann.snippet
|
|
150
|
+
if (!snippet) continue
|
|
151
|
+
|
|
152
|
+
// Rebuild after every inserted mark. Wrapping text splits and moves text
|
|
153
|
+
// nodes, so a cached offset map can point at stale DOM after the first hit.
|
|
154
|
+
const { fullText, textNodes } = buildTextIndex(container)
|
|
155
|
+
|
|
156
|
+
// Find all occurrences of the snippet in the full text
|
|
157
|
+
const occurrences: number[] = []
|
|
158
|
+
let searchFrom = 0
|
|
159
|
+
while (true) {
|
|
160
|
+
const idx = fullText.indexOf(snippet, searchFrom)
|
|
161
|
+
if (idx === -1) break
|
|
162
|
+
occurrences.push(idx)
|
|
163
|
+
searchFrom = idx + 1
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const [firstOccurrence] = occurrences
|
|
167
|
+
if (firstOccurrence === undefined) continue
|
|
168
|
+
|
|
169
|
+
// Pick the best occurrence using context
|
|
170
|
+
let bestIdx = firstOccurrence
|
|
171
|
+
if (occurrences.length > 1 && ann.context) {
|
|
172
|
+
let bestScore = -1
|
|
173
|
+
for (const idx of occurrences) {
|
|
174
|
+
let score = 0
|
|
175
|
+
if (ann.context.before) {
|
|
176
|
+
const preceding = fullText.slice(Math.max(0, idx - 80), idx)
|
|
177
|
+
if (preceding.includes(ann.context.before)) score += 2
|
|
178
|
+
else if (ann.context.before.length > 10 && preceding.includes(ann.context.before.slice(-10))) score += 1
|
|
179
|
+
}
|
|
180
|
+
if (ann.context.after) {
|
|
181
|
+
const following = fullText.slice(idx + snippet.length, idx + snippet.length + 80)
|
|
182
|
+
if (following.includes(ann.context.after)) score += 2
|
|
183
|
+
else if (ann.context.after.length > 10 && following.includes(ann.context.after.slice(0, 10))) score += 1
|
|
184
|
+
}
|
|
185
|
+
if (score > bestScore) {
|
|
186
|
+
bestScore = score
|
|
187
|
+
bestIdx = idx
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Map text offsets to DOM positions
|
|
193
|
+
const startOffset = bestIdx
|
|
194
|
+
const endOffset = bestIdx + snippet.length
|
|
195
|
+
|
|
196
|
+
const startPos = offsetToNode(textNodes, startOffset)
|
|
197
|
+
const endPos = offsetToNode(textNodes, endOffset)
|
|
198
|
+
if (!startPos || !endPos) continue
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const range = document.createRange()
|
|
202
|
+
range.setStart(startPos.node, startPos.offset)
|
|
203
|
+
range.setEnd(endPos.node, endPos.offset)
|
|
204
|
+
wrapRangeWithMark(range, ann.id)
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function buildTextIndex(container: HTMLElement): {
|
|
210
|
+
fullText: string
|
|
211
|
+
textNodes: { node: Text; start: number }[]
|
|
212
|
+
} {
|
|
213
|
+
const textNodes: { node: Text; start: number }[] = []
|
|
214
|
+
let fullText = ''
|
|
215
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT)
|
|
216
|
+
let tn: Text | null
|
|
217
|
+
while ((tn = walker.nextNode() as Text | null)) {
|
|
218
|
+
if (!isIndexedText(tn)) continue
|
|
219
|
+
textNodes.push({ node: tn, start: fullText.length })
|
|
220
|
+
fullText += tn.textContent || ''
|
|
221
|
+
}
|
|
222
|
+
return { fullText, textNodes }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function offsetToNode(
|
|
226
|
+
textNodes: { node: Text; start: number }[],
|
|
227
|
+
offset: number,
|
|
228
|
+
): { node: Text; offset: number } | null {
|
|
229
|
+
for (let i = textNodes.length - 1; i >= 0; i--) {
|
|
230
|
+
const entry = textNodes[i]
|
|
231
|
+
if (!entry || entry.start > offset) continue
|
|
232
|
+
const localOffset = offset - entry.start
|
|
233
|
+
const nodeLen = entry.node.textContent?.length ?? 0
|
|
234
|
+
if (localOffset <= nodeLen) {
|
|
235
|
+
return { node: entry.node, offset: localOffset }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Remove the decoration for a given annotation ID, restoring the original text
|
|
243
|
+
* nodes an inline mark wrapped.
|
|
244
|
+
*/
|
|
245
|
+
export function unwrapMarks(annotationId: string) {
|
|
246
|
+
const marks = document.querySelectorAll(`[data-annotation-id="${annotationId}"]`)
|
|
247
|
+
for (const mark of marks) {
|
|
248
|
+
const parent = mark.parentNode
|
|
249
|
+
if (!parent) continue
|
|
250
|
+
// Decoration that is not an inline mark — a region overlay — wraps no text
|
|
251
|
+
// and is simply taken out.
|
|
252
|
+
if (mark.tagName !== 'MARK') {
|
|
253
|
+
parent.removeChild(mark)
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
while (mark.firstChild) {
|
|
257
|
+
parent.insertBefore(mark.firstChild, mark)
|
|
258
|
+
}
|
|
259
|
+
parent.removeChild(mark)
|
|
260
|
+
parent.normalize()
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Width assumed when the caller does not say — the narrow popover. */
|
|
2
|
+
const DEFAULT_POPOVER_WIDTH = 280
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read a CSS width the popover will be styled with. Only px is a width this
|
|
6
|
+
* placement can reason about; anything else falls back to the default rather
|
|
7
|
+
* than clamping against a number that means something different.
|
|
8
|
+
*/
|
|
9
|
+
export function pxWidth(css: string): number | undefined {
|
|
10
|
+
const match = /^(\d+(?:\.\d+)?)px$/.exec(css.trim())
|
|
11
|
+
return match ? Number(match[1]) : undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Calculate popover position relative to an anchor element.
|
|
16
|
+
* Returns absolute position within the scroll container (or body).
|
|
17
|
+
*/
|
|
18
|
+
export function getPopoverPosition(
|
|
19
|
+
anchor: HTMLElement,
|
|
20
|
+
scrollContainer?: HTMLElement | null,
|
|
21
|
+
popoverWidth: number = DEFAULT_POPOVER_WIDTH,
|
|
22
|
+
): { style: Record<string, string>; parent: HTMLElement } {
|
|
23
|
+
const gap = 8
|
|
24
|
+
const parent = scrollContainer || document.body
|
|
25
|
+
|
|
26
|
+
// Use getBoundingClientRect for both anchor and parent,
|
|
27
|
+
// then add scroll offsets to get absolute position within parent
|
|
28
|
+
const anchorRect = anchor.getBoundingClientRect()
|
|
29
|
+
const parentRect = parent.getBoundingClientRect()
|
|
30
|
+
|
|
31
|
+
// Both rects are viewport-relative, so the subtraction has already cancelled
|
|
32
|
+
// page scroll. Only a scrolling parent adds to that — the body is not what
|
|
33
|
+
// scrolls a document, and counting window.scrollY here counted it twice.
|
|
34
|
+
const scrollTop = parent === document.body ? 0 : parent.scrollTop
|
|
35
|
+
const top = anchorRect.bottom - parentRect.top + scrollTop + gap
|
|
36
|
+
|
|
37
|
+
// Horizontal: clamp to parent width
|
|
38
|
+
const left = Math.max(0, Math.min(anchorRect.left - parentRect.left, parentRect.width - popoverWidth - 12))
|
|
39
|
+
|
|
40
|
+
parent.style.position = 'relative'
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
style: { position: 'absolute', top: `${top}px`, left: `${left}px` },
|
|
44
|
+
parent,
|
|
45
|
+
}
|
|
46
|
+
}
|