tahamajs/IE / Projects /front /src /components /collaboration /CollaborativeEditor.tsx
tahamajs's picture
download
raw
9.26 kB
// src/components/collaboration/ScientificEditor.tsx
import { useState, useEffect } from "react";
import { EditorContent } from "@tiptap/react";
import { useCollaborativeEditor } from "@/hooks/useCollaborativeEditor";
import {
Eye, EyeOff, Users, Wifi, WifiOff, Loader2, Save, Download,
Bold, Italic, Strikethrough, Code, Quote, List, ListOrdered,
Heading1, Heading2, Heading3, Undo2, Redo2, ChevronDown,
} from "lucide-react";
import { saveAs } from "file-saver";
interface ScientificEditorProps {
docId: string;
initialTitle?: string;
}
export function ScientificEditor({ docId, initialTitle = "Untitled" }: ScientificEditorProps) {
const {
editor,
connected,
synced,
peers,
showPreview,
setShowPreview,
previewHtml,
wordCount,
docTitle,
setDocTitle,
undo,
redo,
updatePreview,
exportDocument,
} = useCollaborativeEditor(docId);
const [showExportMenu, setShowExportMenu] = useState(false);
const [charCount, setCharCount] = useState(0);
useEffect(() => {
if (!editor) return;
const update = () => {
const text = editor.state.doc.textContent;
setCharCount(text.length);
};
editor.on("update", update);
return () => { editor.off("update", update); };
}, [editor]);
const handleExport = async (format: "latex" | "pdf" | "docx" | "html") => {
const blob = await exportDocument(format);
if (blob) {
const ext = format === "latex" ? "tex" : format;
saveAs(blob, `${docTitle.replace(/\s+/g, "_")}.${ext}`);
}
setShowExportMenu(false);
};
if (!editor) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
// Helper to get peer's display name
const getPeerName = (p: any) => p.fullName || p.name || "Anonymous";
return (
<div className="max-w-6xl mx-auto bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-800 overflow-hidden">
<div className="sticky top-0 z-30 bg-white/95 dark:bg-gray-900/95 backdrop-blur border-b border-gray-200 dark:border-gray-800 px-4 py-2">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-xs">
{connected ? (
<span className="flex items-center gap-1 text-green-600"><Wifi className="w-3 h-3" /> Synced</span>
) : (
<span className="flex items-center gap-1 text-red-500"><WifiOff className="w-3 h-3" /> Offline</span>
)}
<span className="text-gray-300">|</span>
<span className="text-gray-500">{wordCount} words ยท {charCount} chars</span>
</div>
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
{peers.slice(0, 5).map((p, i) => (
<div
key={i}
className="w-7 h-7 rounded-full border-2 border-white dark:border-gray-900 flex items-center justify-center text-white text-[10px] font-bold"
style={{ backgroundColor: p.color }}
title={getPeerName(p)}
>
{getPeerName(p)[0]?.toUpperCase()}
</div>
))}
{peers.length > 5 && (
<div className="w-7 h-7 rounded-full bg-gray-200 dark:bg-gray-700 border-2 border-white dark:border-gray-900 flex items-center justify-center text-[10px] text-gray-500">
+{peers.length - 5}
</div>
)}
</div>
<button
onClick={() => {
setShowPreview(!showPreview);
if (!showPreview) updatePreview();
}}
className={`btn btn-ghost btn-xs gap-1 ${showPreview ? "text-primary" : ""}`}
>
{showPreview ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
Preview
</button>
<div className="relative">
<button
onClick={() => setShowExportMenu(!showExportMenu)}
className="btn btn-ghost btn-xs gap-1"
>
<Download className="w-3.5 h-3.5" /> Export <ChevronDown className="w-3 h-3" />
</button>
{showExportMenu && (
<div
className="absolute right-0 mt-1 w-44 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 z-40 py-1"
onClick={() => setShowExportMenu(false)}
>
{[
{ f: "latex", l: "๐Ÿ“ LaTeX (.tex)" },
{ f: "pdf", l: "๐Ÿ“• PDF" },
{ f: "docx", l: "๐Ÿ“„ Word (.docx)" },
{ f: "html", l: "๐ŸŒ HTML" },
].map((o) => (
<button
key={o.f}
onClick={() => handleExport(o.f as any)}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
>
{o.l}
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-0.5">
<ToolBtn onClick={undo} title="Undo"><Undo2 className="w-3.5 h-3.5" /></ToolBtn>
<ToolBtn onClick={redo} title="Redo"><Redo2 className="w-3.5 h-3.5" /></ToolBtn>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5 self-center" />
<ToolBtn onClick={() => (editor.chain().focus() as any).toggleBold().run()} active={editor.isActive("bold")}><Bold className="w-3.5 h-3.5" /></ToolBtn>
<ToolBtn onClick={() => (editor.chain().focus() as any).toggleItalic().run()} active={editor.isActive("italic")}><Italic className="w-3.5 h-3.5" /></ToolBtn>
<ToolBtn onClick={() => (editor.chain().focus() as any).toggleStrike().run()} active={editor.isActive("strike")}><Strikethrough className="w-3.5 h-3.5" /></ToolBtn>
<ToolBtn onClick={() => (editor.chain().focus() as any).toggleCode().run()} active={editor.isActive("code")}><Code className="w-3.5 h-3.5" /></ToolBtn>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5 self-center" />
<ToolBtn onClick={() => (editor.chain().focus() as any).toggleHeading({ level: 1 }).run()} active={editor.isActive("heading", { level: 1 })}><Heading1 className="w-3.5 h-3.5" /></ToolBtn>
<ToolBtn onClick={() => (editor.chain().focus() as any).toggleHeading({ level: 2 }).run()} active={editor.isActive("heading", { level: 2 })}><Heading2 className="w-3.5 h-3.5" /></ToolBtn>
<ToolBtn onClick={() => (editor.chain().focus() as any).toggleHeading({ level: 3 }).run()} active={editor.isActive("heading", { level: 3 })}><Heading3 className="w-3.5 h-3.5" /></ToolBtn>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5 self-center" />
<ToolBtn onClick={() => (editor.chain().focus() as any).toggleBulletList().run()} active={editor.isActive("bulletList")}><List className="w-3.5 h-3.5" /></ToolBtn>
<ToolBtn onClick={() => (editor.chain().focus() as any).toggleOrderedList().run()} active={editor.isActive("orderedList")}><ListOrdered className="w-3.5 h-3.5" /></ToolBtn>
<ToolBtn onClick={() => (editor.chain().focus() as any).toggleBlockquote().run()} active={editor.isActive("blockquote")}><Quote className="w-3.5 h-3.5" /></ToolBtn>
<ToolBtn onClick={() => (editor.chain().focus() as any).toggleCodeBlock().run()} active={editor.isActive("codeBlock")}><Code className="w-3.5 h-3.5" /></ToolBtn>
</div>
</div>
<div className="px-8 pt-6">
<input
type="text"
value={docTitle}
onChange={(e) => setDocTitle(e.target.value)}
className="w-full text-3xl font-bold bg-transparent border-none outline-none text-gray-900 dark:text-white"
placeholder="Untitled"
/>
</div>
<div className={`flex ${showPreview ? "divide-x divide-gray-200 dark:divide-gray-800" : ""}`}>
<div className={showPreview ? "w-1/2" : "w-full"}>
<EditorContent editor={editor} />
</div>
{showPreview && (
<div className="w-1/2 bg-gray-50/50 dark:bg-gray-800/50 p-8 max-h-[75vh] overflow-y-auto">
<div
className="prose prose-lg dark:prose-invert max-w-none font-serif"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
</div>
)}
</div>
</div>
);
}
function ToolBtn({
onClick,
active,
children,
title,
}: {
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title?: string;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
className={`p-1.5 rounded-md transition ${
active
? "bg-primary/10 text-primary"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
}`}
>
{children}
</button>
);
}

Xet Storage Details

Size:
9.26 kB
ยท
Xet hash:
3a95557bd53012d61689be3519a5297296559cbeb4f0974b9027ad11d364cffb

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.