feat(editor): paste and drop images into Markdown files (#489)

Pasting or dropping an image into a Markdown editor saves it beside the document in `assets/` and inserts a URL-encoded relative link as one undo step, in local and remote projects. Existing names get numbered suffixes; the folder and clipboard name pattern are in Settings › Editor › General. Non-Markdown editors, text paste and other dropped files keep their behavior.

Fixes #308
This commit is contained in:
l0ng-ai
2026-10-31 08:48:10 +08:00
committed by GitHub
parent f592ff5b05
commit df6ed5ccba
18 changed files with 498 additions and 2 deletions
+3
View File
@@ -151,6 +151,7 @@ export const commands = {
* `charset` (writes the BOM for `utf-8-bom` / UTF-16).
*/
fsCreateFile: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("fs_create_file", { path })),
fsCreateFileBytes: (path: string, data: number[]) => typedError<null, IdeError>(__TAURI_INVOKE("fs_create_file_bytes", { path, data })),
fsCreateDir: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("fs_create_dir", { path })),
fsRename: (from: string, to: string) => typedError<null, IdeError>(__TAURI_INVOKE("fs_rename", { from, to })),
/**
@@ -2700,6 +2701,8 @@ export type EditorConfigProps = {
export type EditorGeneralSettings = {
/** Change font size with ⌘ + mouse wheel. */
wheelZoom?: boolean,
markdownImageFolder?: string,
markdownImageNamePattern?: string,
/** Drag-and-drop of selected text inside the editor. */
dragAndDrop?: boolean,
/** When the caret moves out of view: `minimize` scrolling | `center` the caret line. */
+13
View File
@@ -0,0 +1,13 @@
// Keep binary conversion off the editor thread, including for large clipboard images.
self.onmessage = async (event: MessageEvent<Blob | string>) => {
try {
const source = event.data;
const data =
typeof source === "string"
? Array.from(atob(source.slice(source.indexOf(",") + 1)), (byte) => byte.charCodeAt(0))
: Array.from(new Uint8Array(await source.arrayBuffer()));
self.postMessage({ data });
} catch (error) {
self.postMessage({ error: String(error) });
}
};
+132
View File
@@ -0,0 +1,132 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { history, undo } from "@codemirror/commands";
import { IpcError, commands } from "@/platform/ipc";
import { imageName, imageTarget, markdownImages, relativeImagePath, saveImage } from "./markdownImages";
const views: EditorView[] = [];
afterEach(() => {
views.splice(0).forEach((view) => view.destroy());
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function view(path = "/project/docs/readme.md") {
const editor = new EditorView({
state: EditorState.create({
doc: "before after",
selection: { anchor: 7 },
extensions: [history(), markdownImages({ path, folder: "assets", pattern: "image-{timestamp}" })],
}),
parent: document.body,
});
views.push(editor);
return editor;
}
function paste(editor: EditorView, files: File[]) {
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", { value: { files, getData: () => "plain text" } });
editor.contentDOM.dispatchEvent(event);
return event;
}
describe("Markdown images", () => {
it("retries exclusive name collisions without overwriting", async () => {
const create = vi
.fn()
.mockRejectedValueOnce(new IpcError({ kind: "alreadyExists", message: "exists" }))
.mockRejectedValueOnce(new IpcError({ kind: "alreadyExists", message: "exists" }))
.mockResolvedValue(null);
expect(await saveImage("/p/docs/readme.md", "assets", "photo.png", [0, 255], create)).toBe(
"![](assets/photo-2.png)",
);
expect(create.mock.calls.map(([path]) => path)).toEqual([
"/p/docs/assets/photo.png",
"/p/docs/assets/photo-1.png",
"/p/docs/assets/photo-2.png",
]);
});
it("does not retry permission failures", async () => {
const create = vi.fn().mockRejectedValue(new IpcError({ kind: "permissionDenied", message: "denied" }));
await expect(saveImage("/p/a.md", "assets", "a.png", [], create)).rejects.toThrow("denied");
expect(create).toHaveBeenCalledTimes(1);
});
it("computes nested paths and encodes spaces and Markdown delimiters", () => {
const target = imageTarget("/p/docs/nested/read me.md", "../../my assets", "a (1)#图.png");
expect(target).toBe("/p/my assets/a (1)#图.png");
expect(relativeImagePath("/p/docs/nested/read me.md", target)).toBe(
"../../my%20assets/a%20%281%29%23%E5%9B%BE.png",
);
expect(relativeImagePath("C:\\p\\docs\\readme.md", "C:\\p\\docs\\assets\\a b.png")).toBe("assets/a%20b.png");
expect(imageTarget("/p/a.md", "assets", "a.png")).toBe("/p/assets/a.png");
expect(() => imageTarget("/p/a.md", "/absolute", "a.png")).toThrow();
});
it("uses timestamps for clipboard images and preserves source names", () => {
expect(imageName("", "image/png", "image-{timestamp}", new Date(2026, 9, 31, 0, 15))).toBe(
"image-20261031-001500.png",
);
expect(imageName("photo.jpg", "image/jpeg", "anything")).toBe("photo.jpg");
});
it("leaves non-Markdown image paste and text paste untouched", () => {
expect(markdownImages({ path: "/p/code.ts", folder: "assets", pattern: "image" })).toEqual([]);
const editor = view();
const create = vi.spyOn(commands, "fsCreateFileBytes");
paste(editor, []);
expect(editor.state.doc.toString()).toBe("before plain textafter");
paste(view("/p/code.ts"), [new File(["png"], "a.png", { type: "image/png" })]);
expect(create).not.toHaveBeenCalled();
});
it.each([true, false])("distinguishes a source image.png from raw clipboard data (file: %s)", async (hasFile) => {
vi.stubGlobal("__TAURI_INTERNALS__", {});
vi.stubGlobal(
"Worker",
class {
onmessage?: (e: { data: { data: number[] } }) => void;
terminate() {}
postMessage() {
queueMicrotask(() => this.onmessage?.({ data: { data: [137, 80, 78, 71] } }));
}
},
);
vi.spyOn(commands, "clipboardFileList").mockResolvedValue({
status: "ok",
data: hasFile ? ["/source/image.png"] : [],
});
const create = vi.spyOn(commands, "fsCreateFileBytes").mockResolvedValue({ status: "ok", data: null });
const editor = view();
paste(editor, [new File(["png"], "image.png", { type: "image/png" })]);
await vi.waitFor(() => expect(create).toHaveBeenCalledTimes(1));
expect(create.mock.calls[0]![0]).toMatch(hasFile ? /assets\/image\.png$/ : /assets\/image-\d{8}-\d{6}\.png$/);
});
it("inserts a paste as one isolated undo step and tracks edits during saving", async () => {
vi.stubGlobal(
"Worker",
class {
onmessage?: (e: { data: { data: number[] } }) => void;
terminate() {}
postMessage() {
queueMicrotask(() => this.onmessage?.({ data: { data: [137, 80, 78, 71] } }));
}
},
);
let finish!: (value: { status: "ok"; data: null }) => void;
vi.spyOn(commands, "fsCreateFileBytes").mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const editor = view();
expect(paste(editor, [new File(["png"], "a b.png", { type: "image/png" })]).defaultPrevented).toBe(true);
await vi.waitFor(() => expect(finish).toBeDefined());
editor.dispatch({ changes: { from: 0, insert: "X" }, userEvent: "input.type" });
finish({ status: "ok", data: null });
await vi.waitFor(() => expect(editor.state.doc.toString()).toBe("Xbefore ![](assets/a%20b.png)after"));
expect(undo(editor)).toBe(true);
expect(editor.state.doc.toString()).toBe("Xbefore after");
expect(undo(editor)).toBe(true);
expect(editor.state.doc.toString()).toBe("before after");
});
});
+227
View File
@@ -0,0 +1,227 @@
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
import { isolateHistory } from "@codemirror/commands";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { call, commands, IpcError, isTauri } from "@/platform/ipc";
import { invokeLocal } from "@/platform/tauriCore";
import { ipcPath } from "@/platform/paths";
import { tx } from "@/platform/i18n";
import { notifications } from "@/workbench/contributions";
import { registerFileDropHandler } from "@/workbench/fileDrop";
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|svg|ico|tiff?)$/i;
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
export function isMarkdown(path: string): boolean {
return /\.(md|markdown|mdown|mkd|mkdn|mdwn|mdx|ronn|workbook)$/i.test(path);
}
function normalize(path: string): string {
const parts: string[] = [];
for (const part of path.replace(/\\/g, "/").split("/")) {
if (part === ".." && parts.length && parts.at(-1) !== "..") parts.pop();
else if (part && part !== ".") parts.push(part);
}
return (path.startsWith("/") ? "/" : "") + parts.join("/");
}
export function imageTarget(markdown: string, folder: string, name: string): string {
const path = ipcPath(markdown);
const relative = folder.trim().replace(/\\/g, "/") || "assets";
if (relative.startsWith("/") || /^[a-z]:/i.test(relative)) throw new Error(tx("Image folder must be relative."));
return normalize(`${path.slice(0, path.lastIndexOf("/"))}/${relative}/${name}`);
}
export function relativeImagePath(markdown: string, target: string): string {
const from = normalize(ipcPath(markdown)).split("/").slice(0, -1);
const to = normalize(ipcPath(target)).split("/");
while (from.length && to.length && from[0] === to[0]) {
from.shift();
to.shift();
}
return [...from.map(() => ".."), ...to]
.map((part) =>
encodeURIComponent(part).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`),
)
.join("/");
}
export function imageName(source: string, mime: string, pattern: string, now = new Date()): string {
if (source) return source.replace(/^.*[\\/]/, "");
const pad = (n: number) => String(n).padStart(2, "0");
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const stem = (pattern.trim() || "image-{timestamp}")
.replaceAll("{timestamp}", timestamp)
// Filenames cannot contain control characters.
// eslint-disable-next-line no-control-regex
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "-");
const ext =
({ "image/jpeg": "jpg", "image/svg+xml": "svg", "image/x-icon": "ico" } as Record<string, string>)[mime] ||
mime.split("/")[1]?.replace(/[^a-z0-9]/gi, "") ||
"png";
return `${stem}.${ext}`;
}
export async function saveImage(
markdown: string,
folder: string,
name: string,
data: number[],
create = (path: string, bytes: number[]) => call(commands.fsCreateFileBytes(path, bytes)),
): Promise<string> {
const dot = name.lastIndexOf(".");
const stem = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : "";
for (let n = 0; ; n++) {
const target = imageTarget(markdown, folder, `${stem}${n ? `-${n}` : ""}${ext}`);
try {
await create(target, data);
return `![](${relativeImagePath(markdown, target)})`;
} catch (error) {
if (!(error instanceof IpcError) || error.kind !== "alreadyExists") throw error;
}
}
}
function imageBytes(blob: Blob | string): Promise<number[]> {
if ((typeof blob === "string" ? blob.length * 0.75 : blob.size) > MAX_IMAGE_BYTES)
return Promise.reject(new Error(tx("Image exceeds 16 MB.")));
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("./imageBytes.worker.ts", import.meta.url), { type: "module" });
worker.onmessage = (event: MessageEvent<{ data: number[]; error?: string }>) => {
worker.terminate();
if (event.data.error) reject(new Error(event.data.error));
else resolve(event.data.data);
};
worker.onerror = (event) => {
worker.terminate();
reject(new Error(event.message));
};
worker.postMessage(blob);
});
}
interface ImageOptions {
path: string;
folder: string;
pattern: string;
}
export function markdownImages(options: ImageOptions): Extension {
if (!isMarkdown(options.path)) return [];
return ViewPlugin.fromClass(
class {
alive = true;
pending = new Set<{ from: number; to: number }>();
unregister: () => void;
constructor(readonly view: EditorView) {
this.unregister = registerFileDropHandler((paths, x, y) => {
const rect = view.dom.getBoundingClientRect();
if (
x < rect.left ||
x > rect.right ||
y < rect.top ||
y > rect.bottom ||
!paths.every((p) => IMAGE_FILE.test(p)) ||
view.state.readOnly
)
return false;
this.insert(
paths.map((path) => async () => {
// OS paths belong to this client even in a remote project.
const url = await invokeLocal<string>("fs_read_image", { path });
return { blob: url, name: path.replace(/^.*[\\/]/, "") };
}),
view.posAtCoords({ x, y }) ?? view.state.selection.main.head,
);
return true;
});
}
update(update: ViewUpdate) {
for (const range of this.pending) {
range.from = update.changes.mapPos(range.from, 1);
range.to = update.changes.mapPos(range.to, 1);
}
}
destroy() {
this.alive = false;
this.unregister();
}
insert(sources: Array<() => Promise<{ blob: Blob | string; name: string }>>, position?: number) {
const selection = this.view.state.selection.main;
const range = { from: position ?? selection.from, to: position ?? selection.to };
this.pending.add(range);
void (async () => {
const links: string[] = [];
try {
for (const source of sources) {
const { blob, name } = await source();
if (!this.alive) return;
const bytes = await imageBytes(blob);
if (!this.alive) return;
links.push(
await saveImage(
options.path,
options.folder,
imageName(name, typeof blob === "string" ? "image/png" : blob.type, options.pattern),
bytes,
),
);
}
if (this.alive && !this.view.state.readOnly) {
const text = links.join("\n");
this.view.dispatch({
changes: { from: range.from, to: range.to, insert: text },
selection: EditorSelection.cursor(range.from + text.length),
annotations: isolateHistory.of("full"),
userEvent: "input.paste.image",
filter: false,
scrollIntoView: true,
});
}
} catch (error) {
notifications().notify({ level: "error", title: tx("Cannot insert image"), body: String(error) });
} finally {
this.pending.delete(range);
}
})();
}
files(event: ClipboardEvent | DragEvent): boolean {
if (this.view.state.facet(EditorState.readOnly)) return false;
const transfer = "clipboardData" in event ? event.clipboardData : event.dataTransfer;
const files = Array.from(transfer?.files ?? []).filter((f) => f.type.startsWith("image/"));
if (!files.length) return false;
event.preventDefault();
// WebKit names raw clipboard images "image.png", which can also be a real filename.
const copiedPaths =
event.type === "paste" && files.some((file) => file.name === "image.png") && isTauri()
? call(commands.clipboardFileList()).catch(() => [])
: Promise.resolve([] as string[]);
this.insert(
files.map((file) => async () => ({
blob: file,
name:
file.name === "image.png" &&
event.type === "paste" &&
!(await copiedPaths).some((path) => path.replace(/^.*[\\/]/, "") === file.name)
? ""
: file.name,
})),
event.type === "drop"
? (this.view.posAtCoords({ x: (event as DragEvent).clientX, y: (event as DragEvent).clientY }) ?? undefined)
: undefined,
);
return true;
}
},
{
eventHandlers: {
paste(event) {
return this.files(event);
},
drop(event) {
return this.files(event);
},
},
},
);
}
+3
View File
@@ -33,6 +33,7 @@ import {
insertNewlineKeepIndent,
selectLineStart,
} from "@codemirror/commands";
import { markdownImages } from "./markdownImages";
import type { EditorExtras } from "@/platform/editorSettings";
import { openideaHighlightStyle } from "./highlight";
import {
@@ -511,6 +512,8 @@ export function prefsExtension(o: PrefsOptions): Extension {
const g = prefs.general;
const k = prefs.smartKeys;
const out: Extension[] = [];
if (!o.readOnly)
out.push(markdownImages({ path: o.path, folder: g.markdownImageFolder, pattern: g.markdownImageNamePattern }));
if (g.scrollPastEnd) out.push(scrollPastEnd());
if ((o.wrapOverride ?? softWrapActive(o.softWrap, prefs, o.path)) && g.softWrapIndent) out.push(wrapIndent);
if (o.showWhitespace) {
@@ -95,6 +95,23 @@ const GENERAL = editorPage({
projectScope: true,
keywords: ["mouse", "scroll", "wrap", "trailing spaces", "save"],
sections: [
{
title: "Markdown Images",
items: [
{
t: "text",
key: `${P}.general.markdownImageFolder`,
label: "Image folder",
hint: "Relative to the Markdown file.",
},
{
t: "text",
key: `${P}.general.markdownImageNamePattern`,
label: "Image name pattern",
hint: "For clipboard images: {timestamp} becomes YYYYMMDD-HHmmss. Source file names are preserved.",
},
],
},
{
title: "Mouse Control",
items: [
+2
View File
@@ -67,6 +67,8 @@ export const EDITOR_EXTRA_DEFAULTS = {
boldFontWeight: 700,
general: {
wheelZoom: false,
markdownImageFolder: "assets",
markdownImageNamePattern: "image-{timestamp}",
dragAndDrop: true,
caretScroll: "minimize",
softWrapFiles: "*.md; *.txt; *.rst; *.adoc",
+9 -1
View File
@@ -5215,5 +5215,13 @@
"Pull request #{n} exists for current branch {branch}": "当前分支 {branch} 存在拉取请求 #{n}",
"Show Review Diff": "显示审查差异",
"Pull Request branch update failed": "拉取请求分支更新失败",
"#{n} on {branch}": "#{n} 位于 {branch}"
"#{n} on {branch}": "#{n} 位于 {branch}",
"Markdown Images": "Markdown 图片",
"Image folder": "图片文件夹",
"Relative to the Markdown file.": "相对于 Markdown 文件。",
"Image name pattern": "图片名称格式",
"For clipboard images: {timestamp} becomes YYYYMMDD-HHmmss. Source file names are preserved.": "剪贴板图片中的 {timestamp} 替换为 YYYYMMDD-HHmmss。保留源文件名称。",
"Image folder must be relative.": "图片文件夹必须使用相对路径。",
"Image exceeds 16 MB.": "图片超过 16 MB。",
"Cannot insert image": "无法插入图片"
}
+15 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useProject } from "@/platform/project";
import { useRemote } from "@/platform/remote";
import type { RemoteSession } from "@/platform/ipc";
import { handleFileDrop, type FileDropDeps } from "./fileDrop";
import { handleFileDrop, registerFileDropHandler, type FileDropDeps } from "./fileDrop";
function deps(dirs: string[] = []) {
const calls: string[] = [];
@@ -38,6 +38,20 @@ describe("files dropped from the OS", () => {
document.body.innerHTML = "";
});
it("lets an editor consume image drops before opening files, including remote windows", async () => {
useRemote.setState({ session: { id: "remote" } as unknown as RemoteSession });
const { d, calls } = deps();
const handler = vi.fn(() => true);
const unregister = registerFileDropHandler(handler);
try {
await handleFileDrop(["/local/image.png"], 1, 1, d);
expect(handler).toHaveBeenCalledWith(["/local/image.png"], 1, 1);
expect(calls).toEqual([]);
} finally {
unregister();
}
});
it("opens each dropped file in its own tab, focusing the last; works for files outside the project", async () => {
const { d, calls } = deps();
await handleFileDrop(["/p/a.ts", "/elsewhere/notes.md"], 1, 1, d);
+11
View File
@@ -12,6 +12,16 @@ import { editorService, OWNS_FILE_DROP } from "./contributions";
const OWNER_ATTR = Object.keys(OWNS_FILE_DROP)[0]!;
type FileDropHandler = (paths: readonly string[], x: number, y: number) => boolean;
const fileDropHandlers = new Set<FileDropHandler>();
export function registerFileDropHandler(handler: FileDropHandler): () => void {
fileDropHandlers.add(handler);
return () => {
fileDropHandlers.delete(handler);
};
}
export interface FileDropDeps {
isDir(path: string): Promise<boolean>;
openFile(path: string, focus: boolean): Promise<void>;
@@ -46,6 +56,7 @@ export function handleFileDrop(
y: number,
deps: FileDropDeps = defaultDeps,
): Promise<void> | null {
for (const handler of fileDropHandlers) if (paths.length && handler(paths, x, y)) return Promise.resolve();
const { project, lightEdit } = useProject.getState();
if (!paths.length || !(project || lightEdit) || isRemoteWindow() || dropOwnedAt(x, y)) return null;
return openDropped(paths, deps);