:lightning: sturdier highlight positions
This commit is contained in:
@@ -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)]
|
||||||
|
|||||||
@@ -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(' ')
|
||||||
|
|||||||
Reference in New Issue
Block a user