ubiquitous-invention/apps/web/components/editor/collaboration.tsx

443 lines
15 KiB
TypeScript
Raw Normal View History

"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<CollaborationConnection["doc"]>; provider: NonNullable<CollaborationConnection["provider"]> };
displayName: string;
cursorColor: string;
documentName: string;
placeholder?: string;
} & Pick<BlockEditorProps, "onChange" | "editable" | "className">) {
const containerRef = React.useRef<HTMLDivElement>(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 (
<TooltipProvider delayDuration={200}>
<div
className={cn(
"group/editor overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm",
className,
)}
>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/80 bg-muted/30 px-3 py-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<ConnectionStatus
status={connectionLabel}
onRetry={() => reconnectCollaboration(documentName)}
/>
<PresenceAvatars users={peers} />
</div>
</div>
<EditorToolbar editor={editor} />
<div ref={containerRef} className="relative bg-background dark:bg-background">
<BlockDragHandle editor={editor} containerRef={containerRef} />
<EditorContent editor={editor} className={editorContentStyles} />
{editor ? (
<BubbleMenu
editor={editor}
options={{
placement: "top",
}}
className="flex items-center gap-0.5 rounded-lg border border-border bg-popover p-1 shadow-lg"
>
<BubbleBtn
label="Bold"
shortcut="⌘B"
active={editor.isActive("bold")}
onClick={() => bubbleCh(editor).toggleBold().run()}
>
<Bold className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Italic"
shortcut="⌘I"
active={editor.isActive("italic")}
onClick={() => bubbleCh(editor).toggleItalic().run()}
>
<Italic className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Underline"
shortcut="⌘U"
active={editor.isActive("underline")}
onClick={() => bubbleCh(editor).toggleUnderline().run()}
>
<UnderlineIcon className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Strikethrough"
shortcut="⌘⇧S"
active={editor.isActive("strike")}
onClick={() => bubbleCh(editor).toggleStrike().run()}
>
<Strikethrough className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Code"
shortcut="⌘E"
active={editor.isActive("code")}
onClick={() => bubbleCh(editor).toggleCode().run()}
>
<Code className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Link"
shortcut="⌘K"
active={editor.isActive("link")}
onClick={() => {
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();
}}
>
<Link2 className="size-4" />
</BubbleBtn>
</BubbleMenu>
) : null}
</div>
</div>
<style>{`
.collaboration-cursor__label {
border-radius: 6px 6px 6px 0;
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
position: absolute;
top: -1.25em;
left: -1px;
white-space: nowrap;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
.collaboration-cursor__caret {
border-left: 2px solid;
border-right: none;
margin-left: -1px;
margin-right: -1px;
position: relative;
word-break: normal;
}
`}</style>
</TooltipProvider>
);
}
function BubbleBtn({
label,
shortcut,
active,
onClick,
children,
}: {
label: string;
shortcut: string;
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-8",
active && "bg-primary/15 text-primary hover:bg-primary/20",
)}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{label}
<span className="ml-2 text-xs opacity-70">{shortcut}</span>
</TooltipContent>
</Tooltip>
);
}
/**
* 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 <BlockEditor {...rest} />;
}
if (!collab.doc || !collab.provider) {
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm",
rest.className,
)}
>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/80 bg-muted/30 px-3 py-2">
<ConnectionStatus status="connecting" />
</div>
<div className="flex min-h-[280px] items-center justify-center bg-background px-6 text-sm text-muted-foreground">
Preparing collaboration
</div>
</div>
);
}
return (
<CollaborativeEditorShell
collab={
collab as CollaborationConnection & {
doc: NonNullable<CollaborationConnection["doc"]>;
provider: NonNullable<CollaborationConnection["provider"]>;
}
}
displayName={displayName}
cursorColor={cursorColor}
documentName={documentName}
placeholder={rest.placeholder}
onChange={rest.onChange}
editable={rest.editable}
className={rest.className}
/>
);
}