From d42f78b80e9f4c15adafe897b37148a06ac5e60d Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:29:59 -0400 Subject: [PATCH] :bug: Fix clipboard crash when copying as SVG (#10663) * :bug: Fix clipboard crash when copying as SVG clipboard.write with an image/svg+xml payload throws an unhandled DOMException on browsers that do not support the type. Fall back to writeText for that specific failure. Fixes #10596 Signed-off-by: Akshit Nassa * :paperclip: Update Kaleidos Copyright Signed-off-by: Akshit Nassa --------- Signed-off-by: Akshit Nassa Signed-off-by: Akshit Nassa Co-authored-by: Akshit Nassa Co-authored-by: Andrey Antukh --- .../app/main/data/workspace/clipboard.cljs | 8 +- frontend/src/app/util/clipboard.cljs | 90 ++++++-- frontend/test/frontend_tests/runner.cljs | 2 + .../frontend_tests/util_clipboard_test.cljs | 205 ++++++++++++++++++ 4 files changed, 282 insertions(+), 23 deletions(-) create mode 100644 frontend/test/frontend_tests/util_clipboard_test.cljs diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 25c51f2257..c7f9c0ef91 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -378,9 +378,11 @@ shapes (mapv maybe-translate selected) svg-formatted (svg/generate-formatted-markup objects shapes)] - (clipboard/to-clipboard-multi - {"image/svg+xml" svg-formatted - "text/plain" svg-formatted}))))) + (-> (clipboard/to-clipboard-multi + {"image/svg+xml" svg-formatted + "text/plain" svg-formatted}) + (p/catch (fn [cause] + (js/console.error "clipboard error:" cause)))))))) (defn copy-selected-css [] diff --git a/frontend/src/app/util/clipboard.cljs b/frontend/src/app/util/clipboard.cljs index 0d73774b37..3bc1f1bba3 100644 --- a/frontend/src/app/util/clipboard.cljs +++ b/frontend/src/app/util/clipboard.cljs @@ -9,7 +9,8 @@ ["./clipboard.js" :as impl] [app.common.transit :as t] [app.util.dom :as dom] - [beicon.v2.core :as rx])) + [beicon.v2.core :as rx] + [cuerdas.core :as str])) (def image-types ["image/webp" @@ -123,32 +124,81 @@ (.write ^js clipboard #js [data])) (unavailable-error)))) +(defn- get-clipboard-item-ctor + "Return the `ClipboardItem` constructor, or nil on the browsers that + expose `clipboard.write` without it (Chrome < 116)." + [] + (unchecked-get js/globalThis "ClipboardItem")) + +(defn- writable-mime? + "Browsers keep an allowlist of MIME types accepted by + `clipboard.write`; `image/svg+xml` is outside Firefox's (#10596). + `ClipboardItem.supports` exposes it when available; assume writable + otherwise, since `write` is still guarded by a rejection fallback." + [mime] + (let [ctor (get-clipboard-item-ctor) + supports (some-> ctor (unchecked-get "supports"))] + (if (fn? supports) + (boolean (.call ^js supports ctor mime)) + true))) + +(defn- unsupported-mime-error? + "True when `cause` is the `DOMException` a browser raises for a MIME + type outside its clipboard allowlist (#10596): \"Type 'image/svg+xml' + not supported for write\". Any other rejection (document not focused, + permission denied, insecure origin) must reach the caller instead of + being degraded to a text-only write." + [cause] + (let [message (str (when (some? cause) (unchecked-get cause "message")))] + (str/includes? message "not supported"))) + +(defn- create-multi-clipboard-item + [items] + (js/ClipboardItem. + (reduce-kv + (fn [acc mime payload] + (let [blob (js/Blob. #js [payload] #js {:type mime})] + (unchecked-set acc mime (js/Promise.resolve blob)) + acc)) + #js {} items))) + +(defn- to-clipboard-text + [clipboard items] + (if-let [text (or (get items "text/plain") + (first (vals items)))] + (if (unchecked-get clipboard "writeText") + (.writeText ^js clipboard text) + (unavailable-error)) + (js/Promise.resolve))) + (defn to-clipboard-multi "Write multiple MIME representations as a single ClipboardItem. `items` is a map of mime-type (string) -> string payload. - Falls back to `writeText` with the `text/plain` payload (or the - first available payload) when the asynchronous `clipboard.write` - API is unavailable. If neither path is reachable (e.g. insecure - origin), returns a rejected Promise mirroring `to-clipboard`'s - contract instead of throwing synchronously." + MIME types the browser refuses to write (per `ClipboardItem.supports`) + are dropped, and a `clipboard.write` rejected for an unsupported MIME + type falls back to `writeText` with the `text/plain` payload (or the + first available payload), which is also the path taken when + `clipboard.write` or `ClipboardItem` are missing altogether. Any other + rejection is propagated. If no path is reachable (e.g. insecure origin), + returns a rejected Promise mirroring `to-clipboard`'s contract instead + of throwing synchronously." [items] - (let [clipboard (get-clipboard)] + (let [clipboard (get-clipboard) + writable (when clipboard + (into {} (filter (comp writable-mime? key)) items))] (cond - (and clipboard (unchecked-get clipboard "write")) - (let [obj (reduce-kv - (fn [acc mime payload] - (let [blob (js/Blob. #js [payload] #js {:type mime})] - (unchecked-set acc mime (js/Promise.resolve blob)) - acc)) - #js {} items) - item (js/ClipboardItem. obj)] - (.write ^js clipboard #js [item])) + (and clipboard (seq writable) + (unchecked-get clipboard "write") + (some? (get-clipboard-item-ctor))) + (-> (.write ^js clipboard #js [(create-multi-clipboard-item writable)]) + (.catch (fn [cause] + (if (unsupported-mime-error? cause) + (to-clipboard-text clipboard items) + (js/Promise.reject cause))))) - (and clipboard (unchecked-get clipboard "writeText")) - (when-let [text (or (get items "text/plain") - (first (vals items)))] - (.writeText ^js clipboard text)) + (some? clipboard) + (to-clipboard-text clipboard items) :else (unavailable-error)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 2adae3e61e..2e473f1411 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -59,6 +59,7 @@ [frontend-tests.ui.ds-controls-numeric-input-test] [frontend-tests.ui.layout-container-multiple-test] [frontend-tests.ui.measures-menu-props-test] + [frontend-tests.util-clipboard-test] [frontend-tests.util-object-test] [frontend-tests.util-range-tree-test] [frontend-tests.util-simple-math-test] @@ -133,6 +134,7 @@ 'frontend-tests.ui.ds-controls-numeric-input-test 'frontend-tests.ui.layout-container-multiple-test 'frontend-tests.ui.measures-menu-props-test + 'frontend-tests.util-clipboard-test 'frontend-tests.util-object-test 'frontend-tests.util-range-tree-test 'frontend-tests.util-simple-math-test diff --git a/frontend/test/frontend_tests/util_clipboard_test.cljs b/frontend/test/frontend_tests/util_clipboard_test.cljs new file mode 100644 index 0000000000..4c664786a1 --- /dev/null +++ b/frontend/test/frontend_tests/util_clipboard_test.cljs @@ -0,0 +1,205 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en EspaƱa SL + +(ns frontend-tests.util-clipboard-test + "Regression tests for `to-clipboard-multi` (issue #10596). + + Browsers keep an allowlist of MIME types accepted by the async + Clipboard API. Firefox only accepts text/plain, text/html and + image/png, so writing a ClipboardItem that carries image/svg+xml + rejects with `DOMException: Type 'image/svg+xml' not supported for + write`. `clipboard.write` is all-or-nothing, so the text/plain + representation is lost too and the rejection escapes unhandled." + (:require + [app.util.clipboard :as clipboard] + [cljs.test :as t :include-macros true])) + +(def ^:private svg-markup "") + +(defonce ^:private original-clipboard-item + (unchecked-get js/globalThis "ClipboardItem")) + +(defonce ^:private original-clipboard + (unchecked-get js/navigator "clipboard")) + +(defn- restore-globals! + [] + (unchecked-set js/globalThis "ClipboardItem" original-clipboard-item) + (unchecked-set js/navigator "clipboard" original-clipboard)) + +(t/use-fixtures :each {:after restore-globals!}) + +(defn- install-clipboard-item! + "Install a `ClipboardItem` stub that records the MIME keys it was + built with. When `supported` is provided it is exposed as the + `ClipboardItem.supports` static, mirroring the browsers that ship it." + [supported] + (let [ctor (fn [obj] + ;; a constructor returning an object hands that object back to `new` + #js {:types (js/Object.keys obj)})] + (when (some? supported) + (unchecked-set ctor "supports" (fn [mime] (contains? supported mime)))) + (unchecked-set js/globalThis "ClipboardItem" ctor))) + +(defn- install-navigator-clipboard! + "Install a `navigator.clipboard` stub. `writable` is the set of MIME + types the fake browser accepts on `write`; any item carrying a MIME + outside of it rejects the whole write, as real browsers do." + [state writable] + (unchecked-set + js/navigator "clipboard" + #js {:write + (fn [items] + (let [types (vec (unchecked-get (aget items 0) "types"))] + (swap! state update :written conj types) + (if (every? writable types) + (js/Promise.resolve) + (js/Promise.reject + (js/Error. (str "Type '" (first (remove writable types)) + "' not supported for write")))))) + :writeText + (fn [text] + (swap! state assoc :text text) + (js/Promise.resolve))})) + +(defn- copy-svg + [] + (clipboard/to-clipboard-multi + {"image/svg+xml" svg-markup + "text/plain" svg-markup})) + +(t/deftest firefox-without-supports-falls-back-to-text + (t/testing "#10596: a browser that rejects image/svg+xml must not leak a rejection" + (t/async done + (let [state (atom {:written []})] + (install-clipboard-item! nil) + (install-navigator-clipboard! state #{"text/plain" "text/html" "image/png"}) + (-> (copy-svg) + (.then (fn [_] + (t/is (= svg-markup (:text @state)) + "the SVG markup must still reach the clipboard as text") + (done))) + (.catch (fn [err] + (t/is false (str "to-clipboard-multi must not reject: " err)) + (done)))))))) + +(t/deftest firefox-with-supports-writes-only-the-supported-mimes + (t/testing "#10596: ClipboardItem.supports filters the unsupported MIME out" + (t/async done + (let [state (atom {:written []})] + (install-clipboard-item! #{"text/plain" "text/html" "image/png"}) + (install-navigator-clipboard! state #{"text/plain" "text/html" "image/png"}) + (-> (copy-svg) + (.then (fn [_] + (t/is (= [["text/plain"]] (:written @state)) + "the unsupported MIME must be filtered before writing") + (done))) + (.catch (fn [err] + (t/is false (str "to-clipboard-multi must not reject: " err)) + (done)))))))) + +(t/deftest chrome-keeps-the-svg-representation + (t/testing "#10596: browsers that accept image/svg+xml must still get it" + (t/async done + (let [state (atom {:written []}) + mimes #{"image/svg+xml" "text/plain"}] + (install-clipboard-item! mimes) + (install-navigator-clipboard! state mimes) + (-> (copy-svg) + (.then (fn [_] + (t/is (= 1 (count (:written @state)))) + (t/is (= mimes (set (first (:written @state)))) + "dropping image/svg+xml would regress copy into Illustrator/Inkscape") + (t/is (nil? (:text @state)) + "the writeText fallback must not be used when write succeeds") + (done))) + (.catch (fn [err] + (t/is false (str "to-clipboard-multi must not reject: " err)) + (done)))))))) + +(t/deftest unrelated-write-errors-are-not-swallowed + (t/testing "a rejection unrelated to the MIME allowlist must not degrade to text" + (t/async done + (let [state (atom {:written []})] + (install-clipboard-item! nil) + (unchecked-set + js/navigator "clipboard" + #js {:write + (fn [_] + (js/Promise.reject (js/Error. "Document is not focused."))) + :writeText + (fn [text] + (swap! state assoc :text text) + (js/Promise.resolve))}) + (-> (copy-svg) + (.then (fn [_] + (t/is false "an unrelated failure must reject, not silently write text") + (done))) + (.catch (fn [err] + (t/is (= "Document is not focused." (ex-message err)) + "the original cause must reach the caller") + (t/is (nil? (:text @state)) + "no text must be written on an unrelated failure") + (done)))))))) + +(t/deftest missing-clipboard-item-ctor-falls-back-to-text + (t/testing "`clipboard.write` without a `ClipboardItem` constructor must not throw" + (t/async done + (let [state (atom {:written []})] + (unchecked-set js/globalThis "ClipboardItem" nil) + (install-navigator-clipboard! state #{"text/plain"}) + (-> (copy-svg) + (.then (fn [_] + (t/is (= [] (:written @state))) + (t/is (= svg-markup (:text @state)) + "the payload must still reach the clipboard as text") + (done))) + (.catch (fn [err] + (t/is false (str "to-clipboard-multi must not reject: " err)) + (done)))))))) + +(t/deftest write-unavailable-uses-write-text + (t/async done + (let [state (atom {:written []})] + (install-clipboard-item! nil) + (unchecked-set js/navigator "clipboard" + #js {:writeText (fn [text] + (swap! state assoc :text text) + (js/Promise.resolve))}) + (-> (clipboard/to-clipboard-multi {"image/svg+xml" svg-markup + "text/plain" svg-markup}) + (.then (fn [_] + (t/is (= svg-markup (:text @state))) + (done))) + (.catch (fn [err] + (t/is false (str "unexpected rejection: " err)) + (done))))))) + +(t/deftest empty-items-resolve-without-touching-the-clipboard + (t/async done + (let [state (atom {:written []})] + (install-clipboard-item! nil) + (install-navigator-clipboard! state #{"text/plain"}) + (-> (clipboard/to-clipboard-multi {}) + (.then (fn [_] + (t/is (= [] (:written @state))) + (t/is (nil? (:text @state))) + (done))) + (.catch (fn [err] + (t/is false (str "unexpected rejection: " err)) + (done))))))) + +(t/deftest clipboard-unavailable-rejects + (t/async done + (install-clipboard-item! nil) + (unchecked-set js/navigator "clipboard" nil) + (-> (clipboard/to-clipboard-multi {"text/plain" svg-markup}) + (.then (fn [_] + (t/is false "must reject when the Clipboard API is unavailable") + (done))) + (.catch (fn [err] + (t/is (instance? js/Error err)) + (done))))))