- CalcEditor: accept ytext + awareness props for Y.js collaborative editing
with remote cursors via yCollab
- App: integrate share button, CollabIndicator, route handling for /s/{token}
- App: create cloud documents on share, resolve share tokens on open
- Share button visible when authenticated, opens ShareDialog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
514 lines
17 KiB
TypeScript
514 lines
17 KiB
TypeScript
/**
|
|
* 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<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)
|
|
|
|
// Cloud document tracking
|
|
const [cloudDocId, setCloudDocId] = useState<string | null>(null)
|
|
const [sharedDocTitle, setSharedDocTitle] = useState<string | null>(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<number | null>(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 (
|
|
<div className="calcpad-app">
|
|
<OfflineBanner isOnline={isOnline} />
|
|
|
|
<header className="calcpad-header">
|
|
<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}
|
|
/>
|
|
<FontSizeControl
|
|
fontSize={fontSizeCtx.fontSize}
|
|
onFontSizeChange={fontSizeCtx.setFontSize}
|
|
min={fontSizeCtx.MIN_SIZE}
|
|
max={fontSizeCtx.MAX_SIZE}
|
|
/>
|
|
{isCollaborating && (
|
|
<CollabIndicator connected={connected} peerCount={peerCount} />
|
|
)}
|
|
{auth.isAuthenticated && (
|
|
<button
|
|
className="header-share-btn"
|
|
onClick={handleShare}
|
|
title="Share document"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="18" cy="5" r="3" />
|
|
<circle cx="6" cy="12" r="3" />
|
|
<circle cx="18" cy="19" r="3" />
|
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
|
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<ThemePicker
|
|
theme={themeCtx.theme}
|
|
resolvedTheme={themeCtx.resolvedTheme}
|
|
accentColor={themeCtx.accentColor}
|
|
onThemeChange={themeCtx.setTheme}
|
|
onAccentChange={themeCtx.setAccent}
|
|
/>
|
|
<UserMenu
|
|
onOpenAuth={() => setShowAuthModal(true)}
|
|
onOpenSecurity={() => setShowSecuritySettings(true)}
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
<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)}
|
|
/>
|
|
)}
|
|
<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}
|
|
ytext={isCollaborating ? ytext : null}
|
|
awareness={isCollaborating ? awareness : null}
|
|
/>
|
|
</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}
|
|
isInstalled={installPrompt.isInstalled}
|
|
onInstall={installPrompt.handleInstall}
|
|
onDismiss={installPrompt.handleDismiss}
|
|
/>
|
|
|
|
{showAuthModal && (
|
|
<AuthModal onClose={() => setShowAuthModal(false)} />
|
|
)}
|
|
|
|
{auth.needsPasswordRenewal && (
|
|
<AuthModal
|
|
onClose={() => auth.clearPasswordRenewal()}
|
|
renewalMode
|
|
/>
|
|
)}
|
|
|
|
{showSecuritySettings && (
|
|
<SecuritySettings onClose={() => setShowSecuritySettings(false)} />
|
|
)}
|
|
|
|
{showShareDialog && cloudDocId && (
|
|
<ShareDialog
|
|
documentId={cloudDocId}
|
|
documentTitle={docTitle}
|
|
currentShareToken={null}
|
|
currentSharePermission={null}
|
|
onClose={() => setShowShareDialog(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default App
|