/** * CalcText main application component. * * Workspace layout: header → tab bar → editor + results panel. * Multi-document support via document store with localStorage persistence. * Supports real-time collaboration via Y.js when authenticated. */ import { useCallback, useState, useRef, useEffect, useMemo } 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 { CollabIndicator } from './components/CollabIndicator.tsx' import { useAuth } from './auth/AuthProvider.tsx' import { AuthModal } from './auth/AuthModal.tsx' import { ShareDialog } from './sharing/ShareDialog.tsx' import { useShareToken } from './sharing/useShareToken.ts' import { useCloudSync } from './sync/useCloudSync.ts' import { useYDoc } from './collab/useYDoc.ts' import { useWebSocketProvider } from './collab/useWebSocketProvider.ts' import { getUserColor, getDisplayName } from './collab/awareness.ts' import { useRoute, navigate } from './router/useRoute.ts' 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 cloudSync = useCloudSync() const route = useRoute() const { resolveShareToken } = useShareToken() const [showAuthModal, setShowAuthModal] = useState(false) const [showSecuritySettings, setShowSecuritySettings] = useState(false) const [showShareDialog, setShowShareDialog] = 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) // Cloud document tracking const [cloudDocId, setCloudDocId] = useState(null) const [sharedDocTitle, setSharedDocTitle] = useState(null) // 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) // --- Collaboration setup --- const collabDocId = cloudDocId ?? (route.type === 'shared' ? route.token : null) const { ytext, ready: ydocReady, initContent } = useYDoc(collabDocId) const userName = useMemo(() => getDisplayName(auth.user?.email), [auth.user?.email]) const userColor = useMemo(() => getUserColor(auth.user?.id ?? 'anon'), [auth.user?.id]) const { awareness, connected, peerCount } = useWebSocketProvider({ ydoc: collabDocId ? (ytext as any)?.doc ?? null : null, roomName: collabDocId ? `doc:${collabDocId}` : null, token: auth.session?.access_token ?? null, userName, userColor, }) const isCollaborating = !!collabDocId && !!ytext && ydocReady // Initialize Y.Doc with current content when starting collaboration useEffect(() => { if (isCollaborating && store.activeDoc) { initContent(store.activeDoc.content) } }, [isCollaborating]) // eslint-disable-line react-hooks/exhaustive-deps // --- Handle shared document route --- useEffect(() => { if (route.type !== 'shared') return resolveShareToken(route.token).then(info => { if (info) { setCloudDocId(info.id) setSharedDocTitle(info.title) } }) }, [route]) // eslint-disable-line react-hooks/exhaustive-deps // --- Share current document --- const handleShare = useCallback(async () => { if (!auth.isAuthenticated || !store.activeDoc) { setShowAuthModal(true) return } // Create cloud document if not exists if (!cloudDocId) { const cloudDoc = await cloudSync.createCloudDocument(store.activeDoc.title) if (cloudDoc) { setCloudDocId(cloudDoc.id) } } setShowShareDialog(true) }, [auth.isAuthenticated, store.activeDoc, cloudDocId, cloudSync]) const handleDocChange = useCallback( (lines: string[]) => { engine.evalSheet(lines) // Persist content (only in non-shared mode) if (route.type !== 'shared') { 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, route.type], ) // Switch tabs const handleTabClick = useCallback((id: string) => { if (id === store.activeTabId) return store.setActiveTab(id) setEditorKey(id) setCloudDocId(null) // Reset collab when switching tabs if (route.type === 'shared') navigate('/') }, [store.activeTabId, store.setActiveTab, route.type]) // New document const handleNewTab = useCallback(() => { const doc = store.createDocument() setEditorKey(doc.id) setCloudDocId(null) if (route.type === 'shared') navigate('/') }, [store.createDocument, route.type]) // Close tab const handleTabClose = useCallback((id: string) => { store.closeTab(id) if (id === store.activeTabId) { setCloudDocId(null) } }, [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 } : {} const docTitle = sharedDocTitle ?? store.activeDoc?.title ?? 'Untitled' return (

CalcText

setFormatPreview(p => !p)} />
{isCollaborating && ( )} {auth.isAuthenticated && ( )} 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)} /> )} {showShareDialog && cloudDocId && ( setShowShareDialog(false)} /> )}
) } export default App