🐛 Fix clipboard crash when copying as SVG (#10663)

* 🐛 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 <akshitnassa412@gmail.com>

* 📎 Update Kaleidos Copyright

Signed-off-by: Akshit Nassa <nassaakshit@gmail.com>

---------

Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Signed-off-by: Akshit Nassa <nassaakshit@gmail.com>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
AK 2026-07-23 07:29:59 -04:00 committed by GitHub
parent 09730d10d0
commit d42f78b80e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 282 additions and 23 deletions

View File

@ -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
[]

View File

@ -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))))

View File

@ -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

View File

@ -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 "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>")
(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))))))