:lightning: sturdier highlight positions

This commit is contained in:
2026-03-02 12:16:15 +10:00
parent 384200a765
commit ad2c243849
2 changed files with 56 additions and 4 deletions

View File

@@ -9,6 +9,7 @@ import { marked } from 'marked'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { useEditorStore } from '../../store/editorStore' import { useEditorStore } from '../../store/editorStore'
import type { TextAnnotation } from '../../types/editor' import type { TextAnnotation } from '../../types/editor'
import { reanchorAnnotations } from '../../utils/annotationParser'
import { ContextMenu } from '../FileTree/ContextMenu' import { ContextMenu } from '../FileTree/ContextMenu'
import type { MenuItem } from '../FileTree/ContextMenu' import type { MenuItem } from '../FileTree/ContextMenu'
import './Editor.css' import './Editor.css'
@@ -531,16 +532,19 @@ export function MarkdownEditor(): JSX.Element {
}, [activeFilePath]) // Only sync on file switch }, [activeFilePath]) // Only sync on file switch
// Push annotation decorations into CodeMirror. // Push annotation decorations into CodeMirror.
// Merge the store's list (which annotations exist) with CM-tracked positions // Re-anchors positions against current doc content first so that stale
// (where they actually are after any edits), so that dismissing or adding an // from/to offsets (from session restore or external file edits) are corrected
// annotation doesn't reset surviving highlights to stale store positions. // 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 // Marked addToHistory.of(false) so this sync dispatch never creates an undo
// step — only Apply and tagged auto-dismissals should be undoable. // step — only Apply and tagged auto-dismissals should be undoable.
useEffect(() => { useEffect(() => {
const view = viewRef.current const view = viewRef.current
if (!view) return 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 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({ view.dispatch({
effects: setAnnotationsEffect.of(toDispatch), effects: setAnnotationsEffect.of(toDispatch),
annotations: [Transaction.addToHistory.of(false)] annotations: [Transaction.addToHistory.of(false)]

View File

@@ -99,6 +99,54 @@ function extractMessage(response: string, quoteIndex: number): string {
return last.length > 200 ? last.slice(0, 200) + '…' : last 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) // Find a normalized string in a document (ignores whitespace differences)
function findNormalized(document: string, normalized: string): number { function findNormalized(document: string, normalized: string): number {
const words = normalized.split(' ') const words = normalized.split(' ')