import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Link from "@tiptap/extension-link"; import Image from "@tiptap/extension-image"; 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; /** * If set, occurrences of `cid:invoice-logo` (in src attributes) are * displayed using this data/HTTPS URL inside the editor, but mirrored * back to `cid:invoice-logo` in the submitted hidden field. This keeps * the stored template portable while showing the real logo while editing. */ logoDataUrl?: string | null; } /** * 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, logoDataUrl = null, }: 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 }), Image.configure({ inline: false, allowBase64: true }), ], content: swapCidToLogo(defaultValue || "

", logoDataUrl), 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 = swapLogoToCid(editor?.getHTML() ?? defaultValue, logoDataUrl); if (!mounted) { // Server / pre-hydration fallback: a textarea so the value is still // submittable if JS fails or before TipTap mounts. return (
{label ? ( ) : null}