mirror of
https://github.com/penpot/penpot.git
synced 2026-08-29 16:18:48 +00:00
217 lines
7.5 KiB
Clojure
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))))
|