373 lines
12 KiB
TypeScript
373 lines
12 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 { TextStyle } from "@tiptap/extension-text-style";
|
||
import { Color } from "@tiptap/extension-color";
|
||
import { useEffect, useState } from "react";
|
||
|
||
const PRESET_COLORS = [
|
||
{ name: "Default", value: null as string | null },
|
||
{ name: "LinumIQ blue", value: "#0883DA" },
|
||
{ name: "Black", value: "#000000" },
|
||
{ name: "Grey", value: "#6d7175" },
|
||
{ name: "Red", value: "#d72c0d" },
|
||
{ name: "Green", value: "#1a7e3a" },
|
||
];
|
||
|
||
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), []);
|
||
|
||
// Mirror editor HTML into local state so the hidden <input> always
|
||
// reflects the latest content. Without this, React doesn't re-render
|
||
// when TipTap's content changes and the form submits stale HTML.
|
||
const initialHtml = swapCidToLogo(defaultValue || "<p></p>", logoDataUrl);
|
||
const [html, setHtml] = useState(initialHtml);
|
||
|
||
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 }),
|
||
TextStyle,
|
||
Color,
|
||
],
|
||
content: swapCidToLogo(defaultValue || "<p></p>", logoDataUrl),
|
||
onUpdate: ({ editor }) => {
|
||
setHtml(editor.getHTML());
|
||
},
|
||
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 submittedHtml = swapLogoToCid(html, 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 />
|
||
<ColorMenu editor={editor} />
|
||
<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={submittedHtml} />
|
||
{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" }} />;
|
||
}
|
||
|
||
function ColorMenu({ editor }: { editor: ReturnType<typeof useEditor> | null }) {
|
||
if (!editor) return null;
|
||
return (
|
||
<span style={{ display: "inline-flex", gap: 2, alignItems: "center" }} title="Text colour">
|
||
{PRESET_COLORS.map((c) => (
|
||
<button
|
||
key={c.name}
|
||
type="button"
|
||
onClick={() =>
|
||
c.value
|
||
? editor.chain().focus().setColor(c.value).run()
|
||
: editor.chain().focus().unsetColor().run()
|
||
}
|
||
title={c.name}
|
||
style={{
|
||
width: 20,
|
||
height: 20,
|
||
border: "1px solid #c9cccf",
|
||
borderRadius: 4,
|
||
background: c.value ?? "#fff",
|
||
cursor: "pointer",
|
||
position: "relative",
|
||
}}
|
||
>
|
||
{c.value === null ? (
|
||
<span
|
||
aria-hidden
|
||
style={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
fontSize: 11,
|
||
}}
|
||
>
|
||
×
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
))}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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, """);
|
||
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, """);
|
||
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, "\\$&");
|
||
}
|