import { useEffect, useRef, useState } from 'react' import { EditorView, Decoration, type DecorationSet, hoverTooltip, keymap, ViewPlugin, WidgetType, type ViewUpdate, dropCursor, drawSelection } from '@codemirror/view' import { EditorState, EditorSelection, StateField, StateEffect, Annotation, RangeSetBuilder, Compartment, Transaction } from '@codemirror/state' import { markdown } from '@codemirror/lang-markdown' import { syntaxHighlighting, HighlightStyle, syntaxTree, indentUnit } from '@codemirror/language' import { tags } from '@lezer/highlight' import { history, defaultKeymap, historyKeymap, invertedEffects, selectAll, indentLess } from '@codemirror/commands' import { search, searchKeymap, openSearchPanel } from '@codemirror/search' 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' // StateEffect to push new annotations into the editor export const setAnnotationsEffect = StateEffect.define() // Annotation tag that marks an auto-dismiss transaction so annotationHistory // records its inverse, making the dismissal undoable with Cmd+Z. const userDismissAnnotation = Annotation.define() // StateField stores the raw annotation array for hover lookup. // Positions are mapped through every document change so the field always // reflects where annotations actually are in the current document. // Zero-width entries (from === to after a deletion) are intentionally kept: // if the user cuts text and pastes it back at the same spot, the position // expands and the decoration reappears automatically. const rawAnnotationsField = StateField.define({ create: () => [], update(annotations, tr) { for (const effect of tr.effects) { if (effect.is(setAnnotationsEffect)) return effect.value } if (tr.docChanged && annotations.length > 0) { const oldLen = tr.changes.length return annotations.map(a => { if (a.from === undefined || a.to === undefined) return a return { ...a, from: tr.changes.mapPos(Math.min(a.from, oldLen), -1), to: tr.changes.mapPos(Math.min(a.to, oldLen), 1) } }) } return annotations } }) // Per-session cache: annotation id → { text, suggestion extracted from blockquote } export const tooltipAnalysisCache = new Map() // Debounced auto-dismissal when the user edits text inside a highlighted range. // Exported so FeedbackPanel can cancel a pending timer on manual dismiss. const EDIT_DISMISS_MS = 1200 const dismissTimers = new Map>() export function cancelPendingDismiss(id: string): void { const t = dismissTimers.get(id) if (t !== undefined) { clearTimeout(t); dismissTimers.delete(id) } } function schedulePendingDismiss(id: string): void { const existing = dismissTimers.get(id) if (existing !== undefined) clearTimeout(existing) dismissTimers.set(id, setTimeout(() => { dismissTimers.delete(id) tooltipAnalysisCache.delete(id) const view = currentEditorView if (!view) { // No editor mounted — fall back to direct store update (not undoable) useEditorStore.getState().removeAnnotation(id) return } // Dispatch a tagged CM transaction so annotationHistory records the inverse // and the dismissal can be undone with Cmd+Z. const remaining = view.state.field(rawAnnotationsField).filter(a => a.id !== id) view.dispatch({ effects: setAnnotationsEffect.of(remaining), annotations: [userDismissAnnotation.of(true)] }) // Store sync is handled by the updateListener detecting userDismissAnnotation. }, EDIT_DISMISS_MS)) } // Extract the first blockquote from a markdown string — used as the applicable rewrite function extractBlockquote(md: string): string | null { const lines = md.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 } // Shared analysis function — used by both the hover tooltip and FeedbackPanel. // Returns a cancel function; call it to stop receiving onUpdate callbacks. export function analyseAnnotation( ann: TextAnnotation, onUpdate: (text: string, streaming: boolean, suggestion: string | null) => void ): () => void { const cached = tooltipAnalysisCache.get(ann.id) ?? ann.analysisCache ?? null if (cached) { // Warm the in-memory cache so subsequent calls this session are instant if (!tooltipAnalysisCache.has(ann.id)) tooltipAnalysisCache.set(ann.id, cached) onUpdate(cached.text, false, cached.suggestion) return () => {} } let cancelled = false const { activeFilePath, activeFileContent } = useEditorStore.getState() const typeName = ann.type.replace(/_/g, ' ') let accumulated = '' const api = (window as unknown as { api?: { streamAIMessage: (p: unknown, cb: (c: string) => void) => Promise } }).api if (api?.streamAIMessage) { api.streamAIMessage( { mode: 'chat', documentContent: activeFileContent, documentPath: activeFilePath ?? '', conversationHistory: [], userMessage: ann.autoAnalyse ? `Please provide editorial feedback on this selected passage: "${ann.matchedText}"\n\nIn 1–2 sentences, identify the most important issue or opportunity for improvement, then provide a suggested rewrite in a markdown blockquote:\n\n> Rewritten version here.\n\nBe specific to this exact text.` : `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) => { if (cancelled) return accumulated += chunk onUpdate(accumulated, true, null) } ).then(() => { if (cancelled) return const text = accumulated || ann.message const suggestion = extractBlockquote(text) ?? ann.suggestion ?? null tooltipAnalysisCache.set(ann.id, { text, suggestion }) useEditorStore.getState().setAnnotationAnalysis(ann.id, { text, suggestion }) onUpdate(text, false, suggestion) }).catch(() => { if (!cancelled) onUpdate(ann.message, false, ann.suggestion ?? null) }) } else { const suggestion = ann.suggestion ?? null tooltipAnalysisCache.set(ann.id, { text: ann.message, suggestion }) onUpdate(ann.message, false, suggestion) } return () => { cancelled = true } } // Exported module-level reference so FeedbackPanel can access the live EditorView // without prop-drilling. Set in the mount effect, nulled on cleanup. export let currentEditorView: EditorView | null = null // Scroll the editor to an annotation and place the cursor there. // Always resolves the position from CM's rawAnnotationsField so that edits // made since the annotation was created don't send the cursor to a stale offset. export function scrollToAnnotation(ann: TextAnnotation): void { const view = currentEditorView if (!view) return const tracked = view.state.field(rawAnnotationsField).find(a => a.id === ann.id) ?? ann if (tracked.from === undefined) return view.dispatch({ selection: { anchor: tracked.from }, effects: EditorView.scrollIntoView(tracked.from, { y: 'center' }) }) } // Apply a suggestion to the document and mark that annotation as applied. // Always resolves positions from CM's rawAnnotationsField so that edits made // since the annotation was created don't apply the change at a stale offset, // and so surviving annotation positions are also remapped from their current // (post-edit) locations rather than the stale values held by the store. export function applyAnnotation(ann: TextAnnotation, suggestion: string): void { const view = currentEditorView if (!view) return const { markAnnotationApplied } = useEditorStore.getState() const cmAnnotations = view.state.field(rawAnnotationsField) const tracked = cmAnnotations.find(a => a.id === ann.id) if (!tracked || tracked.from === undefined || tracked.to === undefined) return const changeSpec = { from: tracked.from, to: tracked.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 = cmAnnotations .filter(a => a.id !== ann.id) .map(a => a.from === undefined || a.to === undefined ? 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. view.dispatch({ changes: changeSpec, effects: setAnnotationsEffect.of(remaining) }) // Mark as applied in annotationsByFile (preserves it for chat history) and // remove from the active annotations list. markAnnotationApplied(ann.id) tooltipAnalysisCache.delete(ann.id) } // Hover tooltip — lazily streams a specific AI analysis for the hovered passage const annotationHoverTooltip = hoverTooltip( (view, pos) => { const annotations = view.state.field(rawAnnotationsField) const found = annotations.find(a => a.from !== undefined && a.to !== undefined && pos >= a.from && pos <= a.to) if (!found) return null // Capture in a new const so TypeScript preserves the non-undefined type // across the nested create() closure without requiring non-null assertions. const ann: TextAnnotation = found // User comments: show static tooltip with the comment text — no AI streaming if (ann.type === 'user_comment') { return { pos, end: ann.to, above: true, create() { const dom = document.createElement('div') dom.className = 'annotation-tooltip' dom.addEventListener('mousemove', (e) => e.stopPropagation()) const label = document.createElement('span') label.className = 'annotation-tooltip-label' label.textContent = 'Your comment' dom.appendChild(label) const dismissBtn = document.createElement('button') dismissBtn.className = 'annotation-tooltip-dismiss' dismissBtn.title = 'Dismiss' dismissBtn.textContent = '×' dismissBtn.addEventListener('click', () => { useEditorStore.getState().removeAnnotation(ann.id) }) dom.appendChild(dismissBtn) const divider = document.createElement('div') divider.className = 'annotation-tooltip-divider' dom.appendChild(divider) const body = document.createElement('div') body.className = 'annotation-tooltip-body' body.textContent = ann.comment ?? ann.message dom.appendChild(body) const bridge = document.createElement('div') bridge.className = 'cm-tooltip-arrow' dom.appendChild(bridge) return { dom, destroy() {} } } } } return { pos, end: ann.to, above: true, create() { const dom = document.createElement('div') dom.className = 'annotation-tooltip' dom.addEventListener('mousemove', (e) => e.stopPropagation()) const label = document.createElement('span') label.className = 'annotation-tooltip-label' label.textContent = ann.type.replace(/_/g, ' ') dom.appendChild(label) const dismissBtn = document.createElement('button') dismissBtn.className = 'annotation-tooltip-dismiss' dismissBtn.title = 'Dismiss' dismissBtn.textContent = '×' dismissBtn.addEventListener('click', () => { useEditorStore.getState().removeAnnotation(ann.id) }) dom.appendChild(dismissBtn) const divider = document.createElement('div') divider.className = 'annotation-tooltip-divider' dom.appendChild(divider) const body = document.createElement('div') body.className = 'annotation-tooltip-body' dom.appendChild(body) let rafId: number | null = null let pendingText = '' function showText(text: string, streaming = false): void { if (streaming) { pendingText = text if (rafId === null) { rafId = requestAnimationFrame(() => { rafId = null body.classList.remove('annotation-tooltip-loading') const raw = marked.parse(pendingText + ' ▋') as string body.innerHTML = DOMPurify.sanitize(raw) }) } return } if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null } body.classList.remove('annotation-tooltip-loading') const raw = marked.parse(text) as string 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', () => { applyAnnotation(ann, suggestion) }) dom.appendChild(btn) } body.classList.add('annotation-tooltip-loading') body.innerHTML = 'Analysing…' const cancelAnalysis = analyseAnnotation(ann, (text, streaming, suggestion) => { showText(text, streaming) if (!streaming) { window.dispatchEvent(new CustomEvent('annotation-cached', { detail: { id: ann.id } })) if (suggestion && !dom.querySelector('.annotation-tooltip-apply')) { showApplyButton(suggestion) } } }) const bridge = document.createElement('div') bridge.className = 'cm-tooltip-arrow' dom.appendChild(bridge) return { dom, destroy() { cancelAnalysis(); if (rafId !== null) cancelAnimationFrame(rafId) } } } } }, { hoverTime: 500 } ) // Build a DecorationSet from an annotation list, clamped to docLen. function buildDecoSet(annotations: TextAnnotation[], docLen: number): DecorationSet { const builder = new RangeSetBuilder() const sorted = [...annotations].sort((a, b) => (a.from ?? -1) - (b.from ?? -1)) for (const ann of sorted) { if (ann.from === undefined || ann.to === undefined) continue const from = Math.max(0, Math.min(ann.from, docLen)) const to = Math.max(from, Math.min(ann.to, docLen)) if (from < to) { builder.add(from, to, Decoration.mark({ class: `annotation annotation-${ann.type}`, attributes: { 'data-id': ann.id } })) } } return builder.finish() } // StateField tracks the decoration set derived from annotations. // Rebuilds from rawAnnotationsField on every doc change (not deco.map) so that // position-mapped highlights update immediately, and a decoration whose text // was cut and pasted back at the same spot reappears without any extra action. const annotationField = StateField.define({ create: () => Decoration.none, update(deco, tr) { if (tr.docChanged || tr.effects.some(e => e.is(setAnnotationsEffect))) { return buildDecoSet(tr.state.field(rawAnnotationsField), tr.newDoc.length) } return deco }, provide: (f) => EditorView.decorations.from(f) }) // Teach CM's undo/redo history about annotation state so Cmd+Z can restore // highlights. Records the previous annotation list as the inverse effect for: // • Apply suggestion (docChanged + setAnnotationsEffect) // • Edit-triggered auto-dismiss (tagged with userDismissAnnotation) // External updates (critique loads, store clears) have neither flag and must // NOT enter the undo stack or Undo would remove highlights unexpectedly. const annotationHistory = invertedEffects.of(tr => { const isApply = tr.docChanged && tr.effects.some(e => e.is(setAnnotationsEffect)) const isAutoDismiss = tr.annotation(userDismissAnnotation) === true if (isApply || isAutoDismiss) { const before = tr.startState.field(rawAnnotationsField) return [setAnnotationsEffect.of(before)] } return [] }) const themeCompartment = new Compartment() const editableCompartment = new Compartment() const markdownHighlight = HighlightStyle.define([ { tag: tags.heading, fontWeight: '700', color: 'var(--heading-color)' }, { tag: tags.emphasis, fontStyle: 'italic' }, { tag: tags.strong, fontWeight: 'bold' }, { tag: tags.strikethrough, textDecoration: 'line-through' }, { tag: tags.link, textDecoration: 'underline' }, { tag: tags.url, color: 'var(--accent)' }, { tag: tags.processingInstruction, color: 'var(--text-muted)' }, { tag: tags.comment, fontStyle: 'italic', color: 'var(--text-muted)' }, ]) // Horizontal rule widget — replaces `---` lines with a visual scene-break line class HrWidget extends WidgetType { toDOM(): HTMLElement { const el = document.createElement('span') el.className = 'cm-hr-widget' return el } ignoreEvent() { return false } } function buildHrDecos(view: EditorView): DecorationSet { const builder = new RangeSetBuilder() syntaxTree(view.state).iterate({ enter(node) { if (node.name === 'HorizontalRule') { builder.add(node.from, node.to, Decoration.replace({ widget: new HrWidget() })) } } }) return builder.finish() } const hrPlugin = ViewPlugin.fromClass( class { decorations: DecorationSet constructor(view: EditorView) { this.decorations = buildHrDecos(view) } update(update: ViewUpdate) { if (update.docChanged || update.viewportChanged) { this.decorations = buildHrDecos(update.view) } } }, { decorations: v => v.decorations } ) // Paragraph indent plugin — inserts a zero-height 2em widget at the start of // each paragraph line. Widgets are accounted for in CodeMirror's cursor // positioning, so the cursor on a blank line already sits at the indent // position before any text is typed — eliminating the layout shift. // Being an inline element at position 0, it only indents the first visual // line of each wrapped paragraph (true first-line indent, not block indent). class IndentWidget extends WidgetType { eq(other: WidgetType): boolean { return other instanceof IndentWidget } toDOM(): HTMLElement { const span = document.createElement('span') span.style.cssText = 'display:inline-block;width:2em;height:0' span.setAttribute('aria-hidden', 'true') return span } ignoreEvent() { return true } } const indentWidgetDeco = Decoration.widget({ widget: new IndentWidget(), side: -1 }) function buildParagraphDecos(view: EditorView): DecorationSet { const builder = new RangeSetBuilder() const doc = view.state.doc for (let n = 1; n <= doc.lines; n++) { const line = doc.line(n) const text = line.text const isHeading = /^#{1,6} /.test(text) const isHR = /^---+$/.test(text.trim()) const isSpecial = /^[-*>]/.test(text) // list / blockquote if (!isHeading && !isHR && !isSpecial) { builder.add(line.from, line.from, indentWidgetDeco) } } return builder.finish() } const paragraphIndentPlugin = ViewPlugin.fromClass( class { decorations: DecorationSet constructor(view: EditorView) { this.decorations = buildParagraphDecos(view) } update(update: ViewUpdate) { if (update.docChanged) { this.decorations = buildParagraphDecos(update.view) } } }, { decorations: v => v.decorations } ) function buildTheme(fontSize: number, dark: boolean, focusMode = false): ReturnType { const displaySize = focusMode ? Math.max(fontSize + 3, 18) : fontSize const maxWidth = '100%' const padding = focusMode ? '32px 108px' : '36px 96px' const lineHeight = focusMode ? '1.9' : '1.8' return EditorView.theme( { '&': { height: '100%', fontSize: `${displaySize}px`, backgroundColor: 'transparent', color: 'var(--text-primary)' }, '.cm-scroller': { fontFamily: 'var(--font-serif)', lineHeight, overflow: 'auto', }, '.cm-content': { padding, maxWidth, margin: '0 auto', caretColor: 'var(--accent)', }, '.cm-cursor': { borderLeftColor: 'var(--accent)' }, '.cm-selectionBackground': { backgroundColor: 'var(--active-bg)' }, '&.cm-focused .cm-selectionBackground': { backgroundColor: 'var(--active-bg)' }, '.cm-gutters': { display: 'none' }, '.cm-activeLine': { backgroundColor: 'transparent' }, '.cm-activeLineGutter': { backgroundColor: 'transparent' }, '.cm-hr-widget': { display: 'inline-block', width: '100%', height: '0', borderTop: '1px solid var(--border)', opacity: '0.45', verticalAlign: 'middle', }, // Annotation highlight styles '.annotation-passive_voice': { backgroundColor: 'rgba(255, 200, 0, 0.18)', borderBottom: '2px solid rgba(255, 200, 0, 0.7)', borderRadius: '2px' }, '.annotation-consistency': { backgroundColor: 'rgba(220, 80, 80, 0.18)', borderBottom: '2px solid rgba(220, 80, 80, 0.7)', borderRadius: '2px' }, '.annotation-style': { backgroundColor: 'rgba(80, 160, 255, 0.18)', borderBottom: '2px solid rgba(80, 160, 255, 0.7)', borderRadius: '2px' }, '.annotation-show_tell': { backgroundColor: 'rgba(255, 140, 30, 0.18)', borderBottom: '2px solid rgba(255, 140, 30, 0.7)', borderRadius: '2px' }, '.annotation-critique': { backgroundColor: 'rgba(160, 80, 220, 0.18)', borderBottom: '2px solid rgba(160, 80, 220, 0.7)', borderRadius: '2px' }, '.annotation-custom': { backgroundColor: 'rgba(30, 200, 150, 0.15)', borderBottom: '2px solid rgba(30, 200, 150, 0.7)', borderRadius: '2px' }, '.annotation-user_comment': { backgroundColor: 'rgba(240, 100, 180, 0.15)', borderBottom: '2px solid rgba(240, 100, 180, 0.65)', borderRadius: '2px' } }, { dark } ) } // --- Markdown formatting helpers --- function wrapOrUnwrap(view: EditorView, marker: string): boolean { const { state } = view const changes = state.changeByRange(range => { const selected = state.sliceDoc(range.from, range.to) const m = marker.length // Case 1: selection itself includes the markers (e.g. user selected "**bold**") if (selected.startsWith(marker) && selected.endsWith(marker) && selected.length >= m * 2 + 1) { const inner = selected.slice(m, selected.length - m) return { changes: { from: range.from, to: range.to, insert: inner }, range: EditorSelection.range(range.from, range.from + inner.length) } } // Case 2: markers sit just outside the selection — happens after a wrap when // the selection is left on the inner text (e.g. cursor on "bold" inside "**bold**") const before = range.from >= m ? state.sliceDoc(range.from - m, range.from) : '' const after = state.sliceDoc(range.to, range.to + m) if (before === marker && after === marker) { return { changes: [ { from: range.from - m, to: range.from, insert: '' }, { from: range.to, to: range.to + m, insert: '' } ], range: EditorSelection.range(range.from - m, range.to - m) } } // Case 3: not wrapped — wrap it const wrapped = marker + selected + marker return { changes: { from: range.from, to: range.to, insert: wrapped }, range: EditorSelection.range(range.from + m, range.from + m + selected.length) } }) view.dispatch(state.update(changes, { userEvent: 'input' })) return true } function toggleLinePrefix(view: EditorView, prefix: string): boolean { const { state } = view const changes = state.changeByRange(range => { const lineFrom = state.doc.lineAt(range.from) const lineTo = state.doc.lineAt(range.to) const insertions: { from: number; to: number; insert: string }[] = [] let removing = true for (let ln = lineFrom.number; ln <= lineTo.number; ln++) { if (!state.doc.line(ln).text.startsWith(prefix)) { removing = false; break } } for (let ln = lineFrom.number; ln <= lineTo.number; ln++) { const line = state.doc.line(ln) if (removing) { insertions.push({ from: line.from, to: line.from + prefix.length, insert: '' }) } else if (!line.text.startsWith(prefix)) { insertions.push({ from: line.from, to: line.from, insert: prefix }) } } return { changes: insertions, range } }) view.dispatch(state.update(changes, { userEvent: 'input' })) return true } function setHeading(view: EditorView, level: number): boolean { const { state } = view const prefix = '#'.repeat(level) + ' ' const changes = state.changeByRange(range => { const lineFrom = state.doc.lineAt(range.from) const lineTo = state.doc.lineAt(range.to) const insertions: { from: number; to: number; insert: string }[] = [] for (let ln = lineFrom.number; ln <= lineTo.number; ln++) { const line = state.doc.line(ln) const existingMatch = line.text.match(/^(#{1,6} )/) const existingLen = existingMatch ? existingMatch[0].length : 0 if (line.text.startsWith(prefix)) { insertions.push({ from: line.from, to: line.from + prefix.length, insert: '' }) } else { insertions.push({ from: line.from, to: line.from + existingLen, insert: prefix }) } } return { changes: insertions, range } }) view.dispatch(state.update(changes, { userEvent: 'input' })) return true } function insertLink(view: EditorView): boolean { const { state } = view const changes = state.changeByRange(range => { const selected = state.sliceDoc(range.from, range.to) const insert = `[${selected}]()` const cursorPos = range.from + selected.length + 3 // inside () return { changes: { from: range.from, to: range.to, insert }, range: EditorSelection.cursor(cursorPos) } }) view.dispatch(state.update(changes, { userEvent: 'input' })) return true } function countWords(text: string): number { return text.trim() === '' ? 0 : text.trim().split(/\s+/).length } export function MarkdownEditor(): JSX.Element { const containerRef = useRef(null) const viewRef = useRef(null) const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null) const [hasSelection, setHasSelection] = useState(false) const [pendingComment, setPendingComment] = useState<{ from: number; to: number; text: string } | null>(null) const [commentDraft, setCommentDraft] = useState('') const [wordStats, setWordStats] = useState<{ atCursor: number; total: number }>({ atCursor: 0, total: 0 }) const wordTotalRef = useRef(0) const { activeFilePath, activeFileContent, setContent, annotations, fontSize, theme, focusMode, scrollPositions, pendingScrollToLine, clearPendingScrollToLine, selectionWordCount, projectWordCount } = useEditorStore() function saveComment(): void { if (!pendingComment || !commentDraft.trim()) return const annotation: TextAnnotation = { id: `user-comment-${Date.now()}`, type: 'user_comment', from: pendingComment.from, to: pendingComment.to, matchedText: pendingComment.text, message: commentDraft.trim(), comment: commentDraft.trim(), } const store = useEditorStore.getState() store.setAnnotations([...store.annotations, annotation]) store.setRightPanelTab('feedback') setPendingComment(null) setCommentDraft('') } // Initialize CodeMirror once useEffect(() => { if (!containerRef.current) return // Debounced setContent — avoids a Zustand→React re-render on every keystroke. // The actual file save already has its own 1500ms debounce downstream. let contentTimer: ReturnType | null = null function scheduleSetContent(text: string): void { if (contentTimer) clearTimeout(contentTimer) contentTimer = setTimeout(() => { contentTimer = null; setContent(text) }, 400) } // Debounced word count — avoids expensive string ops on every cursor move. let wordTimer: ReturnType | null = null function scheduleWordStats(getStats: () => { atCursor: number; total: number }): void { if (wordTimer) clearTimeout(wordTimer) wordTimer = setTimeout(() => { wordTimer = null; setWordStats(getStats()) }, 150) } // Debounced telemetry snapshot — fires 5s after the user stops typing let telemetryTimer: ReturnType | null = null function scheduleTelemetrySnapshot(wordCount: number): void { if (telemetryTimer) clearTimeout(telemetryTimer) telemetryTimer = setTimeout(() => { telemetryTimer = null const { activeFilePath: fp } = useEditorStore.getState() if (!fp) return const api = (window as unknown as { api?: { trackWordSnapshot: (p: string, n: number) => Promise } }).api api?.trackWordSnapshot(fp, wordCount).catch(console.error) }, 5_000) } const view = new EditorView({ state: EditorState.create({ doc: '', extensions: [ indentUnit.of(" "), history(), drawSelection(), dropCursor(), search({ top: true }), EditorView.contentAttributes.of({ spellcheck: 'true' }), keymap.of([ { key: 'Tab', run: (view) => { view.dispatch(view.state.replaceSelection(' ')) return true } }, { key: 'Shift-Tab', run: indentLess }, { key: 'Mod-b', run: (view) => wrapOrUnwrap(view, '**') }, { key: 'Mod-i', run: (view) => wrapOrUnwrap(view, '*') }, { key: 'Mod-Shift-s', run: (view) => wrapOrUnwrap(view, '~~') }, { key: 'Mod-e', run: (view) => wrapOrUnwrap(view, '`') }, { key: 'Mod-k', run: insertLink }, { key: '-', run: (view) => { const { state } = view const { from } = state.selection.main if (from >= 1 && state.sliceDoc(from - 1, from) === '-') { view.dispatch(state.update({ changes: { from: from - 1, to: from, insert: '—' }, userEvent: 'input' })) return true } return false } }, { key: 'Mod-Shift-.', run: (view) => toggleLinePrefix(view, '> ') }, { key: 'Mod-1', run: (view) => setHeading(view, 1) }, { key: 'Mod-2', run: (view) => setHeading(view, 2) }, { key: 'Mod-3', run: (view) => setHeading(view, 3) }, ...searchKeymap, ...defaultKeymap, ...historyKeymap ]), markdown({ extensions: [{ remove: ['SetextHeading'] }] }), syntaxHighlighting(markdownHighlight), rawAnnotationsField, annotationField, annotationHoverTooltip, annotationHistory, hrPlugin, paragraphIndentPlugin, themeCompartment.of(buildTheme(fontSize, theme === 'dark', focusMode)), editableCompartment.of(EditorView.editable.of(false)), EditorView.lineWrapping, EditorView.updateListener.of((update) => { if (update.docChanged) { const text = update.state.doc.toString() scheduleSetContent(text) const total = countWords(text) wordTotalRef.current = total scheduleTelemetrySnapshot(total) const head = update.state.selection.main.head scheduleWordStats(() => ({ atCursor: countWords(update.state.doc.sliceString(0, head)), total: wordTotalRef.current })) } else if (update.selectionSet) { const head = update.state.selection.main.head scheduleWordStats(() => ({ atCursor: countWords(update.state.doc.sliceString(0, head)), total: wordTotalRef.current })) } if (update.selectionSet) { const { from, to } = update.state.selection.main const hasNonEmpty = from !== to setHasSelection(hasNonEmpty) if (hasNonEmpty) { const sel = update.state.sliceDoc(from, to) const words = sel.trim() === '' ? 0 : sel.trim().split(/\s+/).length useEditorStore.getState().setSelectionWordCount(words) } else { useEditorStore.getState().setSelectionWordCount(null) } } // 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) { // User is reverting edits — cancel any pending auto-dismissals so // restored highlights aren't immediately removed. dismissTimers.forEach((_, id) => cancelPendingDismiss(id)) const prev = update.startState.field(rawAnnotationsField) const curr = update.state.field(rawAnnotationsField) if (prev !== curr) { useEditorStore.getState().setAnnotations(curr) } return } // When the debounce timer fires it dispatches a tagged CM transaction // (no doc change). Sync the resulting annotation list to the store so // the feedback panel reflects the dismissal immediately. if (update.transactions.some(tr => tr.annotation(userDismissAnnotation) === true)) { useEditorStore.getState().setAnnotations(update.state.field(rawAnnotationsField)) return } // Auto-dismiss annotations whose highlighted text the user edits. // We check the pre-edit annotation positions (startState) against // each changed range reported by the transaction. if (update.docChanged) { const preAnnotations = update.startState.field(rawAnnotationsField) if (preAnnotations.length > 0) { for (const tr of update.transactions) { if (!tr.docChanged) continue // Skip non-user events such as file loads (addToHistory=false). if (tr.annotation(Transaction.addToHistory) === false) continue tr.changes.iterChangedRanges((fromA, toA) => { for (const ann of preAnnotations) { if (ann.from !== undefined && ann.to !== undefined && fromA < ann.to && toA > ann.from) { schedulePendingDismiss(ann.id) } } }) } } } }) ] }), parent: containerRef.current }) viewRef.current = view currentEditorView = view // Track scroll position — debounced so we don't thrash IPC on every pixel let scrollTimer: ReturnType | null = null const onScroll = (): void => { if (scrollTimer) clearTimeout(scrollTimer) scrollTimer = setTimeout(() => { const { activeFilePath: fp, setScrollPosition: save } = useEditorStore.getState() if (fp) save(fp, view.scrollDOM.scrollTop) }, 300) } view.scrollDOM.addEventListener('scroll', onScroll, { passive: true }) // Expose view + undo for dev-mode testing (stripped in production) if (import.meta.env.DEV) { const w = window as unknown as Record 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 () => { view.scrollDOM.removeEventListener('scroll', onScroll) if (scrollTimer) clearTimeout(scrollTimer) if (contentTimer) clearTimeout(contentTimer) if (wordTimer) clearTimeout(wordTimer) if (telemetryTimer) clearTimeout(telemetryTimer) view.destroy() viewRef.current = null currentEditorView = null } }, []) // eslint-disable-line react-hooks/exhaustive-deps // When the active file changes, replace editor content and restore scroll useEffect(() => { const view = viewRef.current if (!view) return const current = view.state.doc.toString() if (current !== activeFileContent) { // Mark the file-load as non-undoable: loading a file should never // appear in the undo stack, so Cmd+Z can't empty the document. view.dispatch({ changes: { from: 0, to: current.length, insert: activeFileContent }, annotations: Transaction.addToHistory.of(false) }) view.dispatch({ selection: { anchor: 0 } }) view.dispatch({ effects: editableCompartment.reconfigure(EditorView.editable.of(!!activeFilePath)) }) // Restore saved scroll position, or go to top for new files const savedScroll = activeFilePath ? scrollPositions[activeFilePath] ?? 0 : 0 // Defer scroll restoration so CodeMirror finishes laying out the new content requestAnimationFrame(() => { view.scrollDOM.scrollTop = savedScroll }) } }, [activeFilePath]) // Only sync on file switch // Push annotation decorations into CodeMirror. // 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 docLen = content.length const reanchored = reanchorAnnotations(annotations, content) const trackedById = new Map(view.state.field(rawAnnotationsField).map(a => [a.id, a])) const toDispatch = reanchored.map(a => { const tracked = trackedById.get(a.id) return (tracked && tracked.from !== undefined && tracked.from >= 0 && tracked.to !== undefined && tracked.to <= docLen) ? tracked : a }) view.dispatch({ effects: setAnnotationsEffect.of(toDispatch), annotations: [Transaction.addToHistory.of(false)] }) }, [annotations]) // Scroll to line requested by project search navigation useEffect(() => { if (pendingScrollToLine === null) return const view = viewRef.current if (!view) return const lineCount = view.state.doc.lines const lineNum = Math.max(1, Math.min(pendingScrollToLine, lineCount)) const line = view.state.doc.line(lineNum) view.dispatch({ effects: EditorView.scrollIntoView(line.from, { y: 'center' }) }) clearPendingScrollToLine() }, [pendingScrollToLine]) // Reconfigure theme when font size, colour theme, or focus mode changes useEffect(() => { const view = viewRef.current if (!view) return view.dispatch({ effects: themeCompartment.reconfigure(buildTheme(fontSize, theme === 'dark', focusMode)) }) }, [fontSize, theme, focusMode]) useEffect(() => { return window.api.onMenuAction((action) => { if (action === 'find' && viewRef.current) { openSearchPanel(viewRef.current) } }) }, []) function handleContextMenu(e: React.MouseEvent): void { if (!activeFilePath) return e.preventDefault() const view = viewRef.current if (view) { const { from, to } = view.state.selection.main setHasSelection(from !== to) } setMenuPos({ x: e.clientX, y: e.clientY }) } const editorMenuItems: (MenuItem | 'separator')[] = [ { label: 'Cut', disabled: !hasSelection, action: () => { const view = viewRef.current if (!view) return const { from, to } = view.state.selection.main if (from === to) return navigator.clipboard.writeText(view.state.sliceDoc(from, to)).catch(console.error) view.dispatch({ changes: { from, to, insert: '' } }) view.focus() } }, { label: 'Copy', disabled: !hasSelection, action: () => { const view = viewRef.current if (!view) return const { from, to } = view.state.selection.main if (from === to) return navigator.clipboard.writeText(view.state.sliceDoc(from, to)).catch(console.error) view.focus() } }, { label: 'Paste', action: () => { const view = viewRef.current if (!view) return navigator.clipboard.readText().then((text) => { const { from, to } = view.state.selection.main view.dispatch({ changes: { from, to, insert: text } }) view.focus() }).catch(console.error) } }, 'separator', { label: 'Select All', action: () => { const view = viewRef.current if (!view) return selectAll(view) view.focus() } }, ...(hasSelection ? [ 'separator' as const, { label: 'Generate feedback', action: () => { const view = viewRef.current if (!view) return const { from, to } = view.state.selection.main const text = view.state.sliceDoc(from, to) if (!text.trim()) return const annotation: TextAnnotation = { id: `custom-${Date.now()}`, type: 'custom', from, to, matchedText: text, message: 'Generating feedback...', autoAnalyse: true, } const store = useEditorStore.getState() store.setAnnotations([...store.annotations, annotation]) store.setRightPanelTab('feedback') } }, { label: 'Add comment', action: () => { const view = viewRef.current if (!view) return const { from, to } = view.state.selection.main const text = view.state.sliceDoc(from, to) if (!text.trim()) return setMenuPos(null) setPendingComment({ from, to, text }) setCommentDraft('') } } ] : []) ] return (
{!activeFilePath && (

Select a chapter from the sidebar to begin editing.

Your draft files will never be modified without saving (Cmd+S).

)}
{menuPos && ( setMenuPos(null)} /> )} {pendingComment && (
setPendingComment(null)}>
e.stopPropagation()}>
“{pendingComment.text.length > 80 ? pendingComment.text.slice(0, 80) + '…' : pendingComment.text}”