mirror of
https://github.com/penpot/penpot.git
synced 2026-07-24 23:18:06 +00:00
🐛 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:
parent
09730d10d0
commit
d42f78b80e
@ -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
|
||||
[]
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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
|
||||
|
||||
205
frontend/test/frontend_tests/util_clipboard_test.cljs
Normal file
205
frontend/test/frontend_tests/util_clipboard_test.cljs
Normal 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))))))
|
||||
Loading…
x
Reference in New Issue
Block a user