Files
linumiq-invoice/app/components/RichTextEditor.tsx
T

304 lines
9.5 KiB
TypeScript

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 <Form> 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 }),
// Extend Image so the inline `style` attribute (e.g. max-height) is
// preserved on parse — TipTap's default Image only keeps src/alt/title.
Image.extend({
addAttributes() {
return {
...this.parent?.(),
style: {
default: "max-height:48px;",
parseHTML: (el) => (el as HTMLElement).getAttribute("style"),
renderHTML: (attrs) =>
attrs.style ? { style: attrs.style as string } : {},
},
};
},
}).configure({ inline: false, allowBase64: true }),
],
content: swapCidToLogo(defaultValue || "<p></p>", 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 (
<div>
{label ? (
<label style={{ display: "block", fontSize: 14, fontWeight: 500, marginBottom: 6 }}>
{label}
</label>
) : null}
<textarea
name={name}
defaultValue={defaultValue}
style={{ width: "100%", minHeight, padding: 8, border: "1px solid #c9cccf", borderRadius: 6 }}
/>
{helpText ? (
<div style={{ fontSize: 12, color: "#6d7175", marginTop: 4 }}>{helpText}</div>
) : null}
</div>
);
}
return (
<div>
{label ? (
<label style={{ display: "block", fontSize: 14, fontWeight: 500, marginBottom: 6 }}>
{label}
</label>
) : null}
<div
role="toolbar"
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
padding: 6,
border: "1px solid #c9cccf",
borderRadius: "6px 6px 0 0",
background: "#f6f6f7",
}}
>
<ToolbarButton
onClick={() => editor?.chain().focus().toggleBold().run()}
active={editor?.isActive("bold") ?? false}
label="B"
title="Bold"
bold
/>
<ToolbarButton
onClick={() => editor?.chain().focus().toggleItalic().run()}
active={editor?.isActive("italic") ?? false}
label="I"
title="Italic"
italic
/>
<ToolbarButton
onClick={() => editor?.chain().focus().toggleStrike().run()}
active={editor?.isActive("strike") ?? false}
label="S"
title="Strikethrough"
/>
<Sep />
<ToolbarButton
onClick={() => editor?.chain().focus().setParagraph().run()}
active={editor?.isActive("paragraph") ?? false}
label="¶"
title="Paragraph"
/>
<ToolbarButton
onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor?.isActive("heading", { level: 2 }) ?? false}
label="H2"
title="Heading 2"
/>
<ToolbarButton
onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor?.isActive("heading", { level: 3 }) ?? false}
label="H3"
title="Heading 3"
/>
<Sep />
<ToolbarButton
onClick={() => editor?.chain().focus().toggleBulletList().run()}
active={editor?.isActive("bulletList") ?? false}
label="• List"
title="Bullet list"
/>
<ToolbarButton
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
active={editor?.isActive("orderedList") ?? false}
label="1. List"
title="Numbered list"
/>
<Sep />
<ToolbarButton
onClick={() => {
const url = window.prompt("URL");
if (url) editor?.chain().focus().setLink({ href: url }).run();
else editor?.chain().focus().unsetLink().run();
}}
active={editor?.isActive("link") ?? false}
label="Link"
title="Insert/remove link"
/>
<Sep />
<ToolbarButton
onClick={() => editor?.chain().focus().undo().run()}
active={false}
label="↺"
title="Undo"
/>
<ToolbarButton
onClick={() => editor?.chain().focus().redo().run()}
active={false}
label="↻"
title="Redo"
/>
</div>
<EditorContent editor={editor} />
<input type="hidden" name={name} value={html} />
{variables.length > 0 ? (
<div style={{ marginTop: 6, display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center" }}>
<span style={{ fontSize: 12, color: "#6d7175", marginRight: 4 }}>Insert variable:</span>
{variables.map((v) => (
<button
key={v.token}
type="button"
onClick={() => editor?.chain().focus().insertContent(v.token).run()}
style={{
fontSize: 12,
padding: "2px 8px",
border: "1px solid #c9cccf",
borderRadius: 12,
background: "#fff",
cursor: "pointer",
}}
title={`Inserts ${v.token}`}
>
{v.label ?? v.token}
</button>
))}
</div>
) : null}
{helpText ? (
<div style={{ fontSize: 12, color: "#6d7175", marginTop: 4 }}>{helpText}</div>
) : null}
</div>
);
}
function ToolbarButton({
onClick,
active,
label,
title,
bold,
italic,
}: {
onClick: () => void;
active: boolean;
label: string;
title: string;
bold?: boolean;
italic?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
style={{
padding: "4px 10px",
background: active ? "#e3e5e7" : "#fff",
border: "1px solid #c9cccf",
borderRadius: 4,
fontWeight: bold ? 700 : 500,
fontStyle: italic ? "italic" : "normal",
cursor: "pointer",
fontSize: 13,
minWidth: 28,
}}
>
{label}
</button>
);
}
function Sep() {
return <span aria-hidden style={{ width: 1, background: "#c9cccf", margin: "2px 4px" }} />;
}
/**
* Replaces `src="cid:invoice-logo"` with the supplied URL so the editor
* can display the actual logo. Done as a string replace because TipTap
* doesn't render `cid:` URLs.
*/
function swapCidToLogo(html: string, logoUrl: string | null): string {
if (!logoUrl) return html;
const escaped = logoUrl.replace(/"/g, "&quot;");
return html
.replace(/src="cid:invoice-logo"/g, `src="${escaped}"`)
.replace(/src='cid:invoice-logo'/g, `src='${escaped}'`);
}
/** Inverse of swapCidToLogo — ensures the cid token is what we store. */
function swapLogoToCid(html: string, logoUrl: string | null): string {
if (!logoUrl) return html;
const escaped = logoUrl.replace(/"/g, "&quot;");
return html
.replace(new RegExp(`src="${escapeRegExp(escaped)}"`, "g"), 'src="cid:invoice-logo"')
.replace(new RegExp(`src='${escapeRegExp(escaped)}'`, "g"), "src='cid:invoice-logo'");
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}