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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* React wrapper around CodeMirror 6 for the CalcPad editor.
|
||||
*
|
||||
* Integrates the CalcPad language mode, answer gutter, error display,
|
||||
* Integrates the CalcPad language mode, error display,
|
||||
* and debounced evaluation via the WASM engine Web Worker.
|
||||
*/
|
||||
|
||||
@@ -15,15 +15,17 @@ import {
|
||||
keymap,
|
||||
} from '@codemirror/view'
|
||||
import {
|
||||
defaultHighlightStyle,
|
||||
syntaxHighlighting,
|
||||
bracketMatching,
|
||||
indentOnInput,
|
||||
HighlightStyle,
|
||||
} from '@codemirror/language'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { calcpadLanguage } from './calcpad-language.ts'
|
||||
import { answerGutterExtension, setAnswersEffect, type LineAnswer } from './answer-gutter.ts'
|
||||
import { errorDisplayExtension, setErrorsEffect, type LineError } from './error-display.ts'
|
||||
import { stripedLinesExtension } from './inline-results.ts'
|
||||
import { formatPreviewExtension, formatPreviewCompartment, formatPreviewEnabled } from './format-preview.ts'
|
||||
import type { EngineLineResult } from '../engine/types.ts'
|
||||
|
||||
export interface CalcEditorProps {
|
||||
@@ -31,22 +33,27 @@ export interface CalcEditorProps {
|
||||
initialDoc?: string
|
||||
/** Called when the document text changes (debounced internally) */
|
||||
onDocChange?: (lines: string[]) => void
|
||||
/** Engine evaluation results to display in the answer gutter */
|
||||
/** Engine evaluation results to display as errors */
|
||||
results?: EngineLineResult[]
|
||||
/** Debounce delay in ms before triggering onDocChange */
|
||||
debounceMs?: number
|
||||
/** Called with the EditorView once created (null on cleanup) */
|
||||
onViewReady?: (view: EditorView | null) => void
|
||||
/** Enable live preview formatting */
|
||||
formatPreview?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* CalcPad editor component built on CodeMirror 6.
|
||||
* Handles syntax highlighting, line numbers, answer gutter,
|
||||
* and error underlines.
|
||||
* Handles syntax highlighting, line numbers, and error underlines.
|
||||
*/
|
||||
export function CalcEditor({
|
||||
initialDoc = '',
|
||||
onDocChange,
|
||||
results,
|
||||
debounceMs = 50,
|
||||
onViewReady,
|
||||
formatPreview = true,
|
||||
}: CalcEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
@@ -86,10 +93,11 @@ export function CalcEditor({
|
||||
indentOnInput(),
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
syntaxHighlighting(calcpadHighlight),
|
||||
calcpadLanguage(),
|
||||
answerGutterExtension(),
|
||||
errorDisplayExtension(),
|
||||
stripedLinesExtension(),
|
||||
formatPreviewExtension(formatPreview),
|
||||
updateListener,
|
||||
calcpadEditorTheme,
|
||||
],
|
||||
@@ -101,6 +109,7 @@ export function CalcEditor({
|
||||
})
|
||||
|
||||
viewRef.current = view
|
||||
onViewReady?.(view)
|
||||
|
||||
// Trigger initial evaluation
|
||||
const doc = view.state.doc.toString()
|
||||
@@ -110,23 +119,33 @@ export function CalcEditor({
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
onViewReady?.(null)
|
||||
}
|
||||
// initialDoc intentionally excluded — we only set it once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scheduleEval])
|
||||
|
||||
// Push engine results into the answer gutter + error display
|
||||
// Toggle format preview mode
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
view.dispatch({
|
||||
effects: formatPreviewCompartment.reconfigure(
|
||||
formatPreview ? formatPreviewEnabled : [],
|
||||
),
|
||||
})
|
||||
}, [formatPreview])
|
||||
|
||||
// Push engine results into the error display
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view || !results) return
|
||||
|
||||
const answers: LineAnswer[] = []
|
||||
const errors: LineError[] = []
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const lineNum = i + 1
|
||||
const result = results[i]
|
||||
answers.push({ line: lineNum, result })
|
||||
|
||||
if (result.type === 'error' && result.error) {
|
||||
// Map to document positions
|
||||
@@ -143,7 +162,6 @@ export function CalcEditor({
|
||||
|
||||
view.dispatch({
|
||||
effects: [
|
||||
setAnswersEffect.of(answers),
|
||||
setErrorsEffect.of(errors),
|
||||
],
|
||||
})
|
||||
@@ -152,6 +170,24 @@ export function CalcEditor({
|
||||
return <div ref={containerRef} className="calc-editor" />
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntax highlighting using CSS variables for theme integration.
|
||||
*/
|
||||
const calcpadHighlight = HighlightStyle.define([
|
||||
{ tag: tags.number, color: 'var(--syntax-number)' },
|
||||
{ tag: tags.operator, color: 'var(--syntax-operator)' },
|
||||
{ tag: tags.variableName, color: 'var(--syntax-variable)' },
|
||||
{ tag: tags.function(tags.variableName), color: 'var(--syntax-function)' },
|
||||
{ tag: tags.keyword, color: 'var(--syntax-keyword)' },
|
||||
{ tag: tags.lineComment, color: 'var(--syntax-comment)', fontStyle: 'italic' },
|
||||
{ tag: tags.heading, color: 'var(--syntax-heading)', fontWeight: '700' },
|
||||
{ tag: tags.definitionOperator, color: 'var(--syntax-operator)' },
|
||||
{ tag: tags.special(tags.variableName), color: 'var(--syntax-function)' },
|
||||
{ tag: tags.constant(tags.variableName), color: 'var(--syntax-number)', fontWeight: '600' },
|
||||
{ tag: tags.paren, color: 'var(--syntax-operator)' },
|
||||
{ tag: tags.punctuation, color: 'var(--syntax-operator)' },
|
||||
])
|
||||
|
||||
/**
|
||||
* Base theme for the CalcPad editor.
|
||||
*/
|
||||
@@ -159,64 +195,44 @@ const calcpadEditorTheme = EditorView.baseTheme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'ui-monospace, Consolas, "Courier New", monospace',
|
||||
fontFamily: 'var(--mono, ui-monospace, Consolas, "Courier New", monospace)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '12px 0',
|
||||
padding: '8px 0',
|
||||
minHeight: '100%',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 16px',
|
||||
padding: '0 12px',
|
||||
lineHeight: '1.6',
|
||||
position: 'relative',
|
||||
textAlign: 'var(--cm-text-align, left)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
borderRight: 'none',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
padding: '0 8px 0 16px',
|
||||
color: '#9ca3af',
|
||||
padding: '0 6px 0 12px',
|
||||
color: 'var(--text, #9ca3af)',
|
||||
opacity: '0.4',
|
||||
fontSize: '13px',
|
||||
minWidth: '32px',
|
||||
},
|
||||
'.cm-answer-gutter': {
|
||||
minWidth: '140px',
|
||||
textAlign: 'right',
|
||||
paddingRight: '16px',
|
||||
borderLeft: '1px solid #e5e4e7',
|
||||
backgroundColor: '#f8f9fa',
|
||||
fontFamily: 'ui-monospace, Consolas, monospace',
|
||||
fontSize: '14px',
|
||||
},
|
||||
'&dark .cm-answer-gutter': {
|
||||
borderLeft: '1px solid #2e303a',
|
||||
backgroundColor: '#1a1b23',
|
||||
},
|
||||
'.cm-answer-value': {
|
||||
color: '#6366f1',
|
||||
'.cm-activeLineGutter .cm-gutterElement': {
|
||||
opacity: '1',
|
||||
fontWeight: '600',
|
||||
},
|
||||
'&dark .cm-answer-value': {
|
||||
color: '#818cf8',
|
||||
},
|
||||
'.cm-answer-error': {
|
||||
color: '#e53e3e',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'&dark .cm-answer-error': {
|
||||
color: '#fc8181',
|
||||
'.cm-stripe': {
|
||||
backgroundColor: 'var(--stripe, rgba(0, 0, 0, 0.02))',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.04)',
|
||||
},
|
||||
'&dark .cm-activeLine': {
|
||||
backgroundColor: 'rgba(129, 140, 248, 0.06)',
|
||||
backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.04))',
|
||||
},
|
||||
'.cm-selectionBackground': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
|
||||
backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.15)) !important',
|
||||
},
|
||||
'.cm-focused': {
|
||||
outline: 'none',
|
||||
|
||||
Reference in New Issue
Block a user