443 lines
15 KiB
TypeScript
443 lines
15 KiB
TypeScript
|
|
"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}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|