From 67204d79ac3536f9d8f75045f407688e2fae3b29 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Sat, 9 May 2026 08:05:00 +0200 Subject: [PATCH] feat(email): render shop logo inside WYSIWYG editor (cid swap) --- app/components/RichTextEditor.tsx | 40 +++++++++++++++++++++++++++++-- app/routes/app.settings.tsx | 5 ++++ package-lock.json | 14 +++++++++++ package.json | 1 + 4 files changed, 58 insertions(+), 2 deletions(-) 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} /> diff --git a/package-lock.json b/package-lock.json index 3566846..a4031f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@shopify/app-bridge-react": "^4.2.4", "@shopify/shopify-app-react-router": "^1.1.0", "@shopify/shopify-app-session-storage-prisma": "^8.0.0", + "@tiptap/extension-image": "^3.23.1", "@tiptap/extension-link": "^3.23.1", "@tiptap/pm": "^3.23.1", "@tiptap/react": "^3.23.1", @@ -4356,6 +4357,19 @@ "@tiptap/pm": "3.23.1" } }, + "node_modules/@tiptap/extension-image": { + "version": "3.23.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.23.1.tgz", + "integrity": "sha512-rAyfh8HS0PfXS8PKl1VQUiDFzXtF5SlrILpOPmz+4Oc4pmI+/vN+ain4z8k6HRxWM03YVpvLvyeQ0OFwi/fq3A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.23.1" + } + }, "node_modules/@tiptap/extension-italic": { "version": "3.23.1", "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.1.tgz", diff --git a/package.json b/package.json index e6b277d..11cfb07 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@shopify/app-bridge-react": "^4.2.4", "@shopify/shopify-app-react-router": "^1.1.0", "@shopify/shopify-app-session-storage-prisma": "^8.0.0", + "@tiptap/extension-image": "^3.23.1", "@tiptap/extension-link": "^3.23.1", "@tiptap/pm": "^3.23.1", "@tiptap/react": "^3.23.1",