fix: preserve inline images when replying #163
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { plainTextToComposerBody } from "../email-composer-utils";
|
||||
import {
|
||||
plainTextToComposerBody,
|
||||
rewriteCidImagesForEditor,
|
||||
replaceInlineImagePlaceholders,
|
||||
INLINE_IMAGE_PLACEHOLDER,
|
||||
} from "../email-composer-utils";
|
||||
|
||||
describe("plainTextToComposerBody", () => {
|
||||
it("returns an empty string for empty input", () => {
|
||||
@@ -24,3 +29,86 @@ describe("plainTextToComposerBody", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteCidImagesForEditor", () => {
|
||||
it("returns input unchanged when no cid: refs are present", () => {
|
||||
const html = '<p>hi</p><img src="https://example.com/x.png">';
|
||||
expect(rewriteCidImagesForEditor(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(rewriteCidImagesForEditor("")).toBe("");
|
||||
});
|
||||
|
||||
it("rewrites a cid: src to placeholder + data-cid", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="cid:abc@x" alt="logo">'
|
||||
);
|
||||
expect(out).toContain('data-cid="abc@x"');
|
||||
expect(out).toContain(`src="${INLINE_IMAGE_PLACEHOLDER}"`);
|
||||
expect(out).toContain('alt="logo"');
|
||||
expect(out).not.toContain('src="cid:');
|
||||
});
|
||||
|
||||
it("preserves an existing data-cid attribute", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="cid:abc" data-cid="kept">'
|
||||
);
|
||||
expect(out).toContain('data-cid="kept"');
|
||||
expect(out).not.toContain('data-cid="abc"');
|
||||
});
|
||||
|
||||
it("leaves non-cid images alone", () => {
|
||||
const out = rewriteCidImagesForEditor(
|
||||
'<img src="https://example.com/x.png"><img src="cid:y">'
|
||||
);
|
||||
expect(out).toContain('src="https://example.com/x.png"');
|
||||
expect(out).toContain('data-cid="y"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceInlineImagePlaceholders", () => {
|
||||
it("returns input unchanged when the map is empty", () => {
|
||||
const html = '<img src="..." data-cid="x">';
|
||||
expect(replaceInlineImagePlaceholders(html, new Map())).toBe(html);
|
||||
});
|
||||
|
||||
it("swaps the placeholder src to the data URL for matching cids", () => {
|
||||
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="abc">`;
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="data:image/png;base64,AAAA"');
|
||||
expect(out).toContain('data-cid="abc"');
|
||||
});
|
||||
|
||||
it("also rewrites raw cid: src refs that lack a placeholder", () => {
|
||||
const html = '<img src="cid:abc" data-cid="abc">';
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="data:image/png;base64,AAAA"');
|
||||
});
|
||||
|
||||
it("does not overwrite images the user has re-pointed away from the cid", () => {
|
||||
const html =
|
||||
'<img src="https://example.com/other.png" data-cid="abc">';
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toContain('src="https://example.com/other.png"');
|
||||
expect(out).not.toContain("data:image/png;base64,AAAA");
|
||||
});
|
||||
|
||||
it("leaves unknown cids untouched", () => {
|
||||
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="missing">`;
|
||||
const out = replaceInlineImagePlaceholders(
|
||||
html,
|
||||
new Map([["abc", "data:image/png;base64,AAAA"]])
|
||||
);
|
||||
expect(out).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,3 +21,59 @@ export function plainTextToComposerBody(text: string): string {
|
||||
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Transparent 1x1 GIF used as a stand-in src while the real inline image is
|
||||
// being fetched from JMAP. Browsers cannot render `cid:` URLs directly, so
|
||||
// without this swap the editor would show a broken-image icon (issue #163).
|
||||
export const INLINE_IMAGE_PLACEHOLDER =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
|
||||
/**
|
||||
* Rewrites `<img src="cid:xxx">` references into `<img src="<placeholder>" data-cid="xxx">`
|
||||
* so TipTap can render the editor (the original cid: URL would 404) while still
|
||||
* carrying the cid through edits. The placeholder is swapped to the actual
|
||||
* image data once the corresponding inline blob has been fetched.
|
||||
*/
|
||||
export function rewriteCidImagesForEditor(html: string): string {
|
||||
if (!html || html.indexOf("cid:") === -1) return html;
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
||||
let touched = false;
|
||||
doc.querySelectorAll("img").forEach((img) => {
|
||||
const src = img.getAttribute("src") || "";
|
||||
if (!/^cid:/i.test(src)) return;
|
||||
const cid = src.slice(4);
|
||||
if (!cid) return;
|
||||
if (!img.getAttribute("data-cid")) {
|
||||
img.setAttribute("data-cid", cid);
|
||||
}
|
||||
img.setAttribute("src", INLINE_IMAGE_PLACEHOLDER);
|
||||
touched = true;
|
||||
});
|
||||
return touched ? doc.body.innerHTML : html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the placeholder src on `<img data-cid="...">` elements with the
|
||||
* resolved data URL once the inline blob has been fetched. Leaves images
|
||||
* whose src has been edited away from the placeholder/cid alone.
|
||||
*/
|
||||
export function replaceInlineImagePlaceholders(
|
||||
html: string,
|
||||
cidToDataUrl: Map<string, string>
|
||||
): string {
|
||||
if (!html || cidToDataUrl.size === 0) return html;
|
||||
if (html.indexOf("data-cid") === -1) return html;
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
||||
let changed = false;
|
||||
doc.querySelectorAll("img[data-cid]").forEach((img) => {
|
||||
const cid = img.getAttribute("data-cid");
|
||||
if (!cid) return;
|
||||
const dataUrl = cidToDataUrl.get(cid);
|
||||
if (!dataUrl) return;
|
||||
const currentSrc = img.getAttribute("src") || "";
|
||||
if (currentSrc !== INLINE_IMAGE_PLACEHOLDER && !/^cid:/i.test(currentSrc)) return;
|
||||
img.setAttribute("src", dataUrl);
|
||||
changed = true;
|
||||
});
|
||||
return changed ? doc.body.innerHTML : html;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user