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

373 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "&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, "\\$&");
}