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,80 +1,338 @@
|
||||
/**
|
||||
* CalcPad main application component.
|
||||
* CalcText main application component.
|
||||
*
|
||||
* Two-column layout:
|
||||
* Left: CodeMirror 6 editor with CalcPad syntax highlighting
|
||||
* Right: Answer gutter (integrated into CodeMirror) + optional standalone AnswerColumn
|
||||
*
|
||||
* The WASM engine runs in a Web Worker. On each document change (debounced),
|
||||
* the editor sends lines to the worker, which evaluates them and posts back
|
||||
* results. Results are fed into the CodeMirror answer gutter extension.
|
||||
* Workspace layout: header → tab bar → editor + results panel.
|
||||
* Multi-document support via document store with localStorage persistence.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useState, useRef, useEffect } from 'react'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { CalcEditor } from './editor/CalcEditor.tsx'
|
||||
import { useEngine } from './engine/useEngine.ts'
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus.ts'
|
||||
import { useInstallPrompt } from './hooks/useInstallPrompt.ts'
|
||||
import { useTheme } from './hooks/useTheme.ts'
|
||||
import { useDocumentStore, loadSidebarState, saveSidebarState } from './hooks/useDocumentStore.ts'
|
||||
import { OfflineBanner } from './components/OfflineBanner.tsx'
|
||||
import { InstallPrompt } from './components/InstallPrompt.tsx'
|
||||
import { ResultsPanel } from './components/ResultsPanel.tsx'
|
||||
import { ThemePicker } from './components/ThemePicker.tsx'
|
||||
import { TabBar } from './components/TabBar.tsx'
|
||||
import { Sidebar } from './components/Sidebar.tsx'
|
||||
import { StatusBar } from './components/StatusBar.tsx'
|
||||
import { AlignToolbar } from './components/AlignToolbar.tsx'
|
||||
import type { Alignment } from './components/AlignToolbar.tsx'
|
||||
import { FormatToolbar } from './components/FormatToolbar.tsx'
|
||||
import { MobileResultsTray } from './components/MobileResultsTray.tsx'
|
||||
import './styles/app.css'
|
||||
|
||||
const INITIAL_DOC = `# CalcPad
|
||||
|
||||
// Basic arithmetic
|
||||
2 + 3
|
||||
10 * 4.5
|
||||
100 / 7
|
||||
|
||||
// Variables
|
||||
price = 49.99
|
||||
quantity = 3
|
||||
subtotal = price * quantity
|
||||
|
||||
// Percentages
|
||||
tax = subtotal * 8%
|
||||
total = subtotal + tax
|
||||
|
||||
// Functions
|
||||
sqrt(144)
|
||||
2 ^ 10
|
||||
`
|
||||
|
||||
function App() {
|
||||
const engine = useEngine()
|
||||
const isOnline = useOnlineStatus()
|
||||
const installPrompt = useInstallPrompt()
|
||||
const themeCtx = useTheme()
|
||||
const store = useDocumentStore()
|
||||
|
||||
const [editorView, setEditorView] = useState<EditorView | null>(null)
|
||||
const resultsPanelRef = useRef<HTMLDivElement>(null)
|
||||
const [modifiedIds, setModifiedIds] = useState<Set<string>>(new Set())
|
||||
const [editorAlign, setEditorAlign] = useState<Alignment>('left')
|
||||
const [resultsAlign, setResultsAlign] = useState<Alignment>('right')
|
||||
const [formatPreview, setFormatPreview] = useState(true)
|
||||
|
||||
// Sidebar state
|
||||
const [sidebarState, setSidebarState] = useState(loadSidebarState)
|
||||
const setSidebarVisible = useCallback((v: boolean) => {
|
||||
const next = { ...sidebarState, visible: v }
|
||||
setSidebarState(next)
|
||||
saveSidebarState(next)
|
||||
}, [sidebarState])
|
||||
const setSidebarWidth = useCallback((w: number) => {
|
||||
const next = { ...sidebarState, width: w }
|
||||
setSidebarState(next)
|
||||
saveSidebarState(next)
|
||||
}, [sidebarState])
|
||||
|
||||
// Track a key to force CalcEditor remount on tab switch
|
||||
const [editorKey, setEditorKey] = useState(store.activeTabId)
|
||||
|
||||
// Draggable divider state
|
||||
const [dividerX, setDividerX] = useState<number | null>(null)
|
||||
const isDragging = useRef(false)
|
||||
|
||||
const handleDocChange = useCallback(
|
||||
(lines: string[]) => {
|
||||
engine.evalSheet(lines)
|
||||
// Persist content
|
||||
const content = lines.join('\n')
|
||||
if (store.activeDoc && content !== store.activeDoc.content) {
|
||||
store.updateContent(store.activeTabId, content)
|
||||
setModifiedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(store.activeTabId)
|
||||
return next
|
||||
})
|
||||
// Clear modified dot after save debounce
|
||||
setTimeout(() => {
|
||||
setModifiedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(store.activeTabId)
|
||||
return next
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
[engine.evalSheet],
|
||||
[engine.evalSheet, store.activeTabId, store.activeDoc, store.updateContent],
|
||||
)
|
||||
|
||||
// Switch tabs
|
||||
const handleTabClick = useCallback((id: string) => {
|
||||
if (id === store.activeTabId) return
|
||||
store.setActiveTab(id)
|
||||
setEditorKey(id)
|
||||
}, [store.activeTabId, store.setActiveTab])
|
||||
|
||||
// New document
|
||||
const handleNewTab = useCallback(() => {
|
||||
const doc = store.createDocument()
|
||||
setEditorKey(doc.id)
|
||||
}, [store.createDocument])
|
||||
|
||||
// Close tab
|
||||
const handleTabClose = useCallback((id: string) => {
|
||||
store.closeTab(id)
|
||||
// If we closed the active tab, editorKey needs updating
|
||||
if (id === store.activeTabId) {
|
||||
// State will update, trigger effect below
|
||||
}
|
||||
}, [store.closeTab, store.activeTabId])
|
||||
|
||||
// Sync editorKey when activeTabId changes externally (e.g. after close)
|
||||
useEffect(() => {
|
||||
if (editorKey !== store.activeTabId) {
|
||||
setEditorKey(store.activeTabId)
|
||||
}
|
||||
}, [store.activeTabId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Apply editor text alignment via CodeMirror
|
||||
useEffect(() => {
|
||||
if (!editorView) return
|
||||
editorView.dom.style.setProperty('--cm-text-align', editorAlign)
|
||||
}, [editorView, editorAlign])
|
||||
|
||||
// Scroll sync: mirror editor scroll position to results panel
|
||||
useEffect(() => {
|
||||
if (!editorView) return
|
||||
const scroller = editorView.scrollDOM
|
||||
const onScroll = () => {
|
||||
if (resultsPanelRef.current) {
|
||||
resultsPanelRef.current.scrollTop = scroller.scrollTop
|
||||
}
|
||||
}
|
||||
scroller.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => scroller.removeEventListener('scroll', onScroll)
|
||||
}, [editorView])
|
||||
|
||||
// Draggable divider handlers
|
||||
const onDividerMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
isDragging.current = true
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.current) return
|
||||
setDividerX(e.clientX)
|
||||
}
|
||||
function onMouseUp() {
|
||||
if (!isDragging.current) return
|
||||
isDragging.current = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
|
||||
// Ctrl+B — toggle sidebar
|
||||
if (mod && e.key === 'b') {
|
||||
e.preventDefault()
|
||||
setSidebarVisible(!sidebarState.visible)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+N — new document
|
||||
if (mod && e.key === 'n') {
|
||||
e.preventDefault()
|
||||
handleNewTab()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+W — close tab
|
||||
if (mod && e.key === 'w') {
|
||||
e.preventDefault()
|
||||
handleTabClose(store.activeTabId)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs
|
||||
if (mod && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const idx = store.openTabIds.indexOf(store.activeTabId)
|
||||
const len = store.openTabIds.length
|
||||
const next = e.shiftKey
|
||||
? store.openTabIds[(idx - 1 + len) % len]
|
||||
: store.openTabIds[(idx + 1) % len]
|
||||
handleTabClick(next)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+1-9 — jump to tab
|
||||
if (mod && e.key >= '1' && e.key <= '9') {
|
||||
e.preventDefault()
|
||||
const tabIdx = parseInt(e.key) - 1
|
||||
if (tabIdx < store.openTabIds.length) {
|
||||
handleTabClick(store.openTabIds[tabIdx])
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [store.activeTabId, store.openTabIds, handleNewTab, handleTabClose, handleTabClick, sidebarState.visible, setSidebarVisible])
|
||||
|
||||
// Compute flex styles from divider position
|
||||
const editorStyle: React.CSSProperties = dividerX !== null
|
||||
? { width: dividerX, flex: 'none' }
|
||||
: {}
|
||||
const resultsStyle: React.CSSProperties = dividerX !== null
|
||||
? { flex: 1 }
|
||||
: {}
|
||||
|
||||
return (
|
||||
<div className="calcpad-app">
|
||||
<OfflineBanner isOnline={isOnline} />
|
||||
|
||||
<header className="calcpad-header">
|
||||
<h1>CalcPad</h1>
|
||||
<p className="subtitle">Notepad Calculator</p>
|
||||
<div className="header-status">
|
||||
<span className={`status-dot ${engine.ready ? '' : 'loading'}`} />
|
||||
<span>{engine.ready ? 'Engine ready' : 'Loading engine...'}</span>
|
||||
<button
|
||||
className="header-sidebar-toggle"
|
||||
onClick={() => setSidebarVisible(!sidebarState.visible)}
|
||||
title="Toggle sidebar (Ctrl+B)"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<h1>CalcText</h1>
|
||||
<div className="header-spacer" />
|
||||
<div className="header-actions">
|
||||
<FormatToolbar
|
||||
editorView={editorView}
|
||||
previewMode={formatPreview}
|
||||
onPreviewToggle={() => setFormatPreview(p => !p)}
|
||||
/>
|
||||
<div className="header-divider" />
|
||||
<AlignToolbar
|
||||
editorAlign={editorAlign}
|
||||
resultsAlign={resultsAlign}
|
||||
onEditorAlignChange={setEditorAlign}
|
||||
onResultsAlignChange={setResultsAlign}
|
||||
/>
|
||||
<ThemePicker
|
||||
theme={themeCtx.theme}
|
||||
accentColor={themeCtx.accentColor}
|
||||
onThemeChange={themeCtx.setTheme}
|
||||
onAccentChange={themeCtx.setAccent}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="calcpad-editor">
|
||||
<div className="editor-pane">
|
||||
<CalcEditor
|
||||
initialDoc={INITIAL_DOC}
|
||||
onDocChange={handleDocChange}
|
||||
results={engine.results}
|
||||
debounceMs={50}
|
||||
<TabBar
|
||||
tabs={store.openDocs}
|
||||
activeTabId={store.activeTabId}
|
||||
onTabClick={handleTabClick}
|
||||
onTabClose={handleTabClose}
|
||||
onTabRename={store.renameDocument}
|
||||
onNewTab={handleNewTab}
|
||||
modifiedIds={modifiedIds}
|
||||
/>
|
||||
|
||||
<div className="calcpad-workspace">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
{sidebarState.visible && (
|
||||
<div
|
||||
className="sidebar-backdrop"
|
||||
onClick={() => setSidebarVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
<Sidebar
|
||||
visible={sidebarState.visible}
|
||||
width={sidebarState.width}
|
||||
documents={store.documents}
|
||||
folders={store.folders}
|
||||
activeTabId={store.activeTabId}
|
||||
openTabIds={store.openTabIds}
|
||||
onFileClick={(id) => { store.openDocument(id); setEditorKey(id) }}
|
||||
onNewDocument={(title, content) => {
|
||||
const doc = store.createDocument(title, content)
|
||||
setEditorKey(doc.id)
|
||||
}}
|
||||
onNewFolder={() => store.createFolder()}
|
||||
onRenameDocument={store.renameDocument}
|
||||
onDeleteDocument={store.deleteDocument}
|
||||
onToggleFavorite={store.toggleFavorite}
|
||||
onMoveToFolder={store.moveToFolder}
|
||||
onRenameFolder={store.renameFolder}
|
||||
onDeleteFolder={store.deleteFolder}
|
||||
onWidthChange={setSidebarWidth}
|
||||
/>
|
||||
|
||||
<main className="calcpad-editor">
|
||||
<div className="editor-pane" style={editorStyle}>
|
||||
<CalcEditor
|
||||
key={editorKey}
|
||||
initialDoc={store.activeDoc?.content ?? ''}
|
||||
onDocChange={handleDocChange}
|
||||
results={engine.results}
|
||||
debounceMs={50}
|
||||
onViewReady={setEditorView}
|
||||
formatPreview={formatPreview}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pane-divider"
|
||||
onMouseDown={onDividerMouseDown}
|
||||
/>
|
||||
<ResultsPanel
|
||||
ref={resultsPanelRef}
|
||||
results={engine.results}
|
||||
align={resultsAlign}
|
||||
style={resultsStyle}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<MobileResultsTray
|
||||
results={engine.results}
|
||||
docLines={store.activeDoc?.content.split('\n') ?? []}
|
||||
/>
|
||||
|
||||
<StatusBar
|
||||
editorView={editorView}
|
||||
engineReady={engine.ready}
|
||||
lineCount={store.activeDoc?.content.split('\n').length ?? 0}
|
||||
/>
|
||||
|
||||
<InstallPrompt
|
||||
promptEvent={installPrompt.promptEvent}
|
||||
|
||||
Reference in New Issue
Block a user