fix: preserve pasted editor images and respect their format preference (#5568)

## Linked issue (required)

Fixes #5567

## Summary / motivation (required)

Pasting a base64 image in the new editor currently corrupts its bytes,
and the HTML paste path can put image markup into an image's `src`.
Decode base64 directly into bytes and return the saved filename from the
inline-image helper. Read the clipboard PNG preference's boolean value
so a disabled preference selects JPEG.

Add regressions through the public clipboard APIs for plain-text and
HTML data URLs and both image-format settings. Include editor route
sources and tests in the Vitest build inputs so edits to these
regressions reliably rerun the tests.

## Steps to reproduce (required, use N/A if not applicable)

Using a disposable profile on the base revision:

1. Open the new editor and disable "Paste without shift key strips
formatting" to enable extended paste.
2. Paste the base64 PNG data URL from the linked issue as plain text,
then as the source of an image in clipboard HTML. Inspect the stored
media bytes and inserted image source: the bytes change and the HTML
source is invalid.
3. Disable "Paste clipboard images as PNG" and paste a bitmap image
through the clipboard path. PNG is still selected.
4. After a successful `just test-ts`, edit only an editor route test and
rerun it. The build incorrectly reuses the prior Vitest result.

## How to test

- [x] `RELEASE=1 just check` passed on this focused upstream-based
branch (`ee16fc015`), using Apple Silicon macOS and the repository's
pinned toolchains.
- [x] Added or updated regression coverage for the changed behavior.

Clipboard regressions check exact decoded bytes, a local media filename
in the inserted image, and both PNG/JPEG preference values. Confirmed
route test edits invalidate the Vitest target. Clipboard/backend APIs
are mocked; no user collection is accessed.
## Risk / compatibility / migration (optional)

No database migration or new dependency. The legacy paste branch is
unchanged. The build input change only causes relevant source/test edits
to rerun Vitest. Complete local validation was on macOS; other platforms
depend on CI.

## UI evidence (required for visual changes; otherwise N/A)

N/A: no visual design changes. Regression tests inspect the inserted
image markup.

## Scope

- [x] This PR is focused on one change (no unrelated edits).

---------

Co-authored-by: Abdo <abdo@abdnh.net>
This commit is contained in:
praxish
2026-09-12 11:57:37 +03:00
committed by GitHub
co-authored by Abdo
parent f5a0b60806
commit 2fae55543c
3 changed files with 103 additions and 9 deletions
+1 -1
View File
@@ -346,7 +346,7 @@ fn check_web(build: &mut Build) -> Result<()> {
":node_modules",
":ts:generated",
glob!["ts/{svelte.config.js,vite.config.ts,tsconfig.json}"],
glob!["ts/{lib,deck-options,html-filter,domlib,reviewer,change-notetype}/**/*"],
glob!["ts/{lib,routes,reviewer}/**/*"],
],
},
)?;
@@ -2,18 +2,72 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
// @vitest-environment jsdom
import { playFile } from "@generated/backend";
import { beforeEach, expect, test, vi } from "vitest";
import { ConfigKey_Bool } from "@generated/anki/config_pb";
import { ConvertPastedImageResponse, ReadClipboardResponse } from "@generated/anki/frontend_pb";
import { Bool, String as GenericString } from "@generated/anki/generic_pb";
import {
addMediaFile,
convertPastedImage,
getAbsoluteMediaPath,
getConfigBool,
playFile,
readClipboard,
} from "@generated/backend";
import { Buffer } from "node:buffer";
import { webcrypto } from "node:crypto";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { filenameToLink, isAudio } from "./data-transfer";
import { pasteHTML } from "../old-editor-adapter";
import { filenameToLink, handlePaste, isAudio, readImageFromClipboard } from "./data-transfer";
// jsdom does not provide these clipboard APIs.
class TestDataTransfer {
files: File[] = [];
constructor(private data: Record<string, string>) {}
getData(type: string): string {
return this.data[type] ?? "";
}
}
class TestClipboardEvent extends Event {
readonly clipboardData: DataTransfer | null;
constructor(type: string, init: ClipboardEventInit) {
super(type, init);
this.clipboardData = init.clipboardData ?? null;
}
}
const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1cAAAAASUVORK5CYII=";
const pngBytes = new Uint8Array(Buffer.from(pngBase64, "base64"));
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
vi.stubGlobal("DataTransfer", TestDataTransfer);
vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
vi.stubGlobal("crypto", webcrypto);
vi.mocked(getConfigBool).mockResolvedValue(new Bool({ val: false }));
vi.mocked(addMediaFile).mockImplementation(async ({ desiredName }) => new GenericString({ val: desiredName }));
});
afterEach(() => {
vi.unstubAllGlobals();
});
vi.mock("@generated/backend", async (importOriginal) => ({
...(await importOriginal<object>()),
addMediaFile: vi.fn(),
convertPastedImage: vi.fn(),
getAbsoluteMediaPath: vi.fn(),
getConfigBool: vi.fn(),
playFile: vi.fn(),
readClipboard: vi.fn(),
}));
vi.mock("../old-editor-adapter", () => ({
pasteHTML: vi.fn(),
}));
test("isAudio recognizes audio/video suffixes regardless of case", () => {
@@ -41,3 +95,43 @@ test("filenameToLink returns bare filename if unrecognized", () => {
expect(link).toBe("test.foo");
expect(vi.mocked(playFile)).toHaveBeenCalledTimes(0);
});
test.each(["text/plain", "text/html"])(
"pasting a base64 PNG from %s preserves image bytes and inserts a local image",
async (type) => {
const url = `data:image/png;base64,${pngBase64}`;
const data = new TestDataTransfer({ [type]: type === "text/html" ? `<img src="${url}">` : url });
const event = new ClipboardEvent("paste", {
clipboardData: data as unknown as DataTransfer,
cancelable: true,
});
await handlePaste(event, false);
expect(addMediaFile).toHaveBeenCalledExactlyOnceWith({
desiredName: expect.stringMatching(/^paste-[0-9a-f]{40}\.png$/),
data: pngBytes,
});
const filename = vi.mocked(addMediaFile).mock.calls[0][0].desiredName;
const template = document.createElement("template");
template.innerHTML = vi.mocked(pasteHTML).mock.calls[0][0];
expect(template.content.querySelectorAll("img")).toHaveLength(1);
expect(template.content.querySelector("img")?.getAttribute("src")).toBe(filename);
},
);
test.each([
{ pasteAsPng: true, extension: "png" },
{ pasteAsPng: false, extension: "jpg" },
])("clipboard images use $extension when the PNG preference is $pasteAsPng", async ({ pasteAsPng, extension }) => {
vi.mocked(getConfigBool).mockResolvedValue(new Bool({ val: pasteAsPng }));
vi.mocked(readClipboard).mockResolvedValue(new ReadClipboardResponse({ data: { "image/png": pngBytes } }));
vi.mocked(convertPastedImage).mockResolvedValue(new ConvertPastedImageResponse({ data: pngBytes }));
vi.mocked(getAbsoluteMediaPath).mockImplementation(async ({ val }) => new GenericString({ val: `/media/${val}` }));
const path = await readImageFromClipboard();
expect(getConfigBool).toHaveBeenCalledWith({ key: ConfigKey_Bool.PASTE_IMAGES_AS_PNG });
expect(convertPastedImage).toHaveBeenCalledExactlyOnceWith({ data: pngBytes, ext: extension });
expect(path).toMatch(new RegExp(`^/media/paste-[0-9a-f]{40}\\.${extension}$`));
});
@@ -217,10 +217,10 @@ async function pastedImageFilename(data: ImageData, ext: string): Promise<string
}
async function addPastedImage(data: ImageData, ext: string, convert = false): Promise<string> {
const filename = await pastedImageFilename(data, ext);
if (convert) {
data = (await convertPastedImage({ data: imageDataToUint8Array(data), ext })).data;
}
const filename = await pastedImageFilename(data, ext);
return await addMediaFromData(filename, imageDataToUint8Array(data));
}
@@ -231,11 +231,11 @@ async function inlinedImageToFilename(src: string): Promise<string> {
const fullPrefix = prefix + ext + suffix;
if (src.startsWith(fullPrefix)) {
const b64data = src.slice(fullPrefix.length).trim();
const data = atob(b64data);
const data = Uint8Array.from(atob(b64data), (char) => char.charCodeAt(0));
if (ext === "jpeg") {
ext = "jpg";
}
return filenameToLink(await addPastedImage(data, ext));
return await addPastedImage(data, ext);
}
}
return "";
@@ -275,7 +275,7 @@ async function processUrls(
}
async function getPreferredImageExtension(): Promise<string> {
if (await getConfigBool({ key: ConfigKey_Bool.PASTE_IMAGES_AS_PNG })) {
if ((await getConfigBool({ key: ConfigKey_Bool.PASTE_IMAGES_AS_PNG })).val) {
return "png";
}
return "jpg";