feat: wire up real-time collaboration and document sharing

- 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>
This commit is contained in:
2026-03-19 17:48:31 -04:00
parent 808fe07117
commit e9fd8b7c9b
3 changed files with 232 additions and 51 deletions

View File

@@ -3,9 +3,10 @@
* *
* Workspace layout: header → tab bar → editor + results panel. * Workspace layout: header → tab bar → editor + results panel.
* Multi-document support via document store with localStorage persistence. * Multi-document support via document store with localStorage persistence.
* Supports real-time collaboration via Y.js when authenticated.
*/ */
import { useCallback, useState, useRef, useEffect } from 'react' import { useCallback, useState, useRef, useEffect, useMemo } from 'react'
import type { EditorView } from '@codemirror/view' import type { EditorView } from '@codemirror/view'
import { CalcEditor } from './editor/CalcEditor.tsx' import { CalcEditor } from './editor/CalcEditor.tsx'
import { useEngine } from './engine/useEngine.ts' import { useEngine } from './engine/useEngine.ts'
@@ -28,8 +29,16 @@ import { useFontSize } from './hooks/useFontSize.ts'
import { MobileResultsTray } from './components/MobileResultsTray.tsx' import { MobileResultsTray } from './components/MobileResultsTray.tsx'
import { UserMenu } from './components/UserMenu.tsx' import { UserMenu } from './components/UserMenu.tsx'
import { SecuritySettings } from './components/SecuritySettings.tsx' import { SecuritySettings } from './components/SecuritySettings.tsx'
import { CollabIndicator } from './components/CollabIndicator.tsx'
import { useAuth } from './auth/AuthProvider.tsx' import { useAuth } from './auth/AuthProvider.tsx'
import { AuthModal } from './auth/AuthModal.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' import './styles/app.css'
function App() { function App() {
@@ -40,9 +49,13 @@ function App() {
const fontSizeCtx = useFontSize() const fontSizeCtx = useFontSize()
const auth = useAuth() const auth = useAuth()
const store = useDocumentStore() const store = useDocumentStore()
const cloudSync = useCloudSync()
const route = useRoute()
const { resolveShareToken } = useShareToken()
const [showAuthModal, setShowAuthModal] = useState(false) const [showAuthModal, setShowAuthModal] = useState(false)
const [showSecuritySettings, setShowSecuritySettings] = useState(false) const [showSecuritySettings, setShowSecuritySettings] = useState(false)
const [showShareDialog, setShowShareDialog] = useState(false)
const [editorView, setEditorView] = useState<EditorView | null>(null) const [editorView, setEditorView] = useState<EditorView | null>(null)
const resultsPanelRef = useRef<HTMLDivElement>(null) const resultsPanelRef = useRef<HTMLDivElement>(null)
@@ -51,6 +64,10 @@ function App() {
const [resultsAlign, setResultsAlign] = useState<Alignment>('right') const [resultsAlign, setResultsAlign] = useState<Alignment>('right')
const [formatPreview, setFormatPreview] = useState(true) const [formatPreview, setFormatPreview] = useState(true)
// Cloud document tracking
const [cloudDocId, setCloudDocId] = useState<string | null>(null)
const [sharedDocTitle, setSharedDocTitle] = useState<string | null>(null)
// Sidebar state // Sidebar state
const [sidebarState, setSidebarState] = useState(loadSidebarState) const [sidebarState, setSidebarState] = useState(loadSidebarState)
const setSidebarVisible = useCallback((v: boolean) => { const setSidebarVisible = useCallback((v: boolean) => {
@@ -71,29 +88,85 @@ function App() {
const [dividerX, setDividerX] = useState<number | null>(null) const [dividerX, setDividerX] = useState<number | null>(null)
const isDragging = useRef(false) 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( const handleDocChange = useCallback(
(lines: string[]) => { (lines: string[]) => {
engine.evalSheet(lines) engine.evalSheet(lines)
// Persist content // Persist content (only in non-shared mode)
const content = lines.join('\n') if (route.type !== 'shared') {
if (store.activeDoc && content !== store.activeDoc.content) { const content = lines.join('\n')
store.updateContent(store.activeTabId, content) if (store.activeDoc && content !== store.activeDoc.content) {
setModifiedIds(prev => { store.updateContent(store.activeTabId, content)
const next = new Set(prev)
next.add(store.activeTabId)
return next
})
// Clear modified dot after save debounce
setTimeout(() => {
setModifiedIds(prev => { setModifiedIds(prev => {
const next = new Set(prev) const next = new Set(prev)
next.delete(store.activeTabId) next.add(store.activeTabId)
return next return next
}) })
}, 500) // 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], [engine.evalSheet, store.activeTabId, store.activeDoc, store.updateContent, route.type],
) )
// Switch tabs // Switch tabs
@@ -101,20 +174,23 @@ function App() {
if (id === store.activeTabId) return if (id === store.activeTabId) return
store.setActiveTab(id) store.setActiveTab(id)
setEditorKey(id) setEditorKey(id)
}, [store.activeTabId, store.setActiveTab]) setCloudDocId(null) // Reset collab when switching tabs
if (route.type === 'shared') navigate('/')
}, [store.activeTabId, store.setActiveTab, route.type])
// New document // New document
const handleNewTab = useCallback(() => { const handleNewTab = useCallback(() => {
const doc = store.createDocument() const doc = store.createDocument()
setEditorKey(doc.id) setEditorKey(doc.id)
}, [store.createDocument]) setCloudDocId(null)
if (route.type === 'shared') navigate('/')
}, [store.createDocument, route.type])
// Close tab // Close tab
const handleTabClose = useCallback((id: string) => { const handleTabClose = useCallback((id: string) => {
store.closeTab(id) store.closeTab(id)
// If we closed the active tab, editorKey needs updating
if (id === store.activeTabId) { if (id === store.activeTabId) {
// State will update, trigger effect below setCloudDocId(null)
} }
}, [store.closeTab, store.activeTabId]) }, [store.closeTab, store.activeTabId])
@@ -253,6 +329,8 @@ function App() {
? { flex: 1 } ? { flex: 1 }
: {} : {}
const docTitle = sharedDocTitle ?? store.activeDoc?.title ?? 'Untitled'
return ( return (
<div className="calcpad-app"> <div className="calcpad-app">
<OfflineBanner isOnline={isOnline} /> <OfflineBanner isOnline={isOnline} />
@@ -287,6 +365,24 @@ function App() {
min={fontSizeCtx.MIN_SIZE} min={fontSizeCtx.MIN_SIZE}
max={fontSizeCtx.MAX_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 <ThemePicker
theme={themeCtx.theme} theme={themeCtx.theme}
resolvedTheme={themeCtx.resolvedTheme} resolvedTheme={themeCtx.resolvedTheme}
@@ -351,6 +447,8 @@ function App() {
debounceMs={50} debounceMs={50}
onViewReady={setEditorView} onViewReady={setEditorView}
formatPreview={formatPreview} formatPreview={formatPreview}
ytext={isCollaborating ? ytext : null}
awareness={isCollaborating ? awareness : null}
/> />
</div> </div>
<div <div
@@ -398,6 +496,16 @@ function App() {
{showSecuritySettings && ( {showSecuritySettings && (
<SecuritySettings onClose={() => setShowSecuritySettings(false)} /> <SecuritySettings onClose={() => setShowSecuritySettings(false)} />
)} )}
{showShareDialog && cloudDocId && (
<ShareDialog
documentId={cloudDocId}
documentTitle={docTitle}
currentShareToken={null}
currentSharePermission={null}
onClose={() => setShowShareDialog(false)}
/>
)}
</div> </div>
) )
} }

View File

@@ -3,6 +3,7 @@
* *
* Integrates the CalcPad language mode, error display, * Integrates the CalcPad language mode, error display,
* and debounced evaluation via the WASM engine Web Worker. * and debounced evaluation via the WASM engine Web Worker.
* Supports Y.js collaborative editing when ytext + awareness are provided.
*/ */
import { useRef, useEffect, useCallback } from 'react' import { useRef, useEffect, useCallback } from 'react'
@@ -27,9 +28,12 @@ import { errorDisplayExtension, setErrorsEffect, type LineError } from './error-
import { stripedLinesExtension } from './inline-results.ts' import { stripedLinesExtension } from './inline-results.ts'
import { formatPreviewExtension, formatPreviewCompartment, formatPreviewEnabled } from './format-preview.ts' import { formatPreviewExtension, formatPreviewCompartment, formatPreviewEnabled } from './format-preview.ts'
import type { EngineLineResult } from '../engine/types.ts' import type { EngineLineResult } from '../engine/types.ts'
import type * as Y from 'yjs'
import type { Awareness } from 'y-protocols/awareness'
import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next'
export interface CalcEditorProps { export interface CalcEditorProps {
/** Initial document content */ /** Initial document content (ignored when ytext is provided) */
initialDoc?: string initialDoc?: string
/** Called when the document text changes (debounced internally) */ /** Called when the document text changes (debounced internally) */
onDocChange?: (lines: string[]) => void onDocChange?: (lines: string[]) => void
@@ -41,11 +45,16 @@ export interface CalcEditorProps {
onViewReady?: (view: EditorView | null) => void onViewReady?: (view: EditorView | null) => void
/** Enable live preview formatting */ /** Enable live preview formatting */
formatPreview?: boolean formatPreview?: boolean
/** Y.js text for collaborative editing */
ytext?: Y.Text | null
/** Y.js awareness for remote cursors */
awareness?: Awareness | null
} }
/** /**
* CalcPad editor component built on CodeMirror 6. * CalcPad editor component built on CodeMirror 6.
* Handles syntax highlighting, line numbers, and error underlines. * Handles syntax highlighting, line numbers, and error underlines.
* When ytext is provided, enables real-time collaborative editing.
*/ */
export function CalcEditor({ export function CalcEditor({
initialDoc = '', initialDoc = '',
@@ -54,6 +63,8 @@ export function CalcEditor({
debounceMs = 50, debounceMs = 50,
onViewReady, onViewReady,
formatPreview = true, formatPreview = true,
ytext,
awareness,
}: CalcEditorProps) { }: CalcEditorProps) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null) const viewRef = useRef<EditorView | null>(null)
@@ -83,47 +94,78 @@ export function CalcEditor({
} }
}) })
const state = EditorState.create({ // Build extensions list
doc: initialDoc, const extensions = [
extensions: [ lineNumbers(),
lineNumbers(), drawSelection(),
drawSelection(), highlightActiveLine(),
highlightActiveLine(), bracketMatching(),
bracketMatching(), indentOnInput(),
indentOnInput(), keymap.of([...defaultKeymap, ...historyKeymap]),
history(), syntaxHighlighting(calcpadHighlight),
keymap.of([...defaultKeymap, ...historyKeymap]), calcpadLanguage(),
syntaxHighlighting(calcpadHighlight), errorDisplayExtension(),
calcpadLanguage(), stripedLinesExtension(),
errorDisplayExtension(), formatPreviewExtension(formatPreview),
stripedLinesExtension(), updateListener,
formatPreviewExtension(formatPreview), calcpadEditorTheme,
updateListener, ]
calcpadEditorTheme,
],
})
const view = new EditorView({ // Collaborative mode: use yCollab instead of history
state, if (ytext && awareness) {
parent: containerRef.current, extensions.push(
}) yCollab(ytext, awareness),
keymap.of(yUndoManagerKeymap),
)
viewRef.current = view const state = EditorState.create({
onViewReady?.(view) doc: ytext.toString(),
extensions,
})
// Trigger initial evaluation const view = new EditorView({
const doc = view.state.doc.toString() state,
onDocChangeRef.current?.(doc.split('\n')) parent: containerRef.current,
})
viewRef.current = view
onViewReady?.(view)
const doc = view.state.doc.toString()
onDocChangeRef.current?.(doc.split('\n'))
} else {
// Standard mode: local history
extensions.splice(4, 0, history()) // Insert after indentOnInput
const state = EditorState.create({
doc: initialDoc,
extensions,
})
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 () => { return () => {
if (timerRef.current) clearTimeout(timerRef.current) if (timerRef.current) clearTimeout(timerRef.current)
view.destroy() if (viewRef.current) {
viewRef.current = null viewRef.current.destroy()
viewRef.current = null
}
onViewReady?.(null) onViewReady?.(null)
} }
// initialDoc intentionally excluded — we only set it once on mount // initialDoc intentionally excluded — we only set it once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [scheduleEval]) }, [scheduleEval, ytext, awareness])
// Toggle format preview mode // Toggle format preview mode
useEffect(() => { useEffect(() => {
@@ -237,4 +279,12 @@ const calcpadEditorTheme = EditorView.baseTheme({
'.cm-focused': { '.cm-focused': {
outline: 'none', outline: 'none',
}, },
// Remote cursor styles for collaboration
'.cm-ySelectionInfo': {
fontSize: '11px',
fontFamily: 'var(--sans)',
padding: '1px 4px',
borderRadius: '3px 3px 3px 0',
opacity: '0.9',
},
}) })

View File

@@ -43,6 +43,29 @@
background: var(--border); background: var(--border);
} }
/* ---------- Share button ---------- */
.header-share-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--text);
cursor: pointer;
transition: background 0.1s, border-color 0.1s, color 0.1s;
}
.header-share-btn:hover {
background: var(--accent-bg);
border-color: var(--border);
color: var(--accent);
}
/* ---------- Sidebar toggle ---------- */ /* ---------- Sidebar toggle ---------- */
.header-sidebar-toggle { .header-sidebar-toggle {