From ad2c2438491d63a009d14824d585850cb7f4d3ce Mon Sep 17 00:00:00 2001 From: Alex Hernandez Date: Mon, 2 Mar 2026 12:16:15 +1000 Subject: [PATCH] :lightning: sturdier highlight positions --- .../components/Editor/MarkdownEditor.tsx | 12 +++-- src/renderer/utils/annotationParser.ts | 48 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/Editor/MarkdownEditor.tsx b/src/renderer/components/Editor/MarkdownEditor.tsx index 409b1f5..8a7c974 100644 --- a/src/renderer/components/Editor/MarkdownEditor.tsx +++ b/src/renderer/components/Editor/MarkdownEditor.tsx @@ -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)] diff --git a/src/renderer/utils/annotationParser.ts b/src/renderer/utils/annotationParser.ts index 629ff34..db2af4a 100644 --- a/src/renderer/utils/annotationParser.ts +++ b/src/renderer/utils/annotationParser.ts @@ -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(' ')