import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Link from "@tiptap/extension-link"; import { useEffect, useState } from "react"; interface RichTextEditorProps { /** Hidden form field name; the rendered HTML is mirrored into it. */ name: string; /** Visible label rendered above the editor. */ label: string; /** Initial HTML loaded into the editor. */ defaultValue?: string; /** Optional helper text shown below the editor. */ helpText?: string; /** Insertable variable tokens shown as quick-insert buttons. */ variables?: { token: string; label?: string }[]; /** Min editor height in px. */ minHeight?: number; } /** * TipTap-based WYSIWYG editor that mirrors its HTML content into a hidden * input so the parent
can submit it like any other field. * * Variables like {{invoiceNumber}} are inserted as plain text — the email * renderer is responsible for substituting them at send time. */ export function RichTextEditor({ name, label, defaultValue = "", helpText, variables = [], minHeight = 200, }: RichTextEditorProps) { // TipTap calls into the DOM on init; defer mounting until after hydration // so SSR markup matches the initial client render. const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); const editor = useEditor({ immediatelyRender: false, extensions: [ StarterKit.configure({ // Drop heading levels we don't need to keep the toolbar focused. heading: { levels: [2, 3] }, }), Link.configure({ openOnClick: false }), ], content: defaultValue || "

", editorProps: { attributes: { class: "wysiwyg-editor", style: `min-height:${minHeight}px;padding:8px 12px;border:1px solid #c9cccf;border-top:0;border-radius:0 0 6px 6px;background:#fff;outline:none;`, }, }, }); // Keep editor disposed cleanly on unmount. useEffect(() => () => editor?.destroy(), [editor]); const html = editor?.getHTML() ?? defaultValue; if (!mounted) { // Server / pre-hydration fallback: a textarea so the value is still // submittable if JS fails or before TipTap mounts. return (
{label ? ( ) : null}