| // 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.