mirror of
https://github.com/ankitects/anki.git
synced 2026-10-02 05:24:38 +08:00
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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user