"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(null); const yTitleRef = useRef(null); const yContentRef = useRef(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(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([]); 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(null); const isInitializedRef = useRef(false); const contentRef = useRef(null); const titleRef = useRef(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) => { updateTitle(e.target.value); }; const handleContentChange = (e: React.ChangeEvent) => { 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 (
); } // 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 (
Simulated Offline
); } if (wsStatus === "connected" || connectedTabs > 0) { return (
Live
); } if (wsStatus === "connecting" || syncStatus.state === "syncing") { return (
Connecting...
); } if (wsStatus === "error") { return (
Error
); } return null; }; return (
{/* Header */}
{getSyncBadge()}
{otherParticipants > 0 && (
{otherParticipants + 1}
)} {(unsyncedCount > 0 || syncStatus.pendingUpdates > 0) && (
{unsyncedCount || syncStatus.pendingUpdates} pending
)}
{saveStatus === "saving" && } {saveStatus === "saved" && }
{/* Sync Banner */} {effectiveOnline && otherParticipants > 0 && (
Real-time sync with {otherParticipants + 1} users
)} {/* Offline Banner */} {!effectiveOnline && (
Offline mode. Changes saved locally.
)} {/* Editor */}