diff --git a/app/components/RichTextEditor.tsx b/app/components/RichTextEditor.tsx index 6f203e1..9fb6c97 100644 --- a/app/components/RichTextEditor.tsx +++ b/app/components/RichTextEditor.tsx @@ -1,6 +1,7 @@ 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 { @@ -16,6 +17,13 @@ interface RichTextEditorProps { 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; } /** @@ -32,6 +40,7 @@ export function RichTextEditor({ 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. @@ -46,8 +55,9 @@ export function RichTextEditor({ heading: { levels: [2, 3] }, }), Link.configure({ openOnClick: false }), + Image.configure({ inline: false, allowBase64: true }), ], - content: defaultValue || "
", + content: swapCidToLogo(defaultValue || "", logoDataUrl), editorProps: { attributes: { class: "wysiwyg-editor", @@ -59,7 +69,7 @@ export function RichTextEditor({ // Keep editor disposed cleanly on unmount. useEffect(() => () => editor?.destroy(), [editor]); - const html = editor?.getHTML() ?? defaultValue; + const html = swapLogoToCid(editor?.getHTML() ?? defaultValue, logoDataUrl); if (!mounted) { // Server / pre-hydration fallback: a textarea so the value is still @@ -251,3 +261,29 @@ function ToolbarButton({ function Sep() { return ; } + +/** + * 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, "\\$&"); +} diff --git a/app/routes/app.settings.tsx b/app/routes/app.settings.tsx index d9ca14e..f6e1756 100644 --- a/app/routes/app.settings.tsx +++ b/app/routes/app.settings.tsx @@ -44,6 +44,9 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { if (cached) { logoPreviewDataUrl = `data:${cached.contentType};base64,${Buffer.from(cached.bytes).toString("base64")}`; } + } else if (settings.logoUrl) { + // External HTTPS URL — fine to display directly in the editor. + logoPreviewDataUrl = settings.logoUrl; } return { settings, logoPreviewDataUrl }; }; @@ -391,6 +394,7 @@ export default function SettingsRoute() { defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE} variables={EMAIL_VARS} minHeight={220} + logoDataUrl={logoPreviewDataUrl} />