feat(email): render shop logo inside WYSIWYG editor (cid swap)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useEditor, EditorContent } from "@tiptap/react";
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Link from "@tiptap/extension-link";
|
import Link from "@tiptap/extension-link";
|
||||||
|
import Image from "@tiptap/extension-image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface RichTextEditorProps {
|
interface RichTextEditorProps {
|
||||||
@@ -16,6 +17,13 @@ interface RichTextEditorProps {
|
|||||||
variables?: { token: string; label?: string }[];
|
variables?: { token: string; label?: string }[];
|
||||||
/** Min editor height in px. */
|
/** Min editor height in px. */
|
||||||
minHeight?: number;
|
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,
|
helpText,
|
||||||
variables = [],
|
variables = [],
|
||||||
minHeight = 200,
|
minHeight = 200,
|
||||||
|
logoDataUrl = null,
|
||||||
}: RichTextEditorProps) {
|
}: RichTextEditorProps) {
|
||||||
// TipTap calls into the DOM on init; defer mounting until after hydration
|
// TipTap calls into the DOM on init; defer mounting until after hydration
|
||||||
// so SSR markup matches the initial client render.
|
// so SSR markup matches the initial client render.
|
||||||
@@ -46,8 +55,9 @@ export function RichTextEditor({
|
|||||||
heading: { levels: [2, 3] },
|
heading: { levels: [2, 3] },
|
||||||
}),
|
}),
|
||||||
Link.configure({ openOnClick: false }),
|
Link.configure({ openOnClick: false }),
|
||||||
|
Image.configure({ inline: false, allowBase64: true }),
|
||||||
],
|
],
|
||||||
content: defaultValue || "<p></p>",
|
content: swapCidToLogo(defaultValue || "<p></p>", logoDataUrl),
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class: "wysiwyg-editor",
|
class: "wysiwyg-editor",
|
||||||
@@ -59,7 +69,7 @@ export function RichTextEditor({
|
|||||||
// Keep editor disposed cleanly on unmount.
|
// Keep editor disposed cleanly on unmount.
|
||||||
useEffect(() => () => editor?.destroy(), [editor]);
|
useEffect(() => () => editor?.destroy(), [editor]);
|
||||||
|
|
||||||
const html = editor?.getHTML() ?? defaultValue;
|
const html = swapLogoToCid(editor?.getHTML() ?? defaultValue, logoDataUrl);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
// Server / pre-hydration fallback: a textarea so the value is still
|
// Server / pre-hydration fallback: a textarea so the value is still
|
||||||
@@ -251,3 +261,29 @@ function ToolbarButton({
|
|||||||
function Sep() {
|
function Sep() {
|
||||||
return <span aria-hidden style={{ width: 1, background: "#c9cccf", margin: "2px 4px" }} />;
|
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, """);
|
||||||
|
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, "\\$&");
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
logoPreviewDataUrl = `data:${cached.contentType};base64,${Buffer.from(cached.bytes).toString("base64")}`;
|
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 };
|
return { settings, logoPreviewDataUrl };
|
||||||
};
|
};
|
||||||
@@ -391,6 +394,7 @@ export default function SettingsRoute() {
|
|||||||
defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE}
|
defaultValue={settings.emailBodyHtmlDe || DEFAULT_EMAIL_BODY_DE}
|
||||||
variables={EMAIL_VARS}
|
variables={EMAIL_VARS}
|
||||||
minHeight={220}
|
minHeight={220}
|
||||||
|
logoDataUrl={logoPreviewDataUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
@@ -405,6 +409,7 @@ export default function SettingsRoute() {
|
|||||||
defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN}
|
defaultValue={settings.emailBodyHtmlEn || DEFAULT_EMAIL_BODY_EN}
|
||||||
variables={EMAIL_VARS}
|
variables={EMAIL_VARS}
|
||||||
minHeight={220}
|
minHeight={220}
|
||||||
|
logoDataUrl={logoPreviewDataUrl}
|
||||||
/>
|
/>
|
||||||
</s-stack>
|
</s-stack>
|
||||||
</s-section>
|
</s-section>
|
||||||
|
|||||||
Generated
+14
@@ -18,6 +18,7 @@
|
|||||||
"@shopify/app-bridge-react": "^4.2.4",
|
"@shopify/app-bridge-react": "^4.2.4",
|
||||||
"@shopify/shopify-app-react-router": "^1.1.0",
|
"@shopify/shopify-app-react-router": "^1.1.0",
|
||||||
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
|
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
|
||||||
|
"@tiptap/extension-image": "^3.23.1",
|
||||||
"@tiptap/extension-link": "^3.23.1",
|
"@tiptap/extension-link": "^3.23.1",
|
||||||
"@tiptap/pm": "^3.23.1",
|
"@tiptap/pm": "^3.23.1",
|
||||||
"@tiptap/react": "^3.23.1",
|
"@tiptap/react": "^3.23.1",
|
||||||
@@ -4356,6 +4357,19 @@
|
|||||||
"@tiptap/pm": "3.23.1"
|
"@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": {
|
"node_modules/@tiptap/extension-italic": {
|
||||||
"version": "3.23.1",
|
"version": "3.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.1.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"@shopify/app-bridge-react": "^4.2.4",
|
"@shopify/app-bridge-react": "^4.2.4",
|
||||||
"@shopify/shopify-app-react-router": "^1.1.0",
|
"@shopify/shopify-app-react-router": "^1.1.0",
|
||||||
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
|
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
|
||||||
|
"@tiptap/extension-image": "^3.23.1",
|
||||||
"@tiptap/extension-link": "^3.23.1",
|
"@tiptap/extension-link": "^3.23.1",
|
||||||
"@tiptap/pm": "^3.23.1",
|
"@tiptap/pm": "^3.23.1",
|
||||||
"@tiptap/react": "^3.23.1",
|
"@tiptap/react": "^3.23.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user