Files
calctext/calcpad-web/src/editor/CalcEditor.tsx
C. Cassel ef302ebda9 feat: add auth, real-time collaboration, sharing, font control, and UI fixes
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>
2026-03-19 16:21:04 -04:00

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',
},
})