penpot/frontend/src/app/util/clipboard.cljs
2026-08-25 11:55:10 +02:00

217 lines
7.5 KiB
Clojure

;; 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 SUBSIDIARY SL
(ns app.util.clipboard
(:require
["./clipboard.js" :as impl]
[app.common.transit :as t]
[app.util.dom :as dom]
[beicon.v2.core :as rx]
[cuerdas.core :as str]))
(def image-types
["image/webp"
"image/png"
"image/jpeg"
"image/svg+xml"])
(def ^:private default-options
#js {:decodeTransit t/decode-str
:allowHTMLPaste false})
(defn plain-text->html
"Build a minimal text/html clipboard payload from plain text.
Windows apps often prefer CF_HTML over CF_UNICODETEXT; writing only
text/plain from a contenteditable copy handler can leave them with the
editor surface's empty/`<br>` fallback (a lone newline)."
[text]
(let [escaped (-> (or text "")
(dom/escape-html)
(str/replace "\n" "<br>"))]
(str "<meta charset=\"utf-8\">" escaped)))
(defn- from-data-transfer
"Get clipboard stream from DataTransfer instance"
([data-transfer]
(from-data-transfer data-transfer default-options))
([data-transfer options]
(->> (rx/from (impl/fromDataTransfer data-transfer options))
(rx/mapcat #(rx/from %)))))
(defn from-navigator
([]
(from-navigator default-options))
([options]
(->> (rx/from (impl/fromNavigator options))
(rx/mapcat #(rx/from %)))))
(defn from-clipboard-event
"Get clipboard stream from clipboard event"
([event]
(from-clipboard-event event default-options))
([event options]
(let [cdata (.-clipboardData ^js event)]
(from-data-transfer cdata options))))
(defn from-synthetic-clipboard-event
"Get clipboard stream from synthetic clipboard event"
([event options]
(let [target
(dom/get-target event)
content-editable?
(dom/is-content-editable? target)
is-input?
(= (dom/get-tag-name target) "INPUT")]
;; ignore when pasting into an editable control
(if-not (or content-editable? is-input?)
(-> event
(dom/event->browser-event)
(from-clipboard-event options))
(rx/empty)))))
(defn from-drop-event
"Get clipboard stream from drop event"
([event]
(from-drop-event event default-options))
([event options]
(from-data-transfer (.-dataTransfer ^js event) options)))
(def ^:private unavailable-error-message
"Clipboard API is unavailable. This usually happens when the page is served over plain HTTP; serve Penpot over HTTPS to enable copy-to-clipboard.")
(defn- get-clipboard
"Return the active `navigator.clipboard` object, or nil when the
asynchronous Clipboard API is not exposed (e.g. on insecure origins
per the W3C spec, which is what triggered #4478)."
[]
(unchecked-get js/navigator "clipboard"))
(defn- unavailable-error
"Build the error wrapped in a rejected Promise so callers can chain
`rx/from`/`.catch` and surface a meaningful message instead of the
opaque `TypeError: Cannot read properties of undefined (reading
'writeText')` that leaked out of the previous implementation."
[]
(js/Promise.reject (js/Error. unavailable-error-message)))
(defn to-clipboard
"Write a plain-text string to the system clipboard.
Always returns a Promise. Resolves on success; rejects with a clear
`Error` when `navigator.clipboard` is unavailable (insecure origin)
so callers can chain error handling instead of crashing the UI on a
synchronous `TypeError` like #4478."
[data]
(assert (string? data) "`data` should be string")
(let [clipboard (get-clipboard)]
(if (and clipboard (unchecked-get clipboard "writeText"))
(.writeText ^js clipboard data)
(unavailable-error))))
(defn- create-clipboard-item
[mimetype promise]
(js/ClipboardItem.
(js-obj mimetype promise)))
(defn to-clipboard-promise
"Write a single asynchronous payload to the clipboard under the given
MIME type. The `promise` is resolved by the browser when the
ClipboardItem is committed.
Returns the Promise produced by `clipboard.write`, or a rejected
Promise carrying an `Error` when the asynchronous Clipboard API is
not available (insecure origin / unsupported browser). Mirrors
`to-clipboard`'s defensive contract."
[mimetype promise]
(let [clipboard (get-clipboard)]
(if (and clipboard (unchecked-get clipboard "write"))
(let [data (create-clipboard-item mimetype promise)]
(.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.
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)
writable (when clipboard
(into {} (filter (comp writable-mime? key)) items))]
(cond
(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)))))
(some? clipboard)
(to-clipboard-text clipboard items)
:else
(unavailable-error))))