diff --git a/calcpad-web/src/App.tsx b/calcpad-web/src/App.tsx index 94800ad..f0fe888 100644 --- a/calcpad-web/src/App.tsx +++ b/calcpad-web/src/App.tsx @@ -3,9 +3,10 @@ * * 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 } from 'react' +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' @@ -28,8 +29,16 @@ 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() { @@ -40,9 +49,13 @@ function App() { 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) @@ -51,6 +64,10 @@ function App() { 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) => { @@ -71,29 +88,85 @@ function App() { 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 - 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(() => { + // 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.delete(store.activeTabId) + next.add(store.activeTabId) 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 @@ -101,20 +174,23 @@ function App() { if (id === store.activeTabId) return store.setActiveTab(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 const handleNewTab = useCallback(() => { const doc = store.createDocument() setEditorKey(doc.id) - }, [store.createDocument]) + setCloudDocId(null) + if (route.type === 'shared') navigate('/') + }, [store.createDocument, route.type]) // 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 + setCloudDocId(null) } }, [store.closeTab, store.activeTabId]) @@ -253,6 +329,8 @@ function App() { ? { flex: 1 } : {} + const docTitle = sharedDocTitle ?? store.activeDoc?.title ?? 'Untitled' + return (
@@ -287,6 +365,24 @@ function App() { min={fontSizeCtx.MIN_SIZE} max={fontSizeCtx.MAX_SIZE} /> + {isCollaborating && ( + + )} + {auth.isAuthenticated && ( + + )}
setShowSecuritySettings(false)} /> )} + + {showShareDialog && cloudDocId && ( + setShowShareDialog(false)} + /> + )}
) } diff --git a/calcpad-web/src/editor/CalcEditor.tsx b/calcpad-web/src/editor/CalcEditor.tsx index f4e630e..9d6fda8 100644 --- a/calcpad-web/src/editor/CalcEditor.tsx +++ b/calcpad-web/src/editor/CalcEditor.tsx @@ -3,6 +3,7 @@ * * Integrates the CalcPad language mode, error display, * 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' @@ -27,9 +28,12 @@ import { errorDisplayExtension, setErrorsEffect, type LineError } from './error- import { stripedLinesExtension } from './inline-results.ts' import { formatPreviewExtension, formatPreviewCompartment, formatPreviewEnabled } from './format-preview.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 { - /** Initial document content */ + /** Initial document content (ignored when ytext is provided) */ initialDoc?: string /** Called when the document text changes (debounced internally) */ onDocChange?: (lines: string[]) => void @@ -41,11 +45,16 @@ export interface CalcEditorProps { onViewReady?: (view: EditorView | null) => void /** Enable live preview formatting */ 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. * Handles syntax highlighting, line numbers, and error underlines. + * When ytext is provided, enables real-time collaborative editing. */ export function CalcEditor({ initialDoc = '', @@ -54,6 +63,8 @@ export function CalcEditor({ debounceMs = 50, onViewReady, formatPreview = true, + ytext, + awareness, }: CalcEditorProps) { const containerRef = useRef(null) const viewRef = useRef(null) @@ -83,47 +94,78 @@ export function CalcEditor({ } }) - const state = EditorState.create({ - doc: initialDoc, - extensions: [ - lineNumbers(), - drawSelection(), - highlightActiveLine(), - bracketMatching(), - indentOnInput(), - history(), - keymap.of([...defaultKeymap, ...historyKeymap]), - syntaxHighlighting(calcpadHighlight), - calcpadLanguage(), - errorDisplayExtension(), - stripedLinesExtension(), - formatPreviewExtension(formatPreview), - updateListener, - calcpadEditorTheme, - ], - }) + // Build extensions list + const extensions = [ + lineNumbers(), + drawSelection(), + highlightActiveLine(), + bracketMatching(), + indentOnInput(), + keymap.of([...defaultKeymap, ...historyKeymap]), + syntaxHighlighting(calcpadHighlight), + calcpadLanguage(), + errorDisplayExtension(), + stripedLinesExtension(), + formatPreviewExtension(formatPreview), + updateListener, + calcpadEditorTheme, + ] - const view = new EditorView({ - state, - parent: containerRef.current, - }) + // Collaborative mode: use yCollab instead of history + if (ytext && awareness) { + extensions.push( + yCollab(ytext, awareness), + keymap.of(yUndoManagerKeymap), + ) - viewRef.current = view - onViewReady?.(view) + const state = EditorState.create({ + doc: ytext.toString(), + extensions, + }) - // Trigger initial evaluation - const doc = view.state.doc.toString() - onDocChangeRef.current?.(doc.split('\n')) + const view = new EditorView({ + state, + 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 () => { if (timerRef.current) clearTimeout(timerRef.current) - view.destroy() - viewRef.current = null + if (viewRef.current) { + viewRef.current.destroy() + viewRef.current = null + } onViewReady?.(null) } // initialDoc intentionally excluded — we only set it once on mount // eslint-disable-next-line react-hooks/exhaustive-deps - }, [scheduleEval]) + }, [scheduleEval, ytext, awareness]) // Toggle format preview mode useEffect(() => { @@ -237,4 +279,12 @@ const calcpadEditorTheme = EditorView.baseTheme({ '.cm-focused': { 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', + }, }) diff --git a/calcpad-web/src/styles/app.css b/calcpad-web/src/styles/app.css index fa046b7..6b787dd 100644 --- a/calcpad-web/src/styles/app.css +++ b/calcpad-web/src/styles/app.css @@ -43,6 +43,29 @@ 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 ---------- */ .header-sidebar-toggle {