mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -12,6 +12,8 @@ use std::collections::BTreeMap;
|
||||
pub struct EditorGeneralSettings {
|
||||
/// Change font size with ⌘ + mouse wheel.
|
||||
pub wheel_zoom: bool,
|
||||
pub markdown_image_folder: String,
|
||||
pub markdown_image_name_pattern: String,
|
||||
/// Drag-and-drop of selected text inside the editor.
|
||||
pub drag_and_drop: bool,
|
||||
/// When the caret moves out of view: `minimize` scrolling | `center` the caret line.
|
||||
@@ -35,6 +37,8 @@ impl Default for EditorGeneralSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wheel_zoom: false,
|
||||
markdown_image_folder: "assets".into(),
|
||||
markdown_image_name_pattern: "image-{timestamp}".into(),
|
||||
drag_and_drop: true,
|
||||
caret_scroll: "minimize".into(),
|
||||
soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(),
|
||||
|
||||
@@ -49,6 +49,10 @@ pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_file_bytes(host: &Host, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
|
||||
host.blocking(move || ide_vfs::create_file_bytes(&path, &data)).await
|
||||
}
|
||||
|
||||
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
|
||||
/// state). Returns whether the file is read-only now.
|
||||
pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) -> IdeResult<bool> {
|
||||
|
||||
@@ -168,6 +168,9 @@ impl Dispatcher {
|
||||
.add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::<TaskId>("id")?)) })
|
||||
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_file_bytes", |c| async move {
|
||||
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
|
||||
})
|
||||
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
|
||||
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
|
||||
.add("fs_set_writable", |c| async move {
|
||||
|
||||
@@ -232,6 +232,12 @@ pub fn save_text(path: &Path, text: &str, encoding: &str, has_bom: bool) -> IdeR
|
||||
pub fn create_file(path: &Path) -> IdeResult<()> {
|
||||
ops::create_file(path)
|
||||
}
|
||||
|
||||
/// Creates a binary file exclusively, including missing parent directories.
|
||||
pub fn create_file_bytes(path: &Path, data: &[u8]) -> IdeResult<()> {
|
||||
ops::create_file_bytes(path, data)
|
||||
}
|
||||
|
||||
/// Creates a directory and missing parents; `AlreadyExists` if present.
|
||||
pub fn create_dir(path: &Path) -> IdeResult<()> {
|
||||
ops::create_dir(path)
|
||||
|
||||
@@ -35,6 +35,17 @@ pub(crate) fn create_file(path: &Path) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn create_file_bytes(path: &Path, data: &[u8]) -> IdeResult<()> {
|
||||
use std::io::Write;
|
||||
if exists(path) {
|
||||
return Err(already_exists(path));
|
||||
}
|
||||
ensure_parent(path)?;
|
||||
let mut file = fs::OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
file.write_all(data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// IDEA's "Toggle Read-only Attribute": read-only clears every write bit,
|
||||
/// writable adds the owner's.
|
||||
pub(crate) fn set_writable(path: &Path, writable: bool) -> IdeResult<()> {
|
||||
|
||||
@@ -367,3 +367,34 @@ fn worktree_and_nested_repository_local_excludes_are_reflected_in_listings() {
|
||||
let entries = ide_vfs::read_dir(&nested, &[]).unwrap();
|
||||
assert!(entries.iter().find(|e| e.name == "private").unwrap().ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_file_bytes_is_exclusive() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("image.png");
|
||||
let bytes = [0, 255, 128, 10];
|
||||
ide_vfs::create_file_bytes(&path, &bytes).unwrap();
|
||||
let error = ide_vfs::create_file_bytes(&path, b"replacement").unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::AlreadyExists);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_file_bytes_creates_parents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("nested/assets/image.png");
|
||||
ide_vfs::create_file_bytes(&path, &[0, 255]).unwrap();
|
||||
assert_eq!(std::fs::read(path).unwrap(), [0, 255]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn create_file_bytes_does_not_follow_existing_symlink() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let target = dir.path().join("target.png");
|
||||
let link = dir.path().join("link.png");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
let error = ide_vfs::create_file_bytes(&link, b"data").unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::AlreadyExists);
|
||||
assert!(!target.exists());
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ pub fn fs_create_file(window: Window, state: State<'_, AppState>, path: PathBuf)
|
||||
fs::create_file(&state.host, window.label(), path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fs_create_file_bytes(state: State<'_, AppState>, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
|
||||
fs::create_file_bytes(&state.host, path, data).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn fs_create_dir(path: PathBuf) -> IdeResult<()> {
|
||||
|
||||
@@ -126,6 +126,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
bytes::fs_read_range,
|
||||
bytes::fs_read_image,
|
||||
fs::fs_create_file,
|
||||
fs::fs_create_file_bytes,
|
||||
fs::fs_create_dir,
|
||||
fs::fs_rename,
|
||||
fs::fs_set_writable,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
@@ -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(
|
||||
"",
|
||||
);
|
||||
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 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");
|
||||
});
|
||||
});
|
||||
@@ -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 `})`;
|
||||
} 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);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "无法插入图片"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user