"use client"; import type { AnyExtension } from "@tiptap/core"; import type { Editor } from "@tiptap/react"; import { type ComponentType, Suspense, useEffect, useMemo } from "react"; import { cn } from "../lib/utils"; import { type Collaborator, useEditorContext } from "./editor-provider"; import { retryableLazyEditor } from "./editor-lazy"; import { EditorLoadingPlaceholder } from "./editor-loading"; import { type CollaborationPeers, loadCollaborationPeers } from "./editor-peers"; /** * Props for TiptapEditor component. */ export interface TiptapEditorProps { /** Initial content for new documents (markdown string) */ initialContent?: string; /** Placeholder text when editor is empty */ placeholder?: string; /** Whether the editor is read-only */ readOnly?: boolean; /** Whether to auto-focus on mount */ autoFocus?: boolean; /** Custom className for the editor wrapper */ className?: string; /** Custom className for the editor content area */ contentClassName?: string; /** Callback when content changes */ onUpdate?: (editor: Editor) => void; /** Callback when selection changes */ onSelectionUpdate?: (editor: Editor) => void; /** Callback when editor is ready */ onReady?: (editor: Editor) => void; } /** * Cursor colors with contrasting text. */ const cursorColors: Record = { "#FF6B6B": { background: "#FF6B6B", text: "#FFFFFF" }, "#4ECDC4": { background: "#4ECDC4", text: "#000000" }, "#45B7D1": { background: "#45B7D1", text: "#000000" }, "#96CEB4": { background: "#96CEB4", text: "#000000" }, "#FFEAA7": { background: "#FFEAA7", text: "#000000" }, "#DDA0DD": { background: "#DDA0DD", text: "#000000" }, "#98D8C8": { background: "#98D8C8", text: "#000000" }, "#F7DC6F": { background: "#F7DC6F", text: "#000000" }, "#BB8FCE": { background: "#BB8FCE", text: "#000000" }, "#85C1E9": { background: "#85C1E9", text: "#000000" }, }; /** * Get cursor label colors based on user color. */ function getCursorColors(color: string) { return cursorColors[color] ?? { background: color, text: "#FFFFFF" }; } /** * Builds the collaborative editor against loaded tiptap namespaces. The peers * arrive as an argument so that this module reaches them through type-only * imports, which a bundler erases. */ export function createTiptapEditor( peers: CollaborationPeers, ): ComponentType { const { EditorContent, useEditor } = peers.react; const StarterKit = peers.starterKit.default; const Collaboration = peers.collaboration.default; const CollaborationCaret = peers.collaborationCaret.default; return function TiptapEditor({ initialContent, placeholder = "Start writing...", readOnly = false, autoFocus = false, className, contentClassName, onUpdate, onSelectionUpdate, onReady, }: TiptapEditorProps) { const { doc, provider, connectionState } = useEditorContext(); // Y.js fragment for the editor content const fragment = useMemo(() => doc.getXmlFragment("prosemirror"), [doc]); // Configure Tiptap extensions const extensions = useMemo(() => { const baseExtensions: AnyExtension[] = [ StarterKit.configure({ // Disable history - Y.js handles undo/redo ...({ history: false } as any), // Configure code block for syntax highlighting placeholder codeBlock: { HTMLAttributes: { class: "hljs", }, }, }), Collaboration.configure({ fragment, }), ]; // Add collaboration cursor if provider is available if (provider?.awareness) { baseExtensions.push( CollaborationCaret.configure({ provider, user: provider.awareness.getLocalState()?.user ?? { name: "Anonymous", color: "#808080", }, render: (user: { name: string; color: string }) => { const { background, text } = getCursorColors(user.color); const cursor = document.createElement("span"); cursor.className = "collaboration-cursor"; cursor.style.borderColor = background; const label = document.createElement("span"); label.className = "collaboration-cursor-label"; label.style.backgroundColor = background; label.style.color = text; label.textContent = user.name; cursor.appendChild(label); return cursor; }, }), ); } return baseExtensions; }, [fragment, provider]); // Initialize Tiptap editor const editor = useEditor({ extensions, editable: !readOnly, autofocus: autoFocus, editorProps: { attributes: { class: cn( "prose prose-sm sm:prose-base dark:prose-invert max-w-none", "focus:outline-none", contentClassName, ), "data-placeholder": placeholder, }, }, onUpdate: ({ editor: ed }) => { onUpdate?.(ed); }, onSelectionUpdate: ({ editor: ed }) => { onSelectionUpdate?.(ed); }, onCreate: ({ editor: ed }) => { onReady?.(ed); }, }); // Update editable state when readOnly changes useEffect(() => { if (editor) { editor.setEditable(!readOnly); } }, [editor, readOnly]); // Handle initial content (only for new documents) useEffect(() => { if ( editor && initialContent && connectionState === "synced" && editor.isEmpty ) { editor.commands.setContent(initialContent); } }, [editor, initialContent, connectionState]); return (
{/* Connection status indicator */}
{/* Editor content */}
{/* Placeholder when empty */}
); }; } const lazyTiptapEditor = retryableLazyEditor(async () => createTiptapEditor(await loadCollaborationPeers()), ); /** * TiptapEditor - Collaborative markdown editor with Y.js sync. * Must be used within an EditorProvider. Loads its tiptap, Hocuspocus and * yjs peers on first render. */ export function TiptapEditor(props: TiptapEditorProps) { const LazyTiptapEditor = lazyTiptapEditor(); return ( } > ); } /** * Connection status indicator component. */ function ConnectionIndicator({ state, }: { state: "disconnected" | "connecting" | "connected" | "synced"; }) { const config = { disconnected: { color: "bg-red-500", label: "Disconnected", }, connecting: { color: "bg-yellow-500 animate-pulse", label: "Connecting...", }, connected: { color: "bg-blue-500", label: "Connected", }, synced: { color: "bg-green-500", label: "Synced", }, }[state]; return (
{config.label}
); } /** * Collaborators list component. * Shows active users in the document. */ export function CollaboratorsList({ collaborators, className, }: { collaborators: Collaborator[]; className?: string; }) { if (collaborators.length === 0) { return null; } return (
{collaborators.slice(0, 5).map((collab) => (
{collab.user.name.charAt(0).toUpperCase()}
))} {collaborators.length > 5 && (
+{collaborators.length - 5}
)}
); } export { EditorToolbar } from "./editor-toolbar";