diff --git a/crates/ide-clipboard/src/lib.rs b/crates/ide-clipboard/src/lib.rs index 1c64c30b7..05bc3590d 100644 --- a/crates/ide-clipboard/src/lib.rs +++ b/crates/ide-clipboard/src/lib.rs @@ -1,6 +1,8 @@ //! The file list on the OS clipboard: what Explorer copies when the user //! copies files, so the project view can paste them, and what the project view -//! writes so Explorer can paste the files it copied (IDEA does both). +//! writes so Explorer can paste the files it copied (IDEA does both). The +//! written list comes with the paths as text, so a paste into an editor +//! inserts them instead of doing nothing. //! //! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL //! terminated list of absolute paths, wide when `fWide` is set. The data is set @@ -111,6 +113,8 @@ mod imp { /// `CF_HDROP`, the clipboard format holding a file list. const CF_HDROP: u32 = 15; + /// `CF_UNICODETEXT`, so an editor paste of copied files inserts the paths. + const CF_UNICODETEXT: u32 = 13; /// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here. type Handle = *mut c_void; @@ -182,12 +186,37 @@ mod imp { paths } + /// An `HGLOBAL` holding `text` as UTF-16 with a terminating NUL, and its + /// byte size. Null when the allocation failed. + fn global_text(text: &str) -> (Handle, usize) { + let mut units: Vec = text.encode_utf16().collect(); + units.push(0); + let bytes = units.len() * 2; + unsafe { + let memory = GlobalAlloc(GMEM_MOVEABLE, bytes); + if memory.is_null() { + return (ptr::null_mut(), 0); + } + let data = GlobalLock(memory); + if data.is_null() { + GlobalFree(memory); + return (ptr::null_mut(), 0); + } + ptr::copy_nonoverlapping(units.as_ptr() as *const u8, data as *mut u8, bytes); + GlobalUnlock(memory); + (memory, bytes) + } + } + pub fn set_files(paths: &[PathBuf]) -> bool { let bytes = encode(paths); if bytes.len() <= HEADER { return false; } - // SAFETY: the block is allocated, filled, handed to the clipboard and + // The paths as text, newline-separated, so pasting into an editor + // inserts them (IDEA writes the list and the text together). + let text = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::>().join("\r\n"); + // SAFETY: the blocks are allocated, filled, handed to the clipboard and // freed only on the paths where the clipboard did not take ownership. unsafe { let memory = GlobalAlloc(GMEM_MOVEABLE, bytes.len()); @@ -205,17 +234,27 @@ mod imp { GlobalFree(memory); return false; } - // Replacing the list also drops the text the user copied before. + // Replacing the list also drops the text the user copied before; + // the paths' own text is set back with it. if EmptyClipboard() == 0 { CloseClipboard(); GlobalFree(memory); return false; } - let taken = !SetClipboardData(CF_HDROP, memory).is_null(); - CloseClipboard(); + let mut taken = !SetClipboardData(CF_HDROP, memory).is_null(); if !taken { GlobalFree(memory); } + let (text_memory, _) = global_text(&text); + if !text_memory.is_null() { + // The text alone still pastes somewhere useful. + let text_taken = !SetClipboardData(CF_UNICODETEXT, text_memory).is_null(); + taken |= text_taken; + if !text_taken { + GlobalFree(text_memory); + } + } + CloseClipboard(); taken } } @@ -227,9 +266,10 @@ mod imp { use objc2::rc::{Retained, autoreleasepool}; use objc2::runtime::{AnyClass, AnyObject, ProtocolObject}; use objc2_app_kit::{ - NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting, + NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey, + NSPasteboardWriting, }; - use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSURL}; + use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL}; use std::path::PathBuf; /// `NSURL` as the one class to read, limited to file URLs: a web link the @@ -285,9 +325,15 @@ mod imp { let writers: Vec<&ProtocolObject> = urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect(); let board = NSPasteboard::generalPasteboard(); - // Replacing the list also drops the text the user copied before. + // Replacing the list also drops the text the user copied before; + // the paths' own text is set back with it, so an editor paste + // inserts them (IDEA writes the list and the text together). board.clearContents(); - board.writeObjects(&NSArray::from_slice(&writers)) + board.writeObjects(&NSArray::from_slice(&writers)); + let text = NSString::from_str( + &paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::>().join("\n"), + ); + board.setString_forType(&text, unsafe { NSPasteboardTypeString }) }) } } diff --git a/src/editor/core/cm/clipboard.test.ts b/src/editor/core/cm/clipboard.test.ts new file mode 100644 index 000000000..19b32b961 --- /dev/null +++ b/src/editor/core/cm/clipboard.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { commands } from "@/platform/ipc"; +import { clipboardFilesExtension, pasteEditorClipboard } from "./clipboard"; +import { markdownImages } from "./markdownImages"; + +const views: EditorView[] = []; +beforeEach(() => vi.stubGlobal("__TAURI_INTERNALS__", {})); +afterEach(() => { + views.splice(0).forEach((view) => view.destroy()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +for (const route of ["event", "menu"]) { + it.each([ + { name: "text present", text: "plain", paths: ["/stale"], expected: "plain", calls: 0 }, + { name: "files plus list", text: "", paths: ["/a b", "/中文"], expected: "/a b\n/中文", calls: 1 }, + { name: "files without list", text: "", paths: [], expected: "", calls: 1 }, + { name: "backend error", text: "", paths: null, expected: "", calls: 1 }, + ])(`${route}: $name`, async ({ text, paths, expected, calls }) => { + const list = vi.spyOn(commands, "clipboardFileList"); + if (paths) list.mockResolvedValue({ status: "ok", data: paths }); + else list.mockRejectedValue(new Error("busy")); + const view = new EditorView({ extensions: [clipboardFilesExtension], parent: document.body }); + views.push(view); + if (route === "event") { + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { getData: () => text, files: [], types: ["Files"] }, + }); + view.contentDOM.dispatchEvent(event); + } else { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => text } }); + await pasteEditorClipboard(view); + } + await vi.waitFor(() => expect(view.state.doc.toString()).toBe(expected)); + expect(list).toHaveBeenCalledTimes(calls); + }); +} + +it.each([true, false])("keeps copied PNG and screenshot Markdown image handling (file: %s)", async (hasFile) => { + 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 view = new EditorView({ + extensions: [ + clipboardFilesExtension, + markdownImages({ path: "/p/a.md", folder: "assets", pattern: "image-{timestamp}" }), + ], + parent: document.body, + }); + views.push(view); + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { getData: () => "", files: [new File(["png"], "image.png", { type: "image/png" })], types: ["Files"] }, + }); + view.contentDOM.dispatchEvent(event); + await vi.waitFor(() => expect(view.state.doc.toString()).toMatch(/^!\[\]\(assets\/image.*\.png\)$/)); + expect(create.mock.calls[0]![0]).toMatch(hasFile ? /assets\/image\.png$/ : /assets\/image-\d{8}-\d{6}\.png$/); +}); + +it("does not insert into a read-only editor", async () => { + const list = vi.spyOn(commands, "clipboardFileList"); + const view = new EditorView({ extensions: [clipboardFilesExtension, EditorState.readOnly.of(true)] }); + views.push(view); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "text" } }); + await pasteEditorClipboard(view); + expect(view.state.doc.toString()).toBe(""); + expect(list).not.toHaveBeenCalled(); +}); diff --git a/src/editor/core/cm/clipboard.ts b/src/editor/core/cm/clipboard.ts new file mode 100644 index 000000000..34bc3eaa7 --- /dev/null +++ b/src/editor/core/cm/clipboard.ts @@ -0,0 +1,29 @@ +import { Prec } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard"; + +export function pasteEditorText(view: EditorView, text: string) { + if (!text || !view.dom.isConnected || view.state.readOnly) return; + view.dispatch(view.state.replaceSelection(text), { userEvent: "input.paste", scrollIntoView: true }); + view.focus(); +} + +export async function pasteEditorClipboard(view: EditorView) { + try { + pasteEditorText(view, await readClipboardText()); + } catch { + if (!view.dom.isConnected || view.state.readOnly) return; + view.focus(); + document.execCommand("paste"); + } +} + +// Markdown image handlers get first refusal, including copied image files. +export const clipboardFilesExtension = Prec.lowest( + EditorView.domEventHandlers({ + paste(event, view) { + if (view.state.readOnly) return false; + return pasteClipboardFiles(event, (text) => pasteEditorText(view, text)); + }, + }), +); diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 152930b45..87f761815 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -51,6 +51,7 @@ import { editorBaseKeymap } from "./baseKeymap"; import { occurrenceSearch } from "./editing"; import { gutterLineDrag } from "./gutterDrag"; import { overwriteField } from "./overwrite"; +import { clipboardFilesExtension } from "./clipboard"; import { clipboardHistoryExtension } from "../clipboardHistory"; import type { LanguageInfo } from "../backend"; import { tx } from "@/platform/i18n"; @@ -336,6 +337,7 @@ export function baseExtensions(o: SetupOptions): Extension { overwriteField, gutterLineDrag, clipboardHistoryExtension, + clipboardFilesExtension, indentOnInput(), syntaxHighlighting(openideaHighlightStyle), syntaxHighlightField, diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index cab61a97f..abc244eb5 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -3,6 +3,7 @@ * status, navigation history, autosave. The React views and actions talk to * this singleton; other features use the EditorService and editor/api. */ +import { pasteEditorClipboard } from "./cm/clipboard"; import { EditorSelection } from "@codemirror/state"; import { create } from "zustand"; import { setContext } from "@/platform/context"; @@ -641,18 +642,7 @@ export class EditorController implements SlotHost { icon: "paste", shortcut: "⌘V", disabled: view.state.readOnly, - run: () => { - void navigator.clipboard - ?.readText() - .then((text) => { - view.dispatch(view.state.replaceSelection(text), { userEvent: "input.paste", scrollIntoView: true }); - view.focus(); - }) - .catch(() => { - view.focus(); - document.execCommand("paste"); - }); - }, + run: () => void pasteEditorClipboard(view), }, { kind: "separator" }, { diff --git a/src/features/terminal/TerminalView.tsx b/src/features/terminal/TerminalView.tsx index 6b35883ab..f9868e1a4 100644 --- a/src/features/terminal/TerminalView.tsx +++ b/src/features/terminal/TerminalView.tsx @@ -31,6 +31,7 @@ import { type PaneLayout, type TerminalTab, } from "./store"; +import { pasteTerminalClipboard } from "./paste"; import { TerminalFindBar } from "./FindBar"; import styles from "./Terminal.module.css"; @@ -102,12 +103,7 @@ function paneMenu(e: MouseEvent, key: string) { { label: "Paste", shortcut: mac ? "⌘V" : "Ctrl+Shift+V", - run: () => { - void navigator.clipboard - ?.readText() - .then((t) => t && session.paste(t)) - .catch(() => undefined); - }, + run: () => void pasteTerminalClipboard((text) => session.paste(text)), }, { label: "Select All", run: () => session.term.selectAll() }, { kind: "separator" }, diff --git a/src/features/terminal/embedded.ts b/src/features/terminal/embedded.ts index 4a1b5cad4..1fed7649f 100644 --- a/src/features/terminal/embedded.ts +++ b/src/features/terminal/embedded.ts @@ -4,6 +4,7 @@ * and size go to the owner instead of a terminal PTY (a Run console emulating * a terminal feeds its process). */ +import { installTerminalPaste } from "./paste"; import { Terminal, type IDisposable } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; @@ -83,6 +84,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal }, }), ]; + const removePaste = installTerminalPaste(host, (text) => term.paste(text)); term.open(host); if (opts["aria-label"]) term.textarea?.setAttribute("aria-label", opts["aria-label"]); @@ -109,6 +111,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal fitter.cancel(); processResize.cancel(); observer.disconnect(); + removePaste(); for (const d of disposables) d.dispose(); live.delete(term); term.dispose(); diff --git a/src/features/terminal/paste.test.ts b/src/features/terminal/paste.test.ts new file mode 100644 index 000000000..27dad6f53 --- /dev/null +++ b/src/features/terminal/paste.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { Terminal } from "@xterm/xterm"; +import { commands } from "@/platform/ipc"; +import { installTerminalPaste, pasteTerminalClipboard } from "./paste"; + +beforeEach(() => vi.stubGlobal("__TAURI_INTERNALS__", {})); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +for (const route of ["event", "menu"]) { + it.each([ + { name: "text present", text: "plain", paths: ["/stale"], expected: "plain", calls: 0 }, + { name: "files plus list", text: "", paths: ["/a b", "/中文"], expected: "/a b\n/中文", calls: 1 }, + { name: "files without list", text: "", paths: [], expected: "", calls: 1 }, + { name: "backend error", text: "", paths: null, expected: "", calls: 1 }, + ])(`${route}: $name`, async ({ text, paths, expected, calls }) => { + const list = vi.spyOn(commands, "clipboardFileList"); + if (paths) list.mockResolvedValue({ status: "ok", data: paths }); + else list.mockRejectedValue(new Error("busy")); + const host = document.createElement("div"); + const textarea = document.createElement("textarea"); + host.append(textarea); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste); + // Model xterm's bubble listener: file recovery must preempt its empty paste. + textarea.addEventListener("paste", (event) => + paste((event as ClipboardEvent).clipboardData!.getData("text/plain")), + ); + if (route === "event") { + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { value: { getData: () => text, files: [], types: ["Files"] } }); + textarea.dispatchEvent(event); + expect(event.defaultPrevented).toBe(!text); + } else { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => text } }); + await pasteTerminalClipboard(paste); + } + await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(calls)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(paste.mock.calls).toEqual(expected ? [[expected]] : []); + remove(); + }); +} + +it("ignores clipboard resolution after the terminal is disposed", async () => { + let finish!: (value: { status: "ok"; data: string[] }) => void; + vi.spyOn(commands, "clipboardFileList").mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste); + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { value: { getData: () => "", files: [], types: ["Files"] } }); + host.dispatchEvent(event); + remove(); + finish({ status: "ok", data: ["/late"] }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(paste).not.toHaveBeenCalled(); +}); + +it("passes recovered multiline paths through xterm bracketed paste", async () => { + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["/a b", "/中文"] }); + const term = new Terminal({ allowProposedApi: true }); + const data: string[] = []; + term.onData((text) => data.push(text)); + try { + await new Promise((resolve) => term.write("\x1b[?2004h", resolve)); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "" } }); + await pasteTerminalClipboard((text) => term.paste(text)); + expect(data).toEqual(["\x1b[200~/a b\r/中文\x1b[201~"]); + } finally { + term.dispose(); + } +}); diff --git a/src/features/terminal/paste.ts b/src/features/terminal/paste.ts new file mode 100644 index 000000000..2ce06e9ac --- /dev/null +++ b/src/features/terminal/paste.ts @@ -0,0 +1,24 @@ +import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard"; + +export function installTerminalPaste(host: HTMLElement, paste: (text: string) => void): () => void { + let alive = true; + const onPaste = (event: ClipboardEvent) => { + pasteClipboardFiles(event, (text) => { + if (alive) paste(text); + }); + }; + host.addEventListener("paste", onPaste, true); + return () => { + alive = false; + host.removeEventListener("paste", onPaste, true); + }; +} + +export async function pasteTerminalClipboard(paste: (text: string) => void) { + try { + const text = await readClipboardText(); + if (text) paste(text); + } catch { + /* Clipboard access may be denied. */ + } +} diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 955f14af5..ace17df1e 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -11,6 +11,7 @@ import { Channel } from "@tauri-apps/api/core"; import { call, commands, isTauri, type TerminalId } from "@/platform/ipc"; import { editorService, notifications } from "@/workbench/contributions"; import { fitScheduler, settledResize } from "./fit"; +import { installTerminalPaste, pasteTerminalClipboard } from "./paste"; import { ImeCommitGate } from "./imeCommit"; import { findTerminalLinks, resolvePath, type TerminalLink } from "./links"; import { splitCommandLine } from "@/platform/commandLine"; @@ -275,16 +276,14 @@ export class TerminalSession { if (atBottom !== this.snapshot.follow) this.update({ follow: atBottom }); }), ); + this.disposables.push({ dispose: installTerminalPaste(this.host, (text) => this.paste(text)) }); this.host.addEventListener("mousedown", this.onMiddleClick); } private onMiddleClick = (e: MouseEvent) => { if (e.button !== 1 || !workflowSettings().terminal.pasteOnMiddleClick) return; e.preventDefault(); - void navigator.clipboard - ?.readText() - .then((t) => t && this.paste(t)) - .catch(() => undefined); + void pasteTerminalClipboard((text) => this.paste(text)); }; /** Applies Settings › Tools › Terminal changes to a live session. */ @@ -552,6 +551,7 @@ export class TerminalSession { } paste(text: string) { + if (this.disposed) return; this.term.paste(text); } diff --git a/src/platform/clipboard.test.ts b/src/platform/clipboard.test.ts new file mode 100644 index 000000000..9f19f7414 --- /dev/null +++ b/src/platform/clipboard.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { commands } from "./ipc"; +import { pasteClipboardFiles, readClipboardText } from "./clipboard"; + +beforeEach(() => vi.stubGlobal("__TAURI_INTERNALS__", {})); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function transfer(text: string, files = true): DataTransfer { + return { getData: () => text, files: [], types: files ? ["Files"] : ["text/plain"] } as unknown as DataTransfer; +} + +const cases = [ + { name: "text present", text: "plain text", paths: ["/stale"], expected: "plain text", calls: 0 }, + { + name: "files plus list", + text: "", + paths: ["/a b", "/中文", "C:\\folder"], + expected: "/a b\n/中文\nC:\\folder", + calls: 1, + }, + { name: "files without list", text: "", paths: [], expected: "", calls: 1 }, + { name: "backend error", text: "", paths: null, expected: "", calls: 1 }, +]; + +describe("clipboard text", () => { + it.each(cases)("$name", async ({ text, paths, expected, calls }) => { + const list = vi.spyOn(commands, "clipboardFileList"); + if (paths) list.mockResolvedValue({ status: "ok", data: paths }); + else list.mockRejectedValue(new Error("busy")); + expect(await readClipboardText(transfer(text))).toBe(expected); + expect(list).toHaveBeenCalledTimes(calls); + list.mockClear(); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => text } }); + expect(await readClipboardText()).toBe(expected); + expect(list).toHaveBeenCalledTimes(calls); + }); + + it("does not consult files for an empty text-only event or a browser", async () => { + const list = vi.spyOn(commands, "clipboardFileList"); + expect(await readClipboardText(transfer("", false))).toBe(""); + vi.unstubAllGlobals(); + expect(await readClipboardText(transfer(""))).toBe(""); + expect(list).not.toHaveBeenCalled(); + }); + + it("does not intercept text, already handled images, or an absent transfer", () => { + const paste = vi.fn(); + for (const data of [transfer("text"), null]) { + const event = new Event("paste", { cancelable: true }) as ClipboardEvent; + Object.defineProperty(event, "clipboardData", { value: data }); + expect(pasteClipboardFiles(event, paste)).toBe(false); + } + const event = new Event("paste", { cancelable: true }) as ClipboardEvent; + Object.defineProperty(event, "clipboardData", { value: transfer("") }); + event.preventDefault(); + expect(pasteClipboardFiles(event, paste)).toBe(false); + expect(paste).not.toHaveBeenCalled(); + }); +}); diff --git a/src/platform/clipboard.ts b/src/platform/clipboard.ts new file mode 100644 index 000000000..850a235f2 --- /dev/null +++ b/src/platform/clipboard.ts @@ -0,0 +1,29 @@ +import { call, commands, isTauri } from "./ipc"; + +/** WebKit exposes native file URLs as Files, hiding the pasteboard's text flavor. */ +export async function readClipboardText(transfer?: DataTransfer | null): Promise { + const text = transfer ? transfer.getData("text/plain") : ((await navigator.clipboard?.readText()) ?? ""); + if (text || (transfer && !hasClipboardFiles(transfer)) || !isTauri()) return text; + try { + return (await call(commands.clipboardFileList())).join("\n"); + } catch { + return ""; + } +} + +function hasClipboardFiles(transfer: DataTransfer): boolean { + return !!transfer.files.length || Array.from(transfer.types).includes("Files"); +} + +/** Claim synchronously so the native handler cannot emit an empty paste while IPC runs. */ +export function pasteClipboardFiles(event: ClipboardEvent, paste: (text: string) => void): boolean { + const transfer = event.clipboardData; + if (event.defaultPrevented || !transfer || transfer.getData("text/plain") || !hasClipboardFiles(transfer)) + return false; + event.preventDefault(); + event.stopImmediatePropagation(); + void readClipboardText(transfer).then((text) => { + if (text) paste(text); + }); + return true; +}