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 authored and GitHub committed 2026-10-31 08:48:10 +08:00
1 parent f592ff5b05
commit df6ed5ccba
18 files changed
+498 -2

No files matched your search

+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);