From acfacede2d96fb6adf59b826491d3339d5d95261 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:47:00 +1000 Subject: [PATCH] fix(app): preserve native clipboard paste (#49424) --- .../src/composer/editor/interaction.test.ts | 19 +++++++++++++++ .../app/src/composer/editor/interaction.ts | 24 ++++++++++++------- 2 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 packages/app/src/composer/editor/interaction.test.ts diff --git a/packages/app/src/composer/editor/interaction.test.ts b/packages/app/src/composer/editor/interaction.test.ts new file mode 100644 index 00000000000..145cd30de01 --- /dev/null +++ b/packages/app/src/composer/editor/interaction.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test" +import { shouldHandlePasteAsAttachment } from "./interaction" + +test("leaves paste to the browser when web clipboard data is unavailable", () => { + expect(shouldHandlePasteAsAttachment(clipboard(), false)).toBe(false) +}) + +test("uses native image reading only when the clipboard has no text", () => { + expect(shouldHandlePasteAsAttachment(clipboard(), true)).toBe(true) + expect(shouldHandlePasteAsAttachment(clipboard(["text/plain"]), true)).toBe(false) +}) + +test("handles clipboard files as attachments", () => { + expect(shouldHandlePasteAsAttachment(clipboard([], [{ kind: "file" }]), false)).toBe(true) +}) + +function clipboard(types: string[] = [], items: Array<{ kind: string }> = []) { + return { types, items } as unknown as DataTransfer +} diff --git a/packages/app/src/composer/editor/interaction.ts b/packages/app/src/composer/editor/interaction.ts index c94e71bc3cc..c779a0efc16 100644 --- a/packages/app/src/composer/editor/interaction.ts +++ b/packages/app/src/composer/editor/interaction.ts @@ -384,20 +384,20 @@ export function createComposerEditor(input: { }, onPaste(event: ClipboardEvent) { const clipboard = event.clipboardData - if ( - attachments && - (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain")) - ) { + const text = clipboard?.getData("text/plain") + if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) { void attachments.handlePaste(event) return } - const text = clipboard?.getData("text/plain").replace(/\r\n?/g, "\n") if (!text) return event.preventDefault() // insertText emits input events per line, repeatedly parsing and saving the draft. // Escaped HTML inserts multiline text once and preserves native selection and undo. - const multiline = text.includes("\n") - const value = multiline ? text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") : text + const normalized = text.replace(/\r\n?/g, "\n") + const multiline = normalized.includes("\n") + const value = multiline + ? normalized.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") + : normalized if ( typeof document.execCommand === "function" && document.execCommand(multiline ? "insertHTML" : "insertText", false, value) @@ -408,13 +408,13 @@ export function createComposerEditor(input: { if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return const range = selection.getRangeAt(0) range.deleteContents() - const node = document.createTextNode(text) + const node = document.createTextNode(normalized) range.insertNode(node) range.setStartAfter(node) range.collapse(true) selection.removeAllRanges() selection.addRange(range) - target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text })) + target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: normalized })) }, onDragEnter(event: DragEvent) { event.preventDefault() @@ -450,6 +450,12 @@ export function createComposerEditor(input: { export type ComposerEditorModel = ReturnType +export function shouldHandlePasteAsAttachment(clipboard: DataTransfer | null, readClipboardImage: boolean) { + if (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file")) return true + if (Array.from(clipboard?.types ?? []).some((type) => type.startsWith("text/"))) return false + return readClipboardImage +} + function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) { const position = Math.max(0, Math.min(cursor, text.length)) if (inHistory) return position === 0 || position === text.length