Phase 1 - Bug fixes:
- Fix color labels not showing on active line in format preview
- Replace eye emoji with SVG icon showing clear preview/raw state
- Replace // button with comment icon + better tooltip
- Fix ThemePicker accent colors when using system theme
Phase 2 - Font:
- Load JetBrains Mono via Google Fonts with offline fallback
- Add font size control (A-/A+) with keyboard shortcuts
- Persist font size preference in localStorage
Phase 3 - Auth:
- Supabase-based email/password authentication
- Device session management with configurable password renewal TTL
- AuthModal, UserMenu, SecuritySettings components
Phase 4 - Cloud sync:
- Document metadata sync to Supabase PostgreSQL
- Legacy localStorage migration on first login
- IndexedDB persistence via y-indexeddb
Phase 5 - Real-time collaboration:
- Y.js CRDT integration with CodeMirror 6
- Hocuspocus WebSocket server with JWT auth
- Collaborative cursor awareness
- CollabIndicator component
Phase 6 - Sharing:
- Share links with view/edit permissions
- ShareDialog component with copy-to-clipboard
- Minimal client-side router for /s/{token} URLs
Infrastructure:
- Docker Compose with PostgreSQL, GoTrue, PostgREST, Hocuspocus
- Nginx reverse proxy for all backend services
- SQL migrations with RLS policies
- Production-ready Dockerfile with build args
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
241 lines
7.0 KiB
TypeScript
241 lines
7.0 KiB
TypeScript
/**
|
|
* React wrapper around CodeMirror 6 for the CalcPad editor.
|
|
*
|
|
* Integrates the CalcPad language mode, error display,
|
|
* and debounced evaluation via the WASM engine Web Worker.
|
|
*/
|
|
|
|
import { useRef, useEffect, useCallback } from 'react'
|
|
import { EditorState } from '@codemirror/state'
|
|
import {
|
|
EditorView,
|
|
lineNumbers,
|
|
drawSelection,
|
|
highlightActiveLine,
|
|
keymap,
|
|
} from '@codemirror/view'
|
|
import {
|
|
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 { 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 {
|
|
/** Initial document content */
|
|
initialDoc?: string
|
|
/** Called when the document text changes (debounced internally) */
|
|
onDocChange?: (lines: string[]) => void
|
|
/** 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, 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)
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
// Stable callback ref for doc changes
|
|
const onDocChangeRef = useRef(onDocChange)
|
|
onDocChangeRef.current = onDocChange
|
|
|
|
const scheduleEval = useCallback((view: EditorView) => {
|
|
if (timerRef.current) clearTimeout(timerRef.current)
|
|
timerRef.current = setTimeout(() => {
|
|
timerRef.current = null
|
|
const doc = view.state.doc.toString()
|
|
const lines = doc.split('\n')
|
|
onDocChangeRef.current?.(lines)
|
|
}, debounceMs)
|
|
}, [debounceMs])
|
|
|
|
// Create editor on mount
|
|
useEffect(() => {
|
|
if (!containerRef.current) return
|
|
|
|
const updateListener = EditorView.updateListener.of((update) => {
|
|
if (update.docChanged && viewRef.current) {
|
|
scheduleEval(viewRef.current)
|
|
}
|
|
})
|
|
|
|
const state = EditorState.create({
|
|
doc: initialDoc,
|
|
extensions: [
|
|
lineNumbers(),
|
|
drawSelection(),
|
|
highlightActiveLine(),
|
|
bracketMatching(),
|
|
indentOnInput(),
|
|
history(),
|
|
keymap.of([...defaultKeymap, ...historyKeymap]),
|
|
syntaxHighlighting(calcpadHighlight),
|
|
calcpadLanguage(),
|
|
errorDisplayExtension(),
|
|
stripedLinesExtension(),
|
|
formatPreviewExtension(formatPreview),
|
|
updateListener,
|
|
calcpadEditorTheme,
|
|
],
|
|
})
|
|
|
|
const view = new EditorView({
|
|
state,
|
|
parent: containerRef.current,
|
|
})
|
|
|
|
viewRef.current = view
|
|
onViewReady?.(view)
|
|
|
|
// Trigger initial evaluation
|
|
const doc = view.state.doc.toString()
|
|
onDocChangeRef.current?.(doc.split('\n'))
|
|
|
|
return () => {
|
|
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])
|
|
|
|
// 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 errors: LineError[] = []
|
|
|
|
for (let i = 0; i < results.length; i++) {
|
|
const lineNum = i + 1
|
|
const result = results[i]
|
|
|
|
if (result.type === 'error' && result.error) {
|
|
// Map to document positions
|
|
if (lineNum <= view.state.doc.lines) {
|
|
const docLine = view.state.doc.line(lineNum)
|
|
errors.push({
|
|
from: docLine.from,
|
|
to: docLine.to,
|
|
message: result.error,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
view.dispatch({
|
|
effects: [
|
|
setErrorsEffect.of(errors),
|
|
],
|
|
})
|
|
}, [results])
|
|
|
|
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.
|
|
*/
|
|
const calcpadEditorTheme = EditorView.baseTheme({
|
|
'&': {
|
|
height: '100%',
|
|
fontSize: 'var(--editor-font-size, 15px)',
|
|
fontFamily: 'var(--mono, ui-monospace, Consolas, "Courier New", monospace)',
|
|
},
|
|
'.cm-scroller': {
|
|
overflow: 'auto',
|
|
},
|
|
'.cm-content': {
|
|
padding: '8px 0',
|
|
minHeight: '100%',
|
|
},
|
|
'.cm-line': {
|
|
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 6px 0 12px',
|
|
color: 'var(--text, #9ca3af)',
|
|
opacity: '0.4',
|
|
fontSize: 'calc(var(--editor-font-size, 15px) - 2px)',
|
|
minWidth: '32px',
|
|
},
|
|
'.cm-activeLineGutter .cm-gutterElement': {
|
|
opacity: '1',
|
|
fontWeight: '600',
|
|
},
|
|
'.cm-stripe': {
|
|
backgroundColor: 'var(--stripe, rgba(0, 0, 0, 0.02))',
|
|
},
|
|
'.cm-activeLine': {
|
|
backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.04))',
|
|
},
|
|
'.cm-selectionBackground': {
|
|
backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.15)) !important',
|
|
},
|
|
'.cm-focused': {
|
|
outline: 'none',
|
|
},
|
|
})
|