Files
calctext/calcpad-web/src/editor/error-display.ts
C. Cassel 0d38bd3108 feat(web): implement complete workspace with themes, tabs, sidebar, and mobile
Transform CalcText from a single-document calculator into a full workspace
application with multi-document support, theming, and responsive mobile experience.

- Theme system: 5 presets (Light, Dark, Matrix, Midnight, Warm) + accent colors
- Document model with localStorage persistence and auto-save
- Tab bar with keyboard shortcuts (Ctrl+N/W/Tab/1-9), rename, close
- Sidebar with search, recent, favorites, folders, templates, drag-and-drop
- 5 templates: Budget, Invoice, Unit Converter, Trip Planner, Loan Calculator
- Status bar with cursor position, engine status, dedication to Igor Cassel
- Results panel: type-specific colors, click-to-copy, error hints
- Format toolbar: H, B, I, //, color labels with live preview toggle
- Syntax highlighting using theme CSS variables
- Error hover tooltips
- Mobile: bottom results tray, sidebar drawer, touch targets, safe areas
- Docker multi-stage build (Rust WASM + Vite + Nginx)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:12:05 -04:00

206 lines
4.8 KiB
TypeScript

/**
* 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<LineError[]>()
// --- 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<DecorationSet>({
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<Set<number>>({
create() {
return new Set()
},
update(lines, tr) {
for (const effect of tr.effects) {
if (effect.is(setErrorsEffect)) {
const newLines = new Set<number>()
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<Map<number, string>>({
create() {
return new Map()
},
update(msgs, tr) {
for (const effect of tr.effects) {
if (effect.is(setErrorsEffect)) {
const newMsgs = new Map<number, string>()
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,
]
}