/** * Inline error display for CalcPad. * Adapted from epic/9-2-codemirror-6-editor. * * Shows red underline decorations and gutter markers for lines with errors. */ import { Decoration, type DecorationSet, GutterMarker, gutter, EditorView, hoverTooltip, type Tooltip, } from '@codemirror/view' import { StateField, StateEffect, type Extension, RangeSet } from '@codemirror/state' // --- State Effects --- export interface LineError { from: number // absolute start position in document to: number // absolute end position in document message: string } export const setErrorsEffect = StateEffect.define() // --- Error Gutter Marker --- class ErrorGutterMarker extends GutterMarker { override toDOM(): HTMLElement { const span = document.createElement('span') span.className = 'cm-error-marker' span.textContent = '\u26A0' // Warning sign return span } override eq(other: GutterMarker): boolean { return other instanceof ErrorGutterMarker } } const errorMarkerInstance = new ErrorGutterMarker() // --- Error Underline Decorations (StateField) --- const errorUnderlineMark = Decoration.mark({ class: 'cm-error-underline' }) export const errorDecorationsField = StateField.define({ create() { return Decoration.none }, update(decos, tr) { for (const effect of tr.effects) { if (effect.is(setErrorsEffect)) { if (effect.value.length === 0) { return Decoration.none } const ranges = effect.value .filter((e) => e.from < e.to) .sort((a, b) => a.from - b.from) .map((e) => errorUnderlineMark.range(e.from, e.to)) return RangeSet.of(ranges) } } if (tr.docChanged) { return decos.map(tr.changes) } return decos }, provide(field) { return EditorView.decorations.from(field) }, }) // --- Error Lines Set (for gutter) --- export const errorLinesField = StateField.define>({ create() { return new Set() }, update(lines, tr) { for (const effect of tr.effects) { if (effect.is(setErrorsEffect)) { const newLines = new Set() for (const error of effect.value) { const lineNumber = tr.state.doc.lineAt(error.from).number newLines.add(lineNumber) } return newLines } } return lines }, }) // --- Error Messages (for tooltips) --- export const errorMessagesField = StateField.define>({ create() { return new Map() }, update(msgs, tr) { for (const effect of tr.effects) { if (effect.is(setErrorsEffect)) { const newMsgs = new Map() for (const error of effect.value) { const lineNumber = tr.state.doc.lineAt(error.from).number newMsgs.set(lineNumber, error.message) } return newMsgs } } return msgs }, }) // --- Error Tooltip (hover) --- const errorTooltip = hoverTooltip((view, pos) => { const line = view.state.doc.lineAt(pos) const errorMessages = view.state.field(errorMessagesField) const msg = errorMessages.get(line.number) if (!msg) return null return { pos: line.from, end: line.to, above: false, create() { const dom = document.createElement('div') dom.className = 'cm-error-tooltip' dom.textContent = msg return { dom } }, } satisfies Tooltip }) // --- Error Gutter --- export const errorGutter = gutter({ class: 'cm-error-gutter', lineMarker(view, line) { const lineNumber = view.state.doc.lineAt(line.from).number const errorLines = view.state.field(errorLinesField) return errorLines.has(lineNumber) ? errorMarkerInstance : null }, lineMarkerChange(update) { return update.transactions.some((tr) => tr.effects.some((e) => e.is(setErrorsEffect)), ) }, }) // --- Base Theme --- export const errorBaseTheme = EditorView.baseTheme({ '.cm-error-underline': { textDecoration: 'underline wavy var(--error, red)', textDecorationThickness: '1.5px', }, '.cm-error-marker': { color: 'var(--error, #e53e3e)', fontSize: '14px', cursor: 'pointer', }, '.cm-error-gutter': { width: '20px', }, '.cm-error-tooltip': { backgroundColor: 'var(--bg-secondary, #f8f9fa)', color: 'var(--error, #e53e3e)', border: '1px solid var(--border, #e5e4e7)', borderRadius: '4px', padding: '4px 8px', fontSize: '12px', fontFamily: 'var(--sans, system-ui)', maxWidth: '300px', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', }, '.cm-tooltip': { border: 'none', backgroundColor: 'transparent', }, }) /** * Creates the error display extension bundle. */ export function errorDisplayExtension(): Extension { return [ errorDecorationsField, errorLinesField, errorMessagesField, errorGutter, errorTooltip, errorBaseTheme, ] }