diff --git a/crates/ide-core/src/editor_settings.rs b/crates/ide-core/src/editor_settings.rs index 62789be4..73cc773a 100644 --- a/crates/ide-core/src/editor_settings.rs +++ b/crates/ide-core/src/editor_settings.rs @@ -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(), diff --git a/crates/ide-host/src/ops/fs.rs b/crates/ide-host/src/ops/fs.rs index b9ef8af8..10681ca3 100644 --- a/crates/ide-host/src/ops/fs.rs +++ b/crates/ide-host/src/ops/fs.rs @@ -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) -> 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 { diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index eff39a1e..56055d2d 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -168,6 +168,9 @@ impl Dispatcher { .add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::("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::("path")?) }) .add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::("from")?, &c.arg::("to")?) }) .add("fs_set_writable", |c| async move { diff --git a/crates/ide-vfs/src/lib.rs b/crates/ide-vfs/src/lib.rs index e30b93c5..f1542f3d 100644 --- a/crates/ide-vfs/src/lib.rs +++ b/crates/ide-vfs/src/lib.rs @@ -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) diff --git a/crates/ide-vfs/src/ops.rs b/crates/ide-vfs/src/ops.rs index 6d100294..d256aa34 100644 --- a/crates/ide-vfs/src/ops.rs +++ b/crates/ide-vfs/src/ops.rs @@ -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<()> { diff --git a/crates/ide-vfs/tests/vfs.rs b/crates/ide-vfs/tests/vfs.rs index 267bff2e..76d2cdc7 100644 --- a/crates/ide-vfs/tests/vfs.rs +++ b/crates/ide-vfs/tests/vfs.rs @@ -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()); +} diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index d8cc9382..5ff8d0f1 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -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) -> IdeResult<()> { + fs::create_file_bytes(&state.host, path, data).await +} + #[tauri::command] #[specta::specta] pub fn fs_create_dir(path: PathBuf) -> IdeResult<()> { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0799187d..ab138820 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -126,6 +126,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src/bindings.ts b/src/bindings.ts index e930e76a..632aff17 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -151,6 +151,7 @@ export const commands = { * `charset` (writes the BOM for `utf-8-bom` / UTF-16). */ fsCreateFile: (path: string) => typedError(__TAURI_INVOKE("fs_create_file", { path })), + fsCreateFileBytes: (path: string, data: number[]) => typedError(__TAURI_INVOKE("fs_create_file_bytes", { path, data })), fsCreateDir: (path: string) => typedError(__TAURI_INVOKE("fs_create_dir", { path })), fsRename: (from: string, to: string) => typedError(__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. */ diff --git a/src/editor/core/cm/imageBytes.worker.ts b/src/editor/core/cm/imageBytes.worker.ts new file mode 100644 index 00000000..34af3f00 --- /dev/null +++ b/src/editor/core/cm/imageBytes.worker.ts @@ -0,0 +1,13 @@ +// Keep binary conversion off the editor thread, including for large clipboard images. +self.onmessage = async (event: MessageEvent) => { + 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) }); + } +}; diff --git a/src/editor/core/cm/markdownImages.test.ts b/src/editor/core/cm/markdownImages.test.ts new file mode 100644 index 00000000..5a391a1c --- /dev/null +++ b/src/editor/core/cm/markdownImages.test.ts @@ -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"); + }); +}); diff --git a/src/editor/core/cm/markdownImages.ts b/src/editor/core/cm/markdownImages.ts new file mode 100644 index 00000000..4b8675a5 --- /dev/null +++ b/src/editor/core/cm/markdownImages.ts @@ -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)[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 { + 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 { + 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("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); + }, + }, + }, + ); +} diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index 3767edce..99aa6507 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -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) { diff --git a/src/features/settings/pages/editor/general.tsx b/src/features/settings/pages/editor/general.tsx index 901550a5..009ae4ae 100644 --- a/src/features/settings/pages/editor/general.tsx +++ b/src/features/settings/pages/editor/general.tsx @@ -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: [ diff --git a/src/platform/editorSettings.ts b/src/platform/editorSettings.ts index 6c641297..1f1b3f99 100644 --- a/src/platform/editorSettings.ts +++ b/src/platform/editorSettings.ts @@ -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", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 78c7bd91..ad96e216 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "无法插入图片" } diff --git a/src/workbench/fileDrop.test.ts b/src/workbench/fileDrop.test.ts index a572ad01..5c218598 100644 --- a/src/workbench/fileDrop.test.ts +++ b/src/workbench/fileDrop.test.ts @@ -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); diff --git a/src/workbench/fileDrop.ts b/src/workbench/fileDrop.ts index b3d44656..80bd2331 100644 --- a/src/workbench/fileDrop.ts +++ b/src/workbench/fileDrop.ts @@ -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(); + +export function registerFileDropHandler(handler: FileDropHandler): () => void { + fileDropHandlers.add(handler); + return () => { + fileDropHandlers.delete(handler); + }; +} + export interface FileDropDeps { isDir(path: string): Promise; openFile(path: string, focus: boolean): Promise; @@ -46,6 +56,7 @@ export function handleFileDrop( y: number, deps: FileDropDeps = defaultDeps, ): Promise | 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);