import { useEffect, useMemo, useState, type ReactElement } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { Extension } from "@tiptap/core"; import Underline from "@tiptap/extension-underline"; import TextAlign from "@tiptap/extension-text-align"; import Link from "@tiptap/extension-link"; import { TextStyle } from "@tiptap/extension-text-style"; import Color from "@tiptap/extension-color"; import Highlight from "@tiptap/extension-highlight"; import Placeholder from "@tiptap/extension-placeholder"; import FontFamily from "@tiptap/extension-font-family"; import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, ChevronDown, ChevronUp, Eraser, Highlighter, IndentDecrease, IndentIncrease, Italic, Link2, List, ListOrdered, RemoveFormatting, Quote, Redo2, Strikethrough, Underline as UnderlineIcon, Undo2, } from "lucide-react"; import { cn } from "@/lib/utils"; interface RichTextEditorProps { label: string; value: string; onChange: (html: string, text: string) => void; placeholder?: string; required?: boolean; error?: string; minHeightClassName?: string; } const FontSize = Extension.create({ name: "fontSize", addGlobalAttributes() { return [ { types: ["textStyle"], attributes: { fontSize: { default: null, parseHTML: (element: HTMLElement) => element.style.fontSize || null, renderHTML: (attributes: Record) => { if (!attributes.fontSize) return {}; return { style: `font-size: ${attributes.fontSize}` }; }, }, }, }, ]; }, addCommands() { return { setFontSize: (fontSize: string) => ({ chain }: { chain: any }) => { return chain().setMark("textStyle", { fontSize }).run(); }, unsetFontSize: () => ({ chain }: { chain: any }) => { return chain().setMark("textStyle", { fontSize: null }).run(); }, } as any; }, }); const ToolButton = ({ isActive, onClick, title, children, }: { isActive?: boolean; onClick: () => void; title: string; children: React.ReactNode; }): ReactElement => ( ); export const RichTextEditor = ({ label, value, onChange, placeholder = "Start writing...", required = false, error, minHeightClassName = "min-h-[300px]", }: RichTextEditorProps): ReactElement => { const [showMoreTools, setShowMoreTools] = useState(false); const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3], }, }), Underline, TextStyle, FontFamily.configure({ types: ["textStyle"], }), FontSize, Color, Highlight.configure({ multicolor: true, }), Link.configure({ openOnClick: false, autolink: true, }), TextAlign.configure({ types: ["heading", "paragraph"], }), Placeholder.configure({ placeholder, }), ], content: value || "", onUpdate: ({ editor: currentEditor }) => { onChange(currentEditor.getHTML(), currentEditor.getText()); }, }); useEffect(() => { if (!editor) return; if (value !== editor.getHTML()) { editor.commands.setContent(value || "", { emitUpdate: false }); } }, [value, editor]); const activeBlock = useMemo((): string => { if (!editor) return "paragraph"; if (editor.isActive("heading", { level: 1 })) return "h1"; if (editor.isActive("heading", { level: 2 })) return "h2"; if (editor.isActive("heading", { level: 3 })) return "h3"; return "paragraph"; }, [editor, editor?.state]); const activeFontFamily = editor?.getAttributes("textStyle").fontFamily || "Verdana"; const activeFontSize = editor?.getAttributes("textStyle").fontSize || "12pt"; const applyBlockStyle = (value: string): void => { if (!editor) return; const chain = editor.chain().focus(); if (value === "paragraph") { chain.setParagraph().run(); return; } const level = Number(value.replace("h", "")); if ([1, 2, 3].includes(level)) { chain.setHeading({ level: level as 1 | 2 | 3 }).run(); } }; return (
editor ?.chain() .focus() .unsetAllMarks() .clearNodes() .setParagraph() .unsetColor() .run() } >
editor?.chain().focus().toggleBold().run()} > editor?.chain().focus().toggleItalic().run()} > editor?.chain().focus().toggleUnderline().run()} >
editor?.chain().focus().setTextAlign("left").run()} > editor?.chain().focus().setTextAlign("center").run()} > editor?.chain().focus().setTextAlign("right").run()} > editor?.chain().focus().setTextAlign("justify").run()} >
editor?.chain().focus().setColor(event.target.value).run() } /> editor ?.chain() .focus() .setHighlight({ color: event.target.value }) .run() } /> setShowMoreTools((prev) => !prev)} > {showMoreTools ? ( ) : ( )}
{showMoreTools && (
editor?.chain().focus().toggleBulletList().run()} > editor?.chain().focus().toggleOrderedList().run()} > editor?.chain().focus().liftListItem("listItem").run() } > editor?.chain().focus().sinkListItem("listItem").run() } > editor?.chain().focus().toggleBlockquote().run()} > { if (!editor) return; const selection = editor.state.selection; const url = window.prompt("Enter URL"); if (url) { const normalizedUrl = /^https?:\/\//i.test(url) ? url : `https://${url}`; const isSelectionEmpty = selection.empty; const from = selection.from; const to = selection.to; if (isSelectionEmpty) { editor .chain() .focus() .setTextSelection({ from, to }) .insertContent(normalizedUrl) .setTextSelection({ from, to: from + normalizedUrl.length, }) .setLink({ href: normalizedUrl }) .run(); return; } editor .chain() .focus() .setTextSelection({ from, to }) .extendMarkRange("link") .setLink({ href: normalizedUrl }) .run(); } }} > editor?.chain().focus().unsetLink().run()} > editor?.chain().focus().toggleHighlight().run()} > editor?.chain().focus().toggleStrike().run()} > editor?.chain().focus().unsetAllMarks().clearNodes().run() } > editor?.chain().focus().undo().run()} > editor?.chain().focus().redo().run()} >
)}
{error &&

{error}

}
); };