Download components/document-editor-sync.tsx from ayushkokande/distributed-collaborative-editor: direct link, hf CLI and curl.
- Browser
- Download file 20.8 kB
-
https://huggingface.co/spaces/ayushkokande/distributed-collaborative-editor/resolve/main/components/document-editor-sync.tsx
- Command line
-
hf download hf://spaces/ayushkokande/distributed-collaborative-editor/components/document-editor-sync.tsx
-
curl -L -o document-editor-sync.tsx https://huggingface.co/spaces/ayushkokande/distributed-collaborative-editor/resolve/main/components/document-editor-sync.tsx
20.8 kB
| "use client"; | |
| import { useEffect, useRef, useState, useCallback } from "react"; | |
| import { useRouter } from "next/navigation"; | |
| import * as Y from "yjs"; | |
| import { | |
| saveDocument, | |
| getDocument, | |
| addUnsyncedUpdate, | |
| getUnsyncedUpdates, | |
| clearUnsyncedUpdates, | |
| type Document, | |
| } from "@/lib/indexeddb"; | |
| import { useTabSync } from "@/hooks/use-tab-sync"; | |
| import { useWebSocketSync } from "@/hooks/use-websocket-sync"; | |
| import { usePresence } from "@/hooks/use-presence"; | |
| import { PresenceAvatars, EditingIndicator } from "@/components/presence-avatars"; | |
| import { TestPanel } from "@/components/test-panel"; | |
| import { | |
| Save, | |
| FileText, | |
| Check, | |
| Loader2, | |
| Wifi, | |
| WifiOff, | |
| CloudOff, | |
| Circle, | |
| RefreshCw, | |
| AlertCircle, | |
| ArrowLeft, | |
| Copy, | |
| Share2, | |
| } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| interface DocumentEditorWithSyncProps { | |
| documentId: string; | |
| } | |
| const AUTO_SAVE_DELAY = 1000; | |
| export function DocumentEditorWithSync({ documentId }: DocumentEditorWithSyncProps) { | |
| const router = useRouter(); | |
| // Y.js refs | |
| const ydocRef = useRef<Y.Doc | null>(null); | |
| const yTitleRef = useRef<Y.Text | null>(null); | |
| const yContentRef = useRef<Y.Text | null>(null); | |
| // State | |
| const [title, setTitle] = useState("Untitled Document"); | |
| const [content, setContent] = useState(""); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [isDocReady, setIsDocReady] = useState(false); | |
| const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle"); | |
| const [lastSaved, setLastSaved] = useState<Date | null>(null); | |
| const [unsyncedCount, setUnsyncedCount] = useState(0); | |
| const [isOnline, setIsOnline] = useState(true); | |
| const [simulatedOffline, setSimulatedOffline] = useState(false); | |
| const [copied, setCopied] = useState(false); | |
| const [debugLogs, setDebugLogs] = useState<string[]>([]); | |
| const [showDebug, setShowDebug] = useState(false); | |
| const addDebugLog = useCallback((msg: string) => { | |
| setDebugLogs(prev => [...prev.slice(-20), `${new Date().toISOString().slice(11,19)} ${msg}`]); | |
| }, []); | |
| const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null); | |
| const isInitializedRef = useRef(false); | |
| const contentRef = useRef<HTMLTextAreaElement>(null); | |
| const titleRef = useRef<HTMLInputElement>(null); | |
| const effectiveOnline = isOnline && !simulatedOffline; | |
| // Track online/offline status | |
| useEffect(() => { | |
| setIsOnline(navigator.onLine); | |
| const handleOnline = () => setIsOnline(true); | |
| const handleOffline = () => setIsOnline(false); | |
| window.addEventListener("online", handleOnline); | |
| window.addEventListener("offline", handleOffline); | |
| return () => { | |
| window.removeEventListener("online", handleOnline); | |
| window.removeEventListener("offline", handleOffline); | |
| }; | |
| }, []); | |
| // Initialize Y.Doc | |
| useEffect(() => { | |
| const ydoc = new Y.Doc(); | |
| ydocRef.current = ydoc; | |
| const yTitle = ydoc.getText("title"); | |
| const yContent = ydoc.getText("content"); | |
| yTitleRef.current = yTitle; | |
| yContentRef.current = yContent; | |
| // Load from IndexedDB | |
| async function loadDocument() { | |
| try { | |
| const savedDoc = await getDocument(documentId); | |
| if (savedDoc?.yjsState) { | |
| Y.applyUpdate(ydoc, savedDoc.yjsState, "load"); | |
| setLastSaved(new Date(savedDoc.updatedAt)); | |
| // Apply unsynced updates | |
| const unsyncedUpdates = await getUnsyncedUpdates(documentId); | |
| for (const update of unsyncedUpdates) { | |
| Y.applyUpdate(ydoc, update.update, "load"); | |
| } | |
| if (unsyncedUpdates.length > 0) { | |
| await clearUnsyncedUpdates(documentId); | |
| } | |
| } else { | |
| // New document | |
| yTitle.insert(0, "Untitled Document"); | |
| } | |
| setTitle(yTitle.toString()); | |
| setContent(yContent.toString()); | |
| isInitializedRef.current = true; | |
| setIsDocReady(true); | |
| } catch (error) { | |
| console.error("Failed to load document:", error); | |
| yTitle.insert(0, "Untitled Document"); | |
| isInitializedRef.current = true; | |
| setIsDocReady(true); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| } | |
| loadDocument(); | |
| // Subscribe to Y.js changes - these fire for ALL changes including remote | |
| const titleObserver = () => { | |
| const newTitle = yTitle.toString(); | |
| console.log("[v0] Y.Title changed:", newTitle); | |
| setTitle(newTitle); | |
| addDebugLog(`Title: "${newTitle.slice(0,30)}"`); | |
| }; | |
| const contentObserver = () => { | |
| const newContent = yContent.toString(); | |
| console.log("[v0] Y.Content changed, length:", newContent.length); | |
| setContent(newContent); | |
| addDebugLog(`Content: ${newContent.length} chars`); | |
| }; | |
| yTitle.observe(titleObserver); | |
| yContent.observe(contentObserver); | |
| addDebugLog("Observers set up"); | |
| // Track updates for persistence | |
| ydoc.on("update", async (update: Uint8Array, origin: unknown) => { | |
| if (origin !== "load" && origin !== "tab-sync" && origin !== "websocket" && isInitializedRef.current) { | |
| try { | |
| await addUnsyncedUpdate(documentId, update); | |
| const updates = await getUnsyncedUpdates(documentId); | |
| setUnsyncedCount(updates.length); | |
| } catch (error) { | |
| console.error("Failed to track update:", error); | |
| } | |
| } | |
| }); | |
| return () => { | |
| ydoc.destroy(); | |
| if (saveTimeoutRef.current) { | |
| clearTimeout(saveTimeoutRef.current); | |
| } | |
| }; | |
| }, [documentId]); | |
| // Tab sync (for same browser) | |
| const { | |
| isActive: isTabSyncActive, | |
| connectedTabs, | |
| tabId, | |
| syncStatus, | |
| requestReconciliation, | |
| flushOfflineQueue, | |
| } = useTabSync({ | |
| ydoc: isDocReady ? ydocRef.current : null, | |
| documentId, | |
| enabled: true, | |
| isOffline: !effectiveOnline, | |
| }); | |
| // WebSocket sync (for cross-device) | |
| const { | |
| status: wsStatus, | |
| connectedClients, | |
| error: wsError, | |
| reconnect: wsReconnect, | |
| } = useWebSocketSync({ | |
| ydoc: isDocReady ? ydocRef.current : null, | |
| documentId, | |
| enabled: effectiveOnline, | |
| onDebugLog: addDebugLog, | |
| }); | |
| // Presence | |
| const { | |
| currentUser, | |
| remoteUsers, | |
| updateCursor, | |
| updateSelection, | |
| clearEditing, | |
| } = usePresence({ | |
| documentId, | |
| enabled: isDocReady, | |
| }); | |
| // Save function | |
| const save = useCallback(async () => { | |
| if (!ydocRef.current || !yTitleRef.current) return; | |
| setSaveStatus("saving"); | |
| try { | |
| const state = Y.encodeStateAsUpdate(ydocRef.current); | |
| const existingDoc = await getDocument(documentId); | |
| const doc: Document = { | |
| id: documentId, | |
| title: yTitleRef.current.toString(), | |
| yjsState: state, | |
| updatedAt: Date.now(), | |
| createdAt: existingDoc?.createdAt || Date.now(), | |
| }; | |
| await saveDocument(doc); | |
| await clearUnsyncedUpdates(documentId); | |
| setUnsyncedCount(0); | |
| setLastSaved(new Date()); | |
| setSaveStatus("saved"); | |
| setTimeout(() => setSaveStatus("idle"), 2000); | |
| } catch (error) { | |
| console.error("Failed to save:", error); | |
| setSaveStatus("idle"); | |
| } | |
| }, [documentId]); | |
| // Debounced save | |
| const scheduleSave = useCallback(() => { | |
| if (saveTimeoutRef.current) { | |
| clearTimeout(saveTimeoutRef.current); | |
| } | |
| saveTimeoutRef.current = setTimeout(save, AUTO_SAVE_DELAY); | |
| }, [save]); | |
| // Update functions | |
| const updateTitle = useCallback((newTitle: string) => { | |
| if (!yTitleRef.current || !isInitializedRef.current) return; | |
| const yTitle = yTitleRef.current; | |
| ydocRef.current?.transact(() => { | |
| yTitle.delete(0, yTitle.length); | |
| yTitle.insert(0, newTitle); | |
| }); | |
| scheduleSave(); | |
| }, [scheduleSave]); | |
| const updateContent = useCallback((newContent: string, cursorPosition?: number) => { | |
| if (!yContentRef.current || !isInitializedRef.current) return; | |
| const yContent = yContentRef.current; | |
| const currentContent = yContent.toString(); | |
| if (cursorPosition !== undefined) { | |
| const lengthDiff = newContent.length - currentContent.length; | |
| if (lengthDiff === 1) { | |
| const insertPos = cursorPosition - 1; | |
| const char = newContent[insertPos]; | |
| if (char !== undefined) { | |
| yContent.insert(insertPos, char); | |
| scheduleSave(); | |
| return; | |
| } | |
| } else if (lengthDiff === -1) { | |
| const deletePos = cursorPosition; | |
| if (deletePos >= 0 && deletePos < currentContent.length) { | |
| yContent.delete(deletePos, 1); | |
| scheduleSave(); | |
| return; | |
| } | |
| } | |
| } | |
| ydocRef.current?.transact(() => { | |
| yContent.delete(0, yContent.length); | |
| yContent.insert(0, newContent); | |
| }); | |
| scheduleSave(); | |
| }, [scheduleSave]); | |
| // Handlers | |
| const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| updateTitle(e.target.value); | |
| }; | |
| const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { | |
| updateContent(e.target.value, e.target.selectionStart); | |
| }; | |
| const handleTitleFocus = () => { | |
| if (titleRef.current) updateCursor(titleRef.current.selectionStart, "title"); | |
| }; | |
| const handleTitleSelect = () => { | |
| if (titleRef.current) { | |
| updateSelection(titleRef.current.selectionStart, titleRef.current.selectionEnd, "title"); | |
| } | |
| }; | |
| const handleContentFocus = () => { | |
| if (contentRef.current) updateCursor(contentRef.current.selectionStart, "content"); | |
| }; | |
| const handleContentSelect = () => { | |
| if (contentRef.current) { | |
| updateSelection(contentRef.current.selectionStart, contentRef.current.selectionEnd, "content"); | |
| } | |
| }; | |
| const handleBlur = () => clearEditing(); | |
| const manualSave = () => { | |
| if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); | |
| save(); | |
| }; | |
| const copyShareLink = async () => { | |
| const url = window.location.href; | |
| await navigator.clipboard.writeText(url); | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 2000); | |
| }; | |
| // Keyboard shortcuts | |
| useEffect(() => { | |
| const handleKeyDown = (e: KeyboardEvent) => { | |
| if ((e.metaKey || e.ctrlKey) && e.key === "s") { | |
| e.preventDefault(); | |
| manualSave(); | |
| } | |
| }; | |
| window.addEventListener("keydown", handleKeyDown); | |
| return () => window.removeEventListener("keydown", handleKeyDown); | |
| }, []); | |
| // Test panel callbacks | |
| const handleSimulateOffline = useCallback(() => setSimulatedOffline(true), []); | |
| const handleSimulateOnline = useCallback(() => { | |
| setSimulatedOffline(false); | |
| setTimeout(() => { | |
| requestReconciliation(); | |
| flushOfflineQueue(); | |
| }, 100); | |
| }, [requestReconciliation, flushOfflineQueue]); | |
| const handleForceSync = useCallback(() => { | |
| requestReconciliation(); | |
| flushOfflineQueue(); | |
| wsReconnect(); | |
| }, [requestReconciliation, flushOfflineQueue, wsReconnect]); | |
| const handleClearLocalData = useCallback(async () => { | |
| const dbs = await indexedDB.databases(); | |
| for (const db of dbs) { | |
| if (db.name) indexedDB.deleteDatabase(db.name); | |
| } | |
| localStorage.clear(); | |
| window.location.reload(); | |
| }, []); | |
| const handleDuplicateUpdate = useCallback(() => { | |
| if (ydocRef.current) { | |
| const state = Y.encodeStateAsUpdate(ydocRef.current); | |
| Y.applyUpdate(ydocRef.current, state, "test-duplicate"); | |
| } | |
| }, []); | |
| const getStateVector = useCallback(() => { | |
| return ydocRef.current ? Y.encodeStateVector(ydocRef.current) : null; | |
| }, []); | |
| if (isLoading) { | |
| return ( | |
| <div className="flex items-center justify-center min-h-screen bg-background"> | |
| <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> | |
| </div> | |
| ); | |
| } | |
| // Count OTHER participants. When the WebSocket is connected the server sees | |
| // every tab/device (each opens its own socket), so connectedClients - 1 is the | |
| // authoritative count. connectedTabs is only a same-browser fallback for when | |
| // the socket is down — adding both double-counts local tabs. | |
| const otherParticipants = | |
| wsStatus === "connected" | |
| ? Math.max(0, connectedClients - 1) | |
| : connectedTabs; | |
| const getSyncBadge = () => { | |
| if (simulatedOffline) { | |
| return ( | |
| <div className="flex items-center gap-1.5 px-2 py-1 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded text-xs"> | |
| <WifiOff className="h-3 w-3" /> | |
| <span>Simulated Offline</span> | |
| </div> | |
| ); | |
| } | |
| if (wsStatus === "connected" || connectedTabs > 0) { | |
| return ( | |
| <div className="flex items-center gap-1.5 px-2 py-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 rounded text-xs"> | |
| <Circle className="h-2 w-2 bg-green-500 rounded-full animate-pulse" /> | |
| <span>Live</span> | |
| </div> | |
| ); | |
| } | |
| if (wsStatus === "connecting" || syncStatus.state === "syncing") { | |
| return ( | |
| <div className="flex items-center gap-1.5 px-2 py-1 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded text-xs"> | |
| <RefreshCw className="h-3 w-3 animate-spin" /> | |
| <span>Connecting...</span> | |
| </div> | |
| ); | |
| } | |
| if (wsStatus === "error") { | |
| return ( | |
| <div className="flex items-center gap-1.5 px-2 py-1 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 rounded text-xs"> | |
| <AlertCircle className="h-3 w-3" /> | |
| <span>Error</span> | |
| </div> | |
| ); | |
| } | |
| return null; | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-background flex flex-col"> | |
| {/* Header */} | |
| <header className="border-b border-border bg-card sticky top-0 z-10"> | |
| <div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between gap-4"> | |
| <div className="flex items-center gap-3 flex-1 min-w-0"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="shrink-0" | |
| onClick={() => router.push("/")} | |
| > | |
| <ArrowLeft className="h-4 w-4" /> | |
| </Button> | |
| <FileText className="h-5 w-5 text-muted-foreground shrink-0" /> | |
| <div className="relative flex-1 min-w-0"> | |
| <input | |
| ref={titleRef} | |
| type="text" | |
| value={title} | |
| onChange={handleTitleChange} | |
| onFocus={handleTitleFocus} | |
| onSelect={handleTitleSelect} | |
| onBlur={handleBlur} | |
| className="w-full bg-transparent text-lg font-medium text-foreground border-none outline-none focus:ring-0" | |
| placeholder="Document title..." | |
| /> | |
| <EditingIndicator remoteUsers={remoteUsers} field="title" /> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2 shrink-0"> | |
| <PresenceAvatars currentUser={currentUser} remoteUsers={remoteUsers} /> | |
| <div className="hidden sm:block">{getSyncBadge()}</div> | |
| {otherParticipants > 0 && ( | |
| <div className="hidden sm:flex items-center gap-1.5 px-2 py-1 bg-muted rounded text-xs text-muted-foreground"> | |
| <Users className="h-3 w-3" /> | |
| <span>{otherParticipants + 1}</span> | |
| </div> | |
| )} | |
| {(unsyncedCount > 0 || syncStatus.pendingUpdates > 0) && ( | |
| <div className="hidden sm:flex items-center gap-1.5 px-2 py-1 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded text-xs"> | |
| <CloudOff className="h-3 w-3" /> | |
| <span>{unsyncedCount || syncStatus.pendingUpdates} pending</span> | |
| </div> | |
| )} | |
| <div className="flex items-center gap-2 text-sm text-muted-foreground"> | |
| {saveStatus === "saving" && <Loader2 className="h-4 w-4 animate-spin" />} | |
| {saveStatus === "saved" && <Check className="h-4 w-4 text-green-500" />} | |
| </div> | |
| <Button variant="outline" size="sm" onClick={copyShareLink} className="gap-1.5"> | |
| {copied ? <Check className="h-3 w-3" /> : <Share2 className="h-3 w-3" />} | |
| <span className="hidden sm:inline">{copied ? "Copied!" : "Share"}</span> | |
| </Button> | |
| <Button size="sm" onClick={manualSave} className="gap-1.5"> | |
| <Save className="h-3 w-3" /> | |
| <span className="hidden sm:inline">Save</span> | |
| </Button> | |
| </div> | |
| </div> | |
| </header> | |
| {/* Sync Banner */} | |
| {effectiveOnline && otherParticipants > 0 && ( | |
| <div className="bg-green-50 dark:bg-green-900/20 border-b border-green-200 dark:border-green-800"> | |
| <div className="max-w-4xl mx-auto px-4 py-2 flex items-center justify-center gap-2 text-sm text-green-700 dark:text-green-400"> | |
| <Circle className="h-2 w-2 bg-green-500 rounded-full animate-pulse" /> | |
| <span> | |
| Real-time sync with {otherParticipants + 1} users | |
| </span> | |
| </div> | |
| </div> | |
| )} | |
| {/* Offline Banner */} | |
| {!effectiveOnline && ( | |
| <div className="bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800"> | |
| <div className="max-w-4xl mx-auto px-4 py-2 flex items-center justify-center gap-2 text-sm text-amber-700 dark:text-amber-400"> | |
| <WifiOff className="h-4 w-4" /> | |
| <span>Offline mode. Changes saved locally.</span> | |
| </div> | |
| </div> | |
| )} | |
| {/* Editor */} | |
| <main className="flex-1 max-w-4xl mx-auto w-full px-4 py-8 relative"> | |
| <EditingIndicator remoteUsers={remoteUsers} field="content" /> | |
| <textarea | |
| ref={contentRef} | |
| value={content} | |
| onChange={handleContentChange} | |
| onFocus={handleContentFocus} | |
| onSelect={handleContentSelect} | |
| onBlur={handleBlur} | |
| className="w-full h-full min-h-[calc(100vh-250px)] bg-transparent text-foreground text-base leading-relaxed resize-none border-none outline-none focus:ring-0" | |
| placeholder="Start writing your document..." | |
| spellCheck | |
| /> | |
| </main> | |
| {/* Footer */} | |
| <footer className="border-t border-border bg-card"> | |
| <div className="max-w-4xl mx-auto px-4 py-2 flex items-center justify-between text-sm text-muted-foreground"> | |
| <div className="flex items-center gap-4"> | |
| <span>{content.length} characters</span> | |
| <span>{content.split(/\s+/).filter(Boolean).length} words</span> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| <span className="text-xs font-mono truncate max-w-[200px]" title={documentId}> | |
| {documentId} | |
| </span> | |
| <span className="text-xs">User: {currentUser.name}</span> | |
| </div> | |
| </div> | |
| </footer> | |
| {/* Debug Panel */} | |
| <div className="fixed bottom-4 left-4 z-50"> | |
| <Button size="sm" variant="outline" onClick={() => setShowDebug(!showDebug)}> | |
| Debug | |
| </Button> | |
| {showDebug && ( | |
| <div className="absolute bottom-10 left-0 w-80 max-h-60 overflow-auto bg-black text-green-400 text-xs font-mono p-2 rounded shadow-lg"> | |
| <div>WS: {wsStatus} | Clients: {connectedClients}</div> | |
| <div>Doc ready: {isDocReady ? "yes" : "no"}</div> | |
| <div>Title: {title.slice(0,30)}</div> | |
| <div>Content len: {content.length}</div> | |
| <hr className="my-1 border-green-800" /> | |
| {debugLogs.map((log, i) => <div key={i}>{log}</div>)} | |
| </div> | |
| )} | |
| </div> | |
| {/* Test Panel */} | |
| <TestPanel | |
| documentId={documentId} | |
| isOnline={effectiveOnline} | |
| connectedTabs={otherParticipants} | |
| unsyncedCount={unsyncedCount} | |
| syncStatus={syncStatus} | |
| onSimulateOffline={handleSimulateOffline} | |
| onSimulateOnline={handleSimulateOnline} | |
| onForceSync={handleForceSync} | |
| onClearLocalData={handleClearLocalData} | |
| onDuplicateUpdate={handleDuplicateUpdate} | |
| getStateVector={getStateVector} | |
| /> | |
| </div> | |
| ); | |
| } | |
| // Import Users icon | |
| function Users(props: React.SVGProps<SVGSVGElement>) { | |
| return ( | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| width="24" | |
| height="24" | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth="2" | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| {...props} | |
| > | |
| <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /> | |
| <circle cx="9" cy="7" r="4" /> | |
| <path d="M22 21v-2a4 4 0 0 0-3-3.87" /> | |
| <path d="M16 3.13a4 4 0 0 1 0 7.75" /> | |
| </svg> | |
| ); | |
| } | |