/** * CalcText main application component. * * Workspace layout: header → tab bar → editor + results panel. * Multi-document support via document store with localStorage persistence. */ 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 { FontSizeControl } from './components/FontSizeControl.tsx' import { useFontSize } from './hooks/useFontSize.ts' import { MobileResultsTray } from './components/MobileResultsTray.tsx' import { UserMenu } from './components/UserMenu.tsx' import { SecuritySettings } from './components/SecuritySettings.tsx' import { useAuth } from './auth/AuthProvider.tsx' import { AuthModal } from './auth/AuthModal.tsx' import './styles/app.css' function App() { const engine = useEngine() const isOnline = useOnlineStatus() const installPrompt = useInstallPrompt() const themeCtx = useTheme() const fontSizeCtx = useFontSize() const auth = useAuth() const store = useDocumentStore() const [showAuthModal, setShowAuthModal] = useState(false) const [showSecuritySettings, setShowSecuritySettings] = useState(false) const [editorView, setEditorView] = useState(null) const resultsPanelRef = useRef(null) const [modifiedIds, setModifiedIds] = useState>(new Set()) const [editorAlign, setEditorAlign] = useState('left') const [resultsAlign, setResultsAlign] = useState('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(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, 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 } // Ctrl+= — increase font size if (mod && (e.key === '=' || e.key === '+')) { e.preventDefault() fontSizeCtx.setFontSize(fontSizeCtx.fontSize + 1) return } // Ctrl+- — decrease font size if (mod && e.key === '-') { e.preventDefault() fontSizeCtx.setFontSize(fontSizeCtx.fontSize - 1) return } // Ctrl+0 — reset font size if (mod && e.key === '0') { e.preventDefault() fontSizeCtx.resetFontSize() return } } document.addEventListener('keydown', handleKey) return () => document.removeEventListener('keydown', handleKey) }, [store.activeTabId, store.openTabIds, handleNewTab, handleTabClose, handleTabClick, sidebarState.visible, setSidebarVisible, fontSizeCtx]) // 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 (

CalcText

setFormatPreview(p => !p)} />
setShowAuthModal(true)} onOpenSecurity={() => setShowSecuritySettings(true)} />
{/* Mobile sidebar backdrop */} {sidebarState.visible && (
setSidebarVisible(false)} /> )} { 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} />
{showAuthModal && ( setShowAuthModal(false)} /> )} {auth.needsPasswordRenewal && ( auth.clearPasswordRenewal()} renewalMode /> )} {showSecuritySettings && ( setShowSecuritySettings(false)} /> )}
) } export default App