feat(email): render shop logo inside WYSIWYG editor (cid swap)

This commit is contained in:
Gerhard Scheikl
2026-05-09 08:05:00 +02:00
parent 573dfbfd50
commit 67204d79ac
4 changed files with 58 additions and 2 deletions
+38 -2
View File
@@ -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 || "<p></p>",
content: swapCidToLogo(defaultValue || "<p></p>", 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 <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, "\\$&");
}
+5
View File
@@ -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}
/>
<Field
@@ -405,6 +409,7 @@ export default function SettingsRoute() {
defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN}
variables={EMAIL_VARS}
minHeight={220}
logoDataUrl={logoPreviewDataUrl}
/>
</s-stack>
</s-section>
+14
View File
@@ -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",
+1
View File
@@ -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",