"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 Link from "@tiptap/extension-link"; 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 { 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 { 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 { AiBlock } from "./extensions/ai-block"; 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", ); export type BlockEditorProps = { content?: string; onChange?: (html: string) => void; editable?: boolean; className?: string; placeholder?: string; }; export function BlockEditor({ content, onChange, editable = true, className, placeholder, }: BlockEditorProps) { const containerRef = React.useRef(null); const extensions = React.useMemo( () => [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, codeBlock: false, horizontalRule: false, link: { openOnClick: false, HTMLAttributes: { class: "text-primary underline underline-offset-2 decoration-primary/50", }, }, }), 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, AiBlock, ], [placeholder], ); const editor = useEditor( { immediatelyRender: false, extensions, content: content ?? "

", editable, editorProps: { attributes: { class: "focus:outline-none", }, }, onUpdate: ({ editor: ed }) => { onChange?.(ed.getHTML()); }, }, [extensions, editable], ); React.useEffect(() => { if (!editor) return; editor.setEditable(editable); }, [editable, editor]); return (
{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} ); }