From 9029d0d6be353a60cf2240cdf7d0b7f973281c9b Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Thu, 9 Apr 2026 02:37:20 +0530 Subject: [PATCH] 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 b0dc3763..b6dfb3ff 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,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", @@ -62,6 +63,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 9f6bebf7..5ae233e4 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 @@ -788,6 +794,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==} @@ -5929,6 +5940,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 8ea78606..9b86252c 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, @@ -138,6 +219,7 @@ export const makeEditor = async ({ }, }, }), + NativeClipboardPasteExtension, ], onUpdate({editor}) { wordCount?.set(editor.storage.wordCount.words)