From 9b30c520e32b51598479e8785c562f08bd935215 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Thu, 9 Apr 2026 02:37:20 +0530 Subject: [PATCH 1/2] fix: support native clipboard image paste on mobile --- package.json | 2 + pnpm-lock.yaml | 15 ++++++++ src/app/editor/index.ts | 82 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/package.json b/package.json index eb5b024f..a9237ab9 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@capacitor/android": "^8.0.1", "@capacitor/app": "^8.0.0", "@capacitor/cli": "^8.0.1", + "@capacitor/clipboard": "^8.0.1", "@capacitor/core": "^8.0.1", "@capacitor/filesystem": "^8.1.0", "@capacitor/ios": "^8.0.1", @@ -63,6 +64,7 @@ "@poppanator/sveltekit-svg": "^4.2.1", "@sveltejs/adapter-static": "^3.0.10", "@tiptap/core": "^2.27.2", + "@tiptap/pm": "^2.27.2", "@types/qrcode": "^1.5.6", "@types/throttle-debounce": "^5.0.2", "@vite-pwa/assets-generator": "^0.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 452248b9..9a6e25e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@capacitor/cli': specifier: ^8.0.1 version: 8.0.1 + '@capacitor/clipboard': + specifier: ^8.0.1 + version: 8.0.1(@capacitor/core@8.0.1) '@capacitor/core': specifier: ^8.0.1 version: 8.0.1 @@ -71,6 +74,9 @@ importers: '@tiptap/core': specifier: ^2.27.2 version: 2.27.2(@tiptap/pm@2.27.2) + '@tiptap/pm': + specifier: ^2.27.2 + version: 2.27.2 '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 @@ -791,6 +797,11 @@ packages: engines: {node: '>=22.0.0'} hasBin: true + '@capacitor/clipboard@8.0.1': + resolution: {integrity: sha512-iOlbTi8MojKyLnYE+M27priXid7vHd0PlDwyHohPzkuQ8Rkp6q7ykwZmPEUD+OnU/Ink7Qw/pUOfKgraKmA6Eg==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/core@8.0.1': resolution: {integrity: sha512-5UqSWxGMp/B8KhYu7rAijqNtYslhcLh+TrbfU48PfdMDsPfaU/VY48sMNzC22xL8BmoFoql/3SKyP+pavTOvOA==} @@ -5969,6 +5980,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@capacitor/clipboard@8.0.1(@capacitor/core@8.0.1)': + dependencies: + '@capacitor/core': 8.0.1 + '@capacitor/core@8.0.1': dependencies: tslib: 2.8.1 diff --git a/src/app/editor/index.ts b/src/app/editor/index.ts index f56fd4b1..27b5f077 100644 --- a/src/app/editor/index.ts +++ b/src/app/editor/index.ts @@ -1,6 +1,10 @@ import {mount} from "svelte" import type {Writable} from "svelte/store" import {get, derived} from "svelte/store" +import {Clipboard} from "@capacitor/clipboard" +import {Capacitor} from "@capacitor/core" +import {Extension} from "@tiptap/core" +import {Plugin, PluginKey} from "@tiptap/pm/state" import {Router} from "@welshman/router" import {dec, inc} from "@welshman/lib" import {throttled} from "@welshman/store" @@ -22,6 +26,83 @@ import {uploadFile} from "@app/core/commands" import {deriveSpaceMembers} from "@app/core/state" import {pushToast} from "@app/util/toast" +const nativeClipboardAvailable = () => + Capacitor.isNativePlatform() && Capacitor.isPluginAvailable("Clipboard") + +const hasStandardPastePayload = (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + + if (!clipboardData) { + return false + } + + if (Array.from(clipboardData.items).some(item => item.kind === "file")) { + return true + } + + if (clipboardData.types.includes("text/html")) { + return true + } + + return clipboardData.getData("text/plain") !== "" +} + +const getNativeClipboardImage = async () => { + try { + const {type, value} = await Clipboard.read() + + if (!type.startsWith("image/") || value === "") { + return undefined + } + + const imageData = value.startsWith("data:") ? value : `data:${type};base64,${value}` + const blob = await fetch(imageData).then(res => res.blob()) + + if (!blob.type.startsWith("image/")) { + return undefined + } + + const extension = type.split("/")[1]?.split("+")[0] || "png" + + return new File([blob], `clipboard-image.${extension}`, {type: blob.type || type}) + } catch { + return undefined + } +} + +const NativeClipboardPasteExtension = Extension.create({ + name: "nativeClipboardPaste", + + addProseMirrorPlugins() { + const editor = this.editor + + return [ + new Plugin({ + key: new PluginKey("nativeClipboardPaste"), + props: { + handlePaste: (_view, event) => { + if (!nativeClipboardAvailable() || hasStandardPastePayload(event)) { + return false + } + + event.preventDefault() + + void getNativeClipboardImage().then(file => { + if (!file) { + return + } + + editor.commands.addFile(file, editor.state.selection.from + 1) + }) + + return true + }, + }, + }), + ] + }, +}) + export const makeEditor = async ({ encryptFiles = false, aggressive = false, @@ -137,6 +218,7 @@ export const makeEditor = async ({ }, }, }), + NativeClipboardPasteExtension, ], onUpdate({editor}) { wordCount?.set(editor.storage.wordCount.words) -- 2.52.0 From 77784ffff513faf542113313c0e478d6e350be69 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Sat, 11 Apr 2026 14:32:11 +0530 Subject: [PATCH 2/2] refactor: move editor clipboard helpers into module --- src/app/editor/clipboard.ts | 81 ++++++++++++++++++++++++++++++++++++ src/app/editor/index.ts | 82 +------------------------------------ 2 files changed, 82 insertions(+), 81 deletions(-) create mode 100644 src/app/editor/clipboard.ts diff --git a/src/app/editor/clipboard.ts b/src/app/editor/clipboard.ts new file mode 100644 index 00000000..fa3e8ed5 --- /dev/null +++ b/src/app/editor/clipboard.ts @@ -0,0 +1,81 @@ +import {Clipboard} from "@capacitor/clipboard" +import {Capacitor} from "@capacitor/core" +import {Extension} from "@tiptap/core" +import {Plugin, PluginKey} from "@tiptap/pm/state" + +const nativeClipboardAvailable = () => + Capacitor.isNativePlatform() && Capacitor.isPluginAvailable("Clipboard") + +const hasStandardPastePayload = (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + + if (!clipboardData) { + return false + } + + if (Array.from(clipboardData.items).some(item => item.kind === "file")) { + return true + } + + if (clipboardData.types.includes("text/html")) { + return true + } + + return clipboardData.getData("text/plain") !== "" +} + +const getNativeClipboardImage = async () => { + try { + const {type, value} = await Clipboard.read() + + if (!type.startsWith("image/") || value === "") { + return undefined + } + + const imageData = value.startsWith("data:") ? value : `data:${type};base64,${value}` + const blob = await fetch(imageData).then(res => res.blob()) + + if (!blob.type.startsWith("image/")) { + return undefined + } + + const extension = type.split("/")[1]?.split("+")[0] || "png" + + return new File([blob], `clipboard-image.${extension}`, {type: blob.type || type}) + } catch { + return undefined + } +} + +export const NativeClipboardPasteExtension = Extension.create({ + name: "nativeClipboardPaste", + + addProseMirrorPlugins() { + const editor = this.editor + + return [ + new Plugin({ + key: new PluginKey("nativeClipboardPaste"), + props: { + handlePaste: (_view, event) => { + if (!nativeClipboardAvailable() || hasStandardPastePayload(event)) { + return false + } + + event.preventDefault() + + void getNativeClipboardImage().then(file => { + if (!file) { + return + } + + editor.commands.addFile(file, editor.state.selection.from + 1) + }) + + return true + }, + }, + }), + ] + }, +}) diff --git a/src/app/editor/index.ts b/src/app/editor/index.ts index 27b5f077..b3a8c858 100644 --- a/src/app/editor/index.ts +++ b/src/app/editor/index.ts @@ -1,10 +1,6 @@ import {mount} from "svelte" import type {Writable} from "svelte/store" import {get, derived} from "svelte/store" -import {Clipboard} from "@capacitor/clipboard" -import {Capacitor} from "@capacitor/core" -import {Extension} from "@tiptap/core" -import {Plugin, PluginKey} from "@tiptap/pm/state" import {Router} from "@welshman/router" import {dec, inc} from "@welshman/lib" import {throttled} from "@welshman/store" @@ -19,6 +15,7 @@ import { } from "@welshman/app" import type {FileAttributes} from "@welshman/editor" import {Editor, MentionSuggestion, WelshmanExtension, editorProps} from "@welshman/editor" +import {NativeClipboardPasteExtension} from "@app/editor/clipboard" import {escapeHtml} from "@lib/html" import {makeMentionNodeView} from "@app/editor/MentionNodeView" import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte" @@ -26,83 +23,6 @@ import {uploadFile} from "@app/core/commands" import {deriveSpaceMembers} from "@app/core/state" import {pushToast} from "@app/util/toast" -const nativeClipboardAvailable = () => - Capacitor.isNativePlatform() && Capacitor.isPluginAvailable("Clipboard") - -const hasStandardPastePayload = (event: ClipboardEvent) => { - const clipboardData = event.clipboardData - - if (!clipboardData) { - return false - } - - if (Array.from(clipboardData.items).some(item => item.kind === "file")) { - return true - } - - if (clipboardData.types.includes("text/html")) { - return true - } - - return clipboardData.getData("text/plain") !== "" -} - -const getNativeClipboardImage = async () => { - try { - const {type, value} = await Clipboard.read() - - if (!type.startsWith("image/") || value === "") { - return undefined - } - - const imageData = value.startsWith("data:") ? value : `data:${type};base64,${value}` - const blob = await fetch(imageData).then(res => res.blob()) - - if (!blob.type.startsWith("image/")) { - return undefined - } - - const extension = type.split("/")[1]?.split("+")[0] || "png" - - return new File([blob], `clipboard-image.${extension}`, {type: blob.type || type}) - } catch { - return undefined - } -} - -const NativeClipboardPasteExtension = Extension.create({ - name: "nativeClipboardPaste", - - addProseMirrorPlugins() { - const editor = this.editor - - return [ - new Plugin({ - key: new PluginKey("nativeClipboardPaste"), - props: { - handlePaste: (_view, event) => { - if (!nativeClipboardAvailable() || hasStandardPastePayload(event)) { - return false - } - - event.preventDefault() - - void getNativeClipboardImage().then(file => { - if (!file) { - return - } - - editor.commands.addFile(file, editor.state.selection.from + 1) - }) - - return true - }, - }, - }), - ] - }, -}) - export const makeEditor = async ({ encryptFiles = false, aggressive = false, -- 2.52.0