"use client" import * as React from "react" import { EditorContent, useEditor, type Editor } from "@tiptap/react" import StarterKit from "@tiptap/starter-kit" import Link from "@tiptap/extension-link" import Placeholder from "@tiptap/extension-placeholder" import { BoldIcon, Code2Icon, EraserIcon, Heading1Icon, Heading2Icon, Heading3Icon, MinusIcon, PilcrowIcon, ItalicIcon, LinkIcon, ListIcon, ListOrderedIcon, QuoteIcon, Redo2Icon, StrikethroughIcon, Undo2Icon, UnlinkIcon, } from "lucide-react" import { cn } from "@/lib/utils" export type RichTextEditorProps = Omit, "defaultValue" | "onChange"> & { value?: string defaultValue?: string onValueChange?: (value: string) => void output?: "text" | "html" placeholder?: string editable?: boolean autoFocus?: boolean minHeight?: number maxHeight?: number maxLength?: number toolbar?: boolean stickyToolbar?: boolean toolbarSize?: "compact" | "default" features?: RichTextFeature[] showCharacterCount?: boolean showWordCount?: boolean onLinkRequest?: (currentHref?: string) => string | null | undefined labels?: { editor?: string toolbar?: string } } export type RichTextFeature = | "history" | "bold" | "italic" | "strike" | "code" | "heading1" | "heading2" | "heading3" | "paragraph" | "bulletList" | "orderedList" | "blockquote" | "codeBlock" | "horizontalRule" | "link" | "clearFormatting" const defaultFeatures: RichTextFeature[] = [ "history", "bold", "italic", "strike", "code", "heading1", "heading2", "heading3", "paragraph", "bulletList", "orderedList", "blockquote", "codeBlock", "horizontalRule", "link", "clearFormatting", ] type ToolbarAction = { feature: RichTextFeature group: string label: string icon: React.ComponentType<{ className?: string }> active?: boolean disabled?: boolean run: () => void } function escapeHtml(value: string) { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") } function contentForEditor(value: string, output: "text" | "html") { return output === "html" ? value : `

${escapeHtml(value)}

` } function EditorToolbar({ editor, onLinkRequest, label, features = defaultFeatures, sticky = false, size = "default" }: { editor: Editor; onLinkRequest?: RichTextEditorProps["onLinkRequest"]; label: string; features?: RichTextFeature[]; sticky?: boolean; size?: "compact" | "default" }) { const [, refresh] = React.useReducer((value) => value + 1, 0) React.useEffect(() => { const update = () => refresh() editor.on("transaction", update) editor.on("selectionUpdate", update) return () => { editor.off("transaction", update) editor.off("selectionUpdate", update) } }, [editor]) const linkHref = editor.getAttributes("link").href as string | undefined const actions: ToolbarAction[] = [ { feature: "history", group: "history", label: "Undo", icon: Undo2Icon, disabled: !editor.can().undo(), run: () => editor.chain().focus().undo().run() }, { feature: "history", group: "history", label: "Redo", icon: Redo2Icon, disabled: !editor.can().redo(), run: () => editor.chain().focus().redo().run() }, { feature: "bold", group: "marks", label: "Bold", icon: BoldIcon, active: editor.isActive("bold"), run: () => editor.chain().focus().toggleBold().run() }, { feature: "italic", group: "marks", label: "Italic", icon: ItalicIcon, active: editor.isActive("italic"), run: () => editor.chain().focus().toggleItalic().run() }, { feature: "strike", group: "marks", label: "Strike", icon: StrikethroughIcon, active: editor.isActive("strike"), run: () => editor.chain().focus().toggleStrike().run() }, { feature: "code", group: "marks", label: "Inline code", icon: Code2Icon, active: editor.isActive("code"), run: () => editor.chain().focus().toggleCode().run() }, { feature: "paragraph", group: "blocks", label: "Paragraph", icon: PilcrowIcon, active: editor.isActive("paragraph"), run: () => editor.chain().focus().setParagraph().run() }, { feature: "heading1", group: "blocks", label: "Heading 1", icon: Heading1Icon, active: editor.isActive("heading", { level: 1 }), run: () => editor.chain().focus().toggleHeading({ level: 1 }).run() }, { feature: "heading2", group: "blocks", label: "Heading 2", icon: Heading2Icon, active: editor.isActive("heading", { level: 2 }), run: () => editor.chain().focus().toggleHeading({ level: 2 }).run() }, { feature: "heading3", group: "blocks", label: "Heading 3", icon: Heading3Icon, active: editor.isActive("heading", { level: 3 }), run: () => editor.chain().focus().toggleHeading({ level: 3 }).run() }, { feature: "bulletList", group: "lists", label: "Bullet list", icon: ListIcon, active: editor.isActive("bulletList"), run: () => editor.chain().focus().toggleBulletList().run() }, { feature: "orderedList", group: "lists", label: "Ordered list", icon: ListOrderedIcon, active: editor.isActive("orderedList"), run: () => editor.chain().focus().toggleOrderedList().run() }, { feature: "blockquote", group: "lists", label: "Blockquote", icon: QuoteIcon, active: editor.isActive("blockquote"), run: () => editor.chain().focus().toggleBlockquote().run() }, { feature: "codeBlock", group: "insert", label: "Code block", icon: Code2Icon, active: editor.isActive("codeBlock"), run: () => editor.chain().focus().toggleCodeBlock().run() }, { feature: "horizontalRule", group: "insert", label: "Horizontal rule", icon: MinusIcon, run: () => editor.chain().focus().setHorizontalRule().run() }, { feature: "clearFormatting", group: "clear", label: "Clear formatting", icon: EraserIcon, run: () => editor.chain().focus().unsetAllMarks().clearNodes().run() }, ] if (onLinkRequest && features.includes("link")) { actions.push({ feature: "link", group: "link", label: "Set link", icon: LinkIcon, active: editor.isActive("link"), run: () => { const href = onLinkRequest(linkHref) if (href === null || href === undefined) return if (!href.trim()) editor.chain().focus().unsetLink().run() else editor.chain().focus().extendMarkRange("link").setLink({ href: href.trim() }).run() }, }) } if (editor.isActive("link")) { actions.push({ feature: "link", group: "link", label: "Remove link", icon: UnlinkIcon, run: () => editor.chain().focus().unsetLink().run() }) } const visibleActions = actions.filter((action) => features.includes(action.feature)) return (
{visibleActions.map((action, index) => { const Icon = action.icon const showSeparator = index > 0 && visibleActions[index - 1]?.group !== action.group return ( {showSeparator ? : null} ) })}
) } function MountedRichTextEditor({ value, defaultValue = "", onValueChange, output = "html", placeholder = "Start typing...", editable = true, autoFocus = false, minHeight = 144, maxHeight, maxLength, toolbar = true, stickyToolbar = false, toolbarSize = "default", features = defaultFeatures, showCharacterCount = false, showWordCount = false, onLinkRequest, labels, className, ...props }: RichTextEditorProps) { const initialContent = value ?? defaultValue const editor = useEditor({ immediatelyRender: true, autofocus: autoFocus, editable, editorProps: { attributes: { "aria-label": labels?.editor ?? "Rich text editor", role: "textbox", }, }, content: contentForEditor(initialContent, output), extensions: [ StarterKit.configure({ link: false }), Link.configure({ openOnClick: false, autolink: true, defaultProtocol: "https" }), Placeholder.configure({ placeholder }), ], onUpdate: ({ editor: nextEditor }) => { onValueChange?.(output === "html" ? nextEditor.getHTML() : nextEditor.getText()) }, }) const [, refreshCount] = React.useReducer((value) => value + 1, 0) React.useEffect(() => { if (!editor) return const update = () => refreshCount() editor.on("update", update) return () => { editor.off("update", update) } }, [editor]) React.useEffect(() => { if (!editor || value === undefined) return const currentValue = output === "html" ? editor.getHTML() : editor.getText() if (currentValue !== value) editor.commands.setContent(contentForEditor(value, output), { emitUpdate: false }) }, [editor, output, value]) React.useEffect(() => { editor?.setEditable(editable) }, [editable, editor]) return (
maxLength || undefined} className={cn("overflow-hidden rounded-md border bg-background shadow-sm transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/25 data-[disabled]:opacity-60 data-[over-limit=true]:border-destructive", className)} {...props} > {editor && editable && toolbar ? : null} {editor && (showCharacterCount || showWordCount || maxLength !== undefined) ?
{showWordCount ? {editor.getText().trim() ? editor.getText().trim().split(/\s+/).length : 0} words : null} {showCharacterCount || maxLength !== undefined ? maxLength && "font-medium text-destructive")}>{editor.getText().length}{maxLength !== undefined ? `/${maxLength}` : " characters"} : null}
: null}
) } function RichTextEditor(props: RichTextEditorProps) { const [mounted, setMounted] = React.useState(false) React.useEffect(() => { setMounted(true) }, []) if (!mounted) { return (
) } return } export { EditorToolbar, RichTextEditor }