✨ handle history
This commit is contained in:
@@ -57,7 +57,6 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.annotation-tooltip-divider {
|
.annotation-tooltip-divider {
|
||||||
@@ -118,3 +117,26 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.annotation-tooltip-apply {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-tooltip-apply:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-base);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { EditorView, Decoration, type DecorationSet, hoverTooltip, keymap } from
|
|||||||
import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment } from '@codemirror/state'
|
import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment } from '@codemirror/state'
|
||||||
import { markdown } from '@codemirror/lang-markdown'
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
||||||
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
|
import { history, defaultKeymap, historyKeymap, invertedEffects } from '@codemirror/commands'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { useEditorStore } from '../../store/editorStore'
|
import { useEditorStore } from '../../store/editorStore'
|
||||||
@@ -24,8 +24,20 @@ const rawAnnotationsField = StateField.define<TextAnnotation[]>({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Per-session cache: annotation id → lazily generated analysis text
|
// Per-session cache: annotation id → { text, suggestion extracted from blockquote }
|
||||||
const tooltipAnalysisCache = new Map<string, string>()
|
const tooltipAnalysisCache = new Map<string, { text: string; suggestion: string | null }>()
|
||||||
|
|
||||||
|
// Extract the first blockquote from a markdown string — used as the applicable rewrite
|
||||||
|
function extractBlockquote(markdown: string): string | null {
|
||||||
|
const lines = markdown.split('\n')
|
||||||
|
const bqLines: string[] = []
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('> ')) bqLines.push(line.slice(2))
|
||||||
|
else if (line === '>') bqLines.push('')
|
||||||
|
}
|
||||||
|
const text = bqLines.join(' ').trim()
|
||||||
|
return text || null
|
||||||
|
}
|
||||||
|
|
||||||
// Hover tooltip — lazily streams a specific AI analysis for the hovered passage
|
// Hover tooltip — lazily streams a specific AI analysis for the hovered passage
|
||||||
const annotationHoverTooltip = hoverTooltip(
|
const annotationHoverTooltip = hoverTooltip(
|
||||||
@@ -62,9 +74,40 @@ const annotationHoverTooltip = hoverTooltip(
|
|||||||
body.innerHTML = DOMPurify.sanitize(raw)
|
body.innerHTML = DOMPurify.sanitize(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showApplyButton(suggestion: string): void {
|
||||||
|
const btnDivider = document.createElement('div')
|
||||||
|
btnDivider.className = 'annotation-tooltip-divider'
|
||||||
|
dom.appendChild(btnDivider)
|
||||||
|
|
||||||
|
const btn = document.createElement('button')
|
||||||
|
btn.className = 'annotation-tooltip-apply'
|
||||||
|
btn.textContent = 'Apply suggestion'
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const { annotations: anns, setAnnotations } = useEditorStore.getState()
|
||||||
|
const changeSpec = { from: ann.from, to: ann.to, insert: suggestion }
|
||||||
|
// Map surviving annotation positions through the text change so
|
||||||
|
// their from/to reflect the new document offsets.
|
||||||
|
const changeSet = view.state.changes(changeSpec)
|
||||||
|
const remaining = anns
|
||||||
|
.filter(a => a.id !== ann.id)
|
||||||
|
.map(a => ({ ...a, from: changeSet.mapPos(a.from), to: changeSet.mapPos(a.to) }))
|
||||||
|
// Combine text replacement + annotation update in one transaction
|
||||||
|
// so CM history treats them as a single undoable unit (Cmd+Z
|
||||||
|
// restores both the original text and the highlight together).
|
||||||
|
view.dispatch({
|
||||||
|
changes: changeSpec,
|
||||||
|
effects: setAnnotationsEffect.of(remaining)
|
||||||
|
})
|
||||||
|
setAnnotations(remaining)
|
||||||
|
tooltipAnalysisCache.delete(ann.id)
|
||||||
|
})
|
||||||
|
dom.appendChild(btn)
|
||||||
|
}
|
||||||
|
|
||||||
const cached = tooltipAnalysisCache.get(ann.id)
|
const cached = tooltipAnalysisCache.get(ann.id)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
showText(cached)
|
showText(cached.text)
|
||||||
|
if (cached.suggestion) showApplyButton(cached.suggestion)
|
||||||
} else {
|
} else {
|
||||||
body.classList.add('annotation-tooltip-loading')
|
body.classList.add('annotation-tooltip-loading')
|
||||||
body.innerHTML = 'Analysing…'
|
body.innerHTML = 'Analysing…'
|
||||||
@@ -81,7 +124,7 @@ const annotationHoverTooltip = hoverTooltip(
|
|||||||
documentContent: activeFileContent,
|
documentContent: activeFileContent,
|
||||||
documentPath: activeFilePath ?? '',
|
documentPath: activeFilePath ?? '',
|
||||||
conversationHistory: [],
|
conversationHistory: [],
|
||||||
userMessage: `This passage was flagged for ${typeName}: "${ann.matchedText}"\n\nIn 1–2 sentences explain specifically what the issue is in this exact passage, then give a direct rewrite of just this passage. Be concise and specific—no generic advice.`
|
userMessage: `This passage was flagged for ${typeName}: "${ann.matchedText}"\n\nIn 1–2 sentences explain the specific issue, then provide a direct rewrite in a markdown blockquote like this:\n\n> Rewritten passage here.\n\nBe specific to this exact text—no generic advice.`
|
||||||
},
|
},
|
||||||
(chunk: string) => {
|
(chunk: string) => {
|
||||||
if (destroyed) return
|
if (destroyed) return
|
||||||
@@ -90,14 +133,20 @@ const annotationHoverTooltip = hoverTooltip(
|
|||||||
}
|
}
|
||||||
).then(() => {
|
).then(() => {
|
||||||
if (destroyed) return
|
if (destroyed) return
|
||||||
const result = accumulated || ann.message
|
const text = accumulated || ann.message
|
||||||
tooltipAnalysisCache.set(ann.id, result)
|
const suggestion = extractBlockquote(text) ?? ann.suggestion ?? null
|
||||||
showText(result)
|
tooltipAnalysisCache.set(ann.id, { text, suggestion })
|
||||||
|
showText(text)
|
||||||
|
if (suggestion) showApplyButton(suggestion)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (!destroyed) showText(ann.message)
|
if (!destroyed) showText(ann.message)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
// Fallback: use stored message and suggestion
|
||||||
|
const suggestion = ann.suggestion ?? null
|
||||||
|
tooltipAnalysisCache.set(ann.id, { text: ann.message, suggestion })
|
||||||
showText(ann.message)
|
showText(ann.message)
|
||||||
|
if (suggestion) showApplyButton(suggestion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +189,18 @@ const annotationField = StateField.define<DecorationSet>({
|
|||||||
provide: (f) => EditorView.decorations.from(f)
|
provide: (f) => EditorView.decorations.from(f)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Teach CM's undo/redo history about annotation state so that Cmd+Z after
|
||||||
|
// an Apply also restores the highlight. For every transaction that carries a
|
||||||
|
// setAnnotationsEffect we record the *previous* annotation list as the
|
||||||
|
// inverse effect; CM history replays it on undo.
|
||||||
|
const annotationHistory = invertedEffects.of(tr => {
|
||||||
|
if (tr.effects.some(e => e.is(setAnnotationsEffect))) {
|
||||||
|
const before = tr.startState.field(rawAnnotationsField)
|
||||||
|
return [setAnnotationsEffect.of(before)]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
const themeCompartment = new Compartment()
|
const themeCompartment = new Compartment()
|
||||||
|
|
||||||
function buildTheme(fontSize: number, dark: boolean): ReturnType<typeof EditorView.theme> {
|
function buildTheme(fontSize: number, dark: boolean): ReturnType<typeof EditorView.theme> {
|
||||||
@@ -223,12 +284,26 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
rawAnnotationsField,
|
rawAnnotationsField,
|
||||||
annotationField,
|
annotationField,
|
||||||
annotationHoverTooltip,
|
annotationHoverTooltip,
|
||||||
|
annotationHistory,
|
||||||
themeCompartment.of(buildTheme(fontSize, theme === 'dark')),
|
themeCompartment.of(buildTheme(fontSize, theme === 'dark')),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
setContent(update.state.doc.toString())
|
setContent(update.state.doc.toString())
|
||||||
}
|
}
|
||||||
|
// When the user Cmd+Z's through an Apply, invertedEffects fires a
|
||||||
|
// setAnnotationsEffect restoring the old list inside CM. Sync that
|
||||||
|
// back to Zustand so the React decoration effect also reflects it.
|
||||||
|
const hasUndoRedo = update.transactions.some(
|
||||||
|
tr => tr.isUserEvent('undo') || tr.isUserEvent('redo')
|
||||||
|
)
|
||||||
|
if (hasUndoRedo) {
|
||||||
|
const prev = update.startState.field(rawAnnotationsField)
|
||||||
|
const curr = update.state.field(rawAnnotationsField)
|
||||||
|
if (prev !== curr) {
|
||||||
|
useEditorStore.getState().setAnnotations(curr)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
@@ -236,6 +311,16 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
})
|
})
|
||||||
|
|
||||||
viewRef.current = view
|
viewRef.current = view
|
||||||
|
// Expose view + undo for dev-mode testing (stripped in production)
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const w = window as unknown as Record<string, unknown>
|
||||||
|
w.__cmView = view
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
import('@codemirror/commands').then(({ undo, redo }) => {
|
||||||
|
w.__cmUndo = () => undo(view)
|
||||||
|
w.__cmRedo = () => redo(view)
|
||||||
|
})
|
||||||
|
}
|
||||||
return () => {
|
return () => {
|
||||||
view.destroy()
|
view.destroy()
|
||||||
viewRef.current = null
|
viewRef.current = null
|
||||||
|
|||||||
Reference in New Issue
Block a user