:lightning: sturdier highlight positions
This commit is contained in:
@@ -9,6 +9,7 @@ import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import type { TextAnnotation } from '../../types/editor'
|
||||
import { reanchorAnnotations } from '../../utils/annotationParser'
|
||||
import { ContextMenu } from '../FileTree/ContextMenu'
|
||||
import type { MenuItem } from '../FileTree/ContextMenu'
|
||||
import './Editor.css'
|
||||
@@ -531,16 +532,19 @@ export function MarkdownEditor(): JSX.Element {
|
||||
}, [activeFilePath]) // Only sync on file switch
|
||||
|
||||
// Push annotation decorations into CodeMirror.
|
||||
// Merge the store's list (which annotations exist) with CM-tracked positions
|
||||
// (where they actually are after any edits), so that dismissing or adding an
|
||||
// annotation doesn't reset surviving highlights to stale store positions.
|
||||
// Re-anchors positions against current doc content first so that stale
|
||||
// from/to offsets (from session restore or external file edits) are corrected
|
||||
// before they reach CM. Then merges with CM-tracked positions so that live
|
||||
// mapPos updates are never overwritten for already-tracked annotations.
|
||||
// Marked addToHistory.of(false) so this sync dispatch never creates an undo
|
||||
// step — only Apply and tagged auto-dismissals should be undoable.
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
const content = view.state.doc.toString()
|
||||
const reanchored = reanchorAnnotations(annotations, content)
|
||||
const trackedById = new Map(view.state.field(rawAnnotationsField).map(a => [a.id, a]))
|
||||
const toDispatch = annotations.map(a => trackedById.get(a.id) ?? a)
|
||||
const toDispatch = reanchored.map(a => trackedById.get(a.id) ?? a)
|
||||
view.dispatch({
|
||||
effects: setAnnotationsEffect.of(toDispatch),
|
||||
annotations: [Transaction.addToHistory.of(false)]
|
||||
|
||||
@@ -99,6 +99,54 @@ function extractMessage(response: string, quoteIndex: number): string {
|
||||
return last.length > 200 ? last.slice(0, 200) + '…' : last
|
||||
}
|
||||
|
||||
// Re-anchor a list of annotations against the current document content.
|
||||
// Validates each annotation's from/to against its matchedText and re-searches
|
||||
// when they disagree (e.g. after session restore with externally modified files).
|
||||
// Annotations whose matchedText can no longer be found are dropped.
|
||||
export function reanchorAnnotations(
|
||||
annotations: TextAnnotation[],
|
||||
content: string
|
||||
): TextAnnotation[] {
|
||||
return annotations.flatMap(ann => {
|
||||
// Fast path: stored position still matches the original text exactly (O(1))
|
||||
if (
|
||||
ann.from >= 0 &&
|
||||
ann.to <= content.length &&
|
||||
ann.from < ann.to &&
|
||||
content.slice(ann.from, ann.to) === ann.matchedText
|
||||
) {
|
||||
return [ann]
|
||||
}
|
||||
// Re-search using hint, full doc scan, then fuzzy fallback
|
||||
const newFrom = findMatchedTextNear(content, ann.matchedText, ann.from)
|
||||
if (newFrom === -1) return []
|
||||
return [{ ...ann, from: newFrom, to: newFrom + ann.matchedText.length }]
|
||||
})
|
||||
}
|
||||
|
||||
function findMatchedTextNear(content: string, text: string, hint: number): number {
|
||||
// 1. Near-hint window (±500 chars) — handles minor external edits cheaply
|
||||
const windowStart = Math.max(0, hint - 500)
|
||||
const localIdx = content.indexOf(text, windowStart)
|
||||
if (localIdx !== -1 && localIdx <= hint + text.length + 500) return localIdx
|
||||
|
||||
// 2. Full document — find occurrence closest to the stored hint
|
||||
let bestIdx = -1
|
||||
let bestDist = Infinity
|
||||
let searchFrom = 0
|
||||
while (true) {
|
||||
const idx = content.indexOf(text, searchFrom)
|
||||
if (idx === -1) break
|
||||
const dist = Math.abs(idx - hint)
|
||||
if (dist < bestDist) { bestDist = dist; bestIdx = idx }
|
||||
searchFrom = idx + 1
|
||||
}
|
||||
if (bestIdx !== -1) return bestIdx
|
||||
|
||||
// 3. Normalized whitespace fallback
|
||||
return findNormalized(content, text.replace(/\s+/g, ' '))
|
||||
}
|
||||
|
||||
// Find a normalized string in a document (ignores whitespace differences)
|
||||
function findNormalized(document: string, normalized: string): number {
|
||||
const words = normalized.split(' ')
|
||||
|
||||
Reference in New Issue
Block a user