"use client"; import * as React from "react"; import { useEditor, EditorContent, type Editor } from "@tiptap/react"; import { BubbleMenu } from "@tiptap/react/menus"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import Image from "@tiptap/extension-image"; import TaskList from "@tiptap/extension-task-list"; import TaskItem from "@tiptap/extension-task-item"; import Highlight from "@tiptap/extension-highlight"; import Typography from "@tiptap/extension-typography"; import TextAlign from "@tiptap/extension-text-align"; import HorizontalRule from "@tiptap/extension-horizontal-rule"; import CodeBlock from "@tiptap/extension-code-block"; import { Table, TableRow, TableCell, TableHeader } from "@tiptap/extension-table"; import { Collaboration } from "@tiptap/extension-collaboration"; import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; import { WebSocketStatus } from "@hocuspocus/provider"; import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code, Link2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { useCollaboration, useAwarenessPeers, colorFromUserName, reconnectCollaboration, type CollaborationConnection, } from "@/lib/yjs-provider"; import { PresenceAvatars, ConnectionStatus, type ConnectionUiStatus, } from "@/components/ui/presence"; import { SlashCommand } from "./slash-menu"; import { EditorToolbar } from "./toolbar"; import { BlockDragHandle } from "./drag-handle"; import { Callout } from "./extensions/callout"; import { Toggle } from "./extensions/toggle"; import { Mention } from "./extensions/mention"; import { Embed } from "./extensions/embed"; import { Divider } from "./extensions/divider"; import { BlockEditor, type BlockEditorProps } from "./editor"; function bubbleCh(editor: Editor) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return editor.chain().focus() as any; } const editorContentStyles = cn( "editor-prose min-h-[280px] w-full max-w-none px-14 py-10 text-[15px] leading-7 text-foreground antialiased", "focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[240px]", "[&_.ProseMirror_p.is-empty]:relative [&_.ProseMirror_p.is-empty::before]:pointer-events-none [&_.ProseMirror_p.is-empty::before]:float-left [&_.ProseMirror_p.is-empty::before]:h-0 [&_.ProseMirror_p.is-empty::before]:text-muted-foreground [&_.ProseMirror_p.is-empty::before]:content-[attr(data-placeholder)]", "[&_.ProseMirror_p]:my-1 [&_.ProseMirror_p]:leading-7", "[&_.ProseMirror_h1]:mb-3 [&_.ProseMirror_h1]:mt-8 [&_.ProseMirror_h1]:text-3xl [&_.ProseMirror_h1]:font-bold [&_.ProseMirror_h1]:tracking-tight [&_.ProseMirror_h1]:text-foreground", "[&_.ProseMirror_h2]:mb-2 [&_.ProseMirror_h2]:mt-6 [&_.ProseMirror_h2]:text-2xl [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h2]:tracking-tight", "[&_.ProseMirror_h3]:mb-2 [&_.ProseMirror_h3]:mt-5 [&_.ProseMirror_h3]:text-xl [&_.ProseMirror_h3]:font-semibold", "[&_.ProseMirror_ul]:my-2 [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-6", "[&_.ProseMirror_ol]:my-2 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-6", "[&_.ProseMirror_li]:my-0.5 [&_.ProseMirror_li_p]:my-0", "[&_.ProseMirror_blockquote]:my-3 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-teal [&_.ProseMirror_blockquote]:bg-muted/40 [&_.ProseMirror_blockquote]:py-1 [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:pr-2 [&_.ProseMirror_blockquote]:italic [&_.ProseMirror_blockquote]:text-muted-foreground", "[&_.ProseMirror_pre]:my-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_pre]:rounded-lg [&_.ProseMirror_pre]:border [&_.ProseMirror_pre]:border-border [&_.ProseMirror_pre]:bg-muted/80 [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:font-mono [&_.ProseMirror_pre]:text-sm", "[&_.ProseMirror_code]:rounded-md [&_.ProseMirror_code]:bg-muted [&_.ProseMirror_code]:px-1.5 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:font-mono [&_.ProseMirror_code]:text-[0.9em] [&_.ProseMirror_code]:text-foreground", "[&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0", "[&_.ProseMirror_hr]:my-6 [&_.ProseMirror_hr]:border-border", "[&_.ProseMirror_img]:my-4 [&_.ProseMirror_img]:max-h-[480px] [&_.ProseMirror_img]:w-auto [&_.ProseMirror_img]:max-w-full [&_.ProseMirror_img]:rounded-lg [&_.ProseMirror_img]:border [&_.ProseMirror_img]:border-border", "[&_.ProseMirror_table]:my-4 [&_.ProseMirror_table]:w-full [&_.ProseMirror_table]:border-collapse [&_.ProseMirror_table]:overflow-hidden [&_.ProseMirror_table]:rounded-md [&_.ProseMirror_table]:border [&_.ProseMirror_table]:border-border", "[&_.ProseMirror_td]:border [&_.ProseMirror_td]:border-border [&_.ProseMirror_td]:bg-background [&_.ProseMirror_td]:px-3 [&_.ProseMirror_td]:py-2", "[&_.ProseMirror_th]:border [&_.ProseMirror_th]:border-border [&_.ProseMirror_th]:bg-muted [&_.ProseMirror_th]:px-3 [&_.ProseMirror_th]:py-2 [&_.ProseMirror_th]:text-left [&_.ProseMirror_th]:font-medium", "[&_.ProseMirror_.ProseMirror-selectednode]:ring-2 [&_.ProseMirror_.ProseMirror-selectednode]:ring-primary/40", "dark:[&_.ProseMirror_blockquote]:bg-muted/20", ); function mapWsToUi(ws: WebSocketStatus): ConnectionUiStatus { if (ws === WebSocketStatus.Connected) return "connected"; if (ws === WebSocketStatus.Connecting) return "connecting"; return "disconnected"; } export type CollaborativeBlockEditorProps = BlockEditorProps & { documentId: string; userName?: string; userColor?: string; }; function CollaborativeEditorShell({ collab, displayName, cursorColor, documentName, placeholder, onChange, editable, className, }: { collab: CollaborationConnection & { doc: NonNullable; provider: NonNullable }; displayName: string; cursorColor: string; documentName: string; placeholder?: string; } & Pick) { const containerRef = React.useRef(null); const { doc, provider } = collab; const extensions = React.useMemo( () => [ StarterKit.configure({ undoRedo: false, heading: { levels: [1, 2, 3] }, codeBlock: false, horizontalRule: false, link: { openOnClick: false, HTMLAttributes: { class: "text-primary underline underline-offset-2 decoration-primary/50", }, }, }), Collaboration.configure({ document: doc, field: "default", provider, }), CollaborationCursor.configure({ provider, user: { name: displayName, color: cursorColor, }, }), Placeholder.configure({ placeholder: placeholder ?? 'Write something, or type "/" for commands…', }), CodeBlock.configure({ HTMLAttributes: { class: "font-mono text-sm", }, }), HorizontalRule, Image.configure({ HTMLAttributes: { class: "rounded-lg border border-border", }, }), TaskList, TaskItem.configure({ nested: true, HTMLAttributes: { class: "flex gap-2", }, }), Highlight.configure({ multicolor: true, }), Typography, TextAlign.configure({ types: ["heading", "paragraph"], }), Table.configure({ resizable: false, }), TableRow, TableHeader, TableCell, SlashCommand, Callout, Toggle, Mention, Embed, Divider, ], [cursorColor, displayName, doc, placeholder, provider], ); const editor = useEditor( { immediatelyRender: false, extensions, // Yjs is the source of truth; avoid seeding HTML that could fight the fragment. content: "", editable: editable ?? true, editorProps: { attributes: { class: "focus:outline-none", }, }, onUpdate: ({ editor: ed }) => { onChange?.(ed.getHTML()); }, }, [extensions, editable], ); React.useEffect(() => { if (!editor) return; editor.commands.updateUser({ name: displayName, color: cursorColor, }); }, [cursorColor, displayName, editor]); React.useEffect(() => { if (!editor) return; editor.setEditable(editable ?? true); }, [editable, editor]); const localClientId = doc.clientID; const peers = useAwarenessPeers(collab.awareness, localClientId); const connectionLabel = mapWsToUi(collab.wsStatus); return (
reconnectCollaboration(documentName)} />
{editor ? ( bubbleCh(editor).toggleBold().run()} > bubbleCh(editor).toggleItalic().run()} > bubbleCh(editor).toggleUnderline().run()} > bubbleCh(editor).toggleStrike().run()} > bubbleCh(editor).toggleCode().run()} > { const prev = editor.getAttributes("link").href as | string | undefined; const href = window.prompt("URL", prev ?? "https://"); if (href === null) return; if (href === "") { bubbleCh(editor).unsetLink().run(); return; } bubbleCh(editor).setLink({ href }).run(); }} > ) : null}
); } function BubbleBtn({ label, shortcut, active, onClick, children, }: { label: string; shortcut: string; active: boolean; onClick: () => void; children: React.ReactNode; }) { return ( {label} {shortcut} ); } /** * Block editor wired to Yjs + Hocuspocus with live cursors and presence. * Falls back to {@link BlockEditor} if the collaboration session cannot be established. */ export function CollaborativeBlockEditor({ documentId, userName, userColor, ...rest }: CollaborativeBlockEditorProps) { const documentName = `object:${documentId}`; const collab = useCollaboration(documentName); const displayName = userName?.trim() || "Anonymous"; const cursorColor = userColor ?? colorFromUserName(displayName); if (collab.error) { return ; } if (!collab.doc || !collab.provider) { return (
Preparing collaboration…
); } return ( ; provider: NonNullable; } } displayName={displayName} cursorColor={cursorColor} documentName={documentName} placeholder={rest.placeholder} onChange={rest.onChange} editable={rest.editable} className={rest.className} /> ); }