distributed-collaborative-editor / components /document-editor-sync.tsx
ayushkokande's picture
Upload folder using huggingface_hub
4ce3e23 verified
Raw
History Blame Contribute Delete
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>
);
}