From f811198b074e8d015a3cdf169f66499008100b0a Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Fri, 7 Nov 2025 14:20:08 +0100 Subject: [PATCH] :recycle: Refactor clipboard --- common/src/app/common/types/text.cljc | 4 + frontend/src/app/main/data/team.cljs | 4 +- .../app/main/data/workspace/clipboard.cljs | 135 +++++++--------- frontend/src/app/main/ui.cljs | 5 + .../app/main/ui/components/copy_button.cljs | 4 +- .../src/app/main/ui/debug/playground.cljs | 51 ++++++ .../src/app/main/ui/debug/playground.scss | 0 frontend/src/app/main/ui/inspect/code.cljs | 4 +- .../main/ui/inspect/styles/panels/text.cljs | 4 +- .../styles/rows/color_properties_row.cljs | 4 +- .../inspect/styles/rows/properties_row.cljs | 4 +- .../app/main/ui/inspect/styles/style_box.cljs | 4 +- frontend/src/app/main/ui/routes.cljs | 3 + .../app/main/ui/settings/access_tokens.cljs | 4 +- .../src/app/main/ui/viewer/share_link.cljs | 4 +- .../app/main/ui/workspace/context_menu.cljs | 5 +- frontend/src/app/util/clipboard.cljs | 59 +++++++ frontend/src/app/util/clipboard.js | 151 ++++++++++++++++++ .../src/app/util/text/content/from_dom.cljs | 24 +-- frontend/src/app/util/webapi.cljs | 63 -------- 20 files changed, 364 insertions(+), 172 deletions(-) create mode 100644 frontend/src/app/main/ui/debug/playground.cljs create mode 100644 frontend/src/app/main/ui/debug/playground.scss create mode 100644 frontend/src/app/util/clipboard.cljs create mode 100644 frontend/src/app/util/clipboard.js diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index 8f62324959..9a56504f0e 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -90,6 +90,10 @@ [{:fill-color clr/black :fill-opacity 1}]) +(def default-paragraph-attrs + {:text-align "left" + :text-direction "ltr"}) + (def default-text-attrs {:font-id "sourcesanspro" :font-family "sourcesanspro" diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index 059ac58140..e05cf81748 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -20,8 +20,8 @@ [app.main.features :as features] [app.main.repo :as rp] [app.main.router :as rt] + [app.util.clipboard :as clipboard] [app.util.storage :as storage] - [app.util.webapi :as wapi] [beicon.v2.core :as rx] [clojure.string :as str] [potok.v2.core :as ptk])) @@ -417,7 +417,7 @@ (rx/map (fn [fragment] (assoc cf/public-uri :fragment fragment))) (rx/tap (fn [uri] - (wapi/write-to-clipboard (str uri)))) + (clipboard/to-clipboard (str uri)))) (rx/tap on-success) (rx/ignore) (rx/catch on-error)))))) diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 32bb1f92d6..704b0efbc6 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -46,6 +46,7 @@ [app.main.repo :as rp] [app.main.router :as rt] [app.main.streams :as ms] + [app.util.clipboard :as clipboard] [app.util.code-gen.markup-svg :as svg] [app.util.code-gen.style-css :as css] [app.util.globals :as ug] @@ -59,7 +60,6 @@ [potok.v2.core :as ptk] [promesa.core :as p])) - (defn copy-selected [] (letfn [(sort-selected [state data] @@ -183,7 +183,7 @@ (let [text (wapi/get-current-selected-text)] (if-not (str/empty? text) (try - (wapi/write-to-clipboard text) + (clipboard/to-clipboard text) (catch :default e (on-copy-error e))) @@ -227,7 +227,7 @@ (rx/map #(t/encode-str % {:type :json-verbose})) (rx/map #(wapi/create-blob % "text/plain")) (rx/subs! resolve reject))))] - (->> (rx/from (wapi/write-to-clipboard-promise "text/plain" resolve-data-promise)) + (->> (rx/from (clipboard/to-clipboard-promise "text/plain" resolve-data-promise)) (rx/catch on-copy-error) (rx/ignore))) @@ -240,7 +240,7 @@ (rx/map (partial sort-selected state)) (rx/map (partial advance-copies state selected)) (rx/map #(t/encode-str % {:type :json-verbose})) - (rx/map wapi/write-to-clipboard) + (rx/map clipboard/to-clipboard) (rx/catch on-copy-error) (rx/ignore)))))))))) @@ -252,49 +252,45 @@ (declare ^:private paste-svg-text) (declare ^:private paste-shapes) +(defn create-paste-from-blob + [in-viewport?] + (fn [blob] + (let [type (.-type blob) + result (cond + (= type "image/svg+xml") + (->> (rx/from (.text blob)) + (rx/map paste-svg-text)) + + (some #(= type %) clipboard/image-types) + (rx/of (paste-image blob)) + + (= type "text/html") + (->> (rx/from (.text blob)) + (rx/map paste-html-text)) + + (= type "application/transit+json") + (->> (rx/from (.text blob)) + (rx/map (fn [text] + (let [transit-data (t/decode-str text)] + (assoc transit-data :in-viewport in-viewport?)))) + (rx/map paste-transit-shapes)) + + :else + (->> (rx/from (.text blob)) + (rx/map paste-text)))] + result))) + +(def default-paste-from-blob (create-paste-from-blob false)) + (defn paste-from-clipboard "Perform a `paste` operation using the Clipboard API." [] - (letfn [(decode-entry [entry] - (try - [:transit (t/decode-str entry)] - (catch :default _cause - [:text entry]))) - - (process-entry [[type data]] - (case type - :text - (cond - (str/empty? data) - (rx/empty) - - (re-find #"> (rx/concat - (->> (wapi/read-from-clipboard) - (rx/map decode-entry) - (rx/mapcat process-entry)) - (->> (wapi/read-image-from-clipboard) - (rx/map paste-image))) - (rx/take 1) - (rx/catch on-error)))))) + (ptk/reify ::paste-from-clipboard + ptk/WatchEvent + (watch [_ _ _] + (->> (clipboard/from-clipboard) + (rx/mapcat default-paste-from-blob) + (rx/take 1))))) (defn paste-from-event "Perform a `paste` operation from user emmited event." @@ -310,30 +306,8 @@ ;; we forbid that scenario so the default behaviour is executed (if is-editing? (rx/empty) - (let [pdata (wapi/read-from-paste-event event) - image-data (some-> pdata wapi/extract-images) - text-data (some-> pdata wapi/extract-text) - html-data (some-> pdata wapi/extract-html-text) - transit-data (ex/ignoring (some-> text-data t/decode-str))] - (cond - (and (string? text-data) (re-find #"> (rx/from image-data) - (rx/map paste-image)) - - (coll? transit-data) - (rx/of (paste-transit-shapes (assoc transit-data :in-viewport in-viewport?))) - - (and (string? html-data) (d/not-empty? html-data)) - (rx/of (paste-html-text html-data text-data)) - - (and (string? text-data) (d/not-empty? text-data)) - (rx/of (paste-text text-data)) - - :else - (rx/empty)))))))) + (->> (clipboard/from-synthetic-clipboard-event event) + (rx/mapcat (create-paste-from-blob in-viewport?)))))))) (defn copy-selected-svg [] @@ -352,7 +326,7 @@ shapes (mapv maybe-translate selected) svg-formatted (svg/generate-formatted-markup objects shapes)] - (wapi/write-to-clipboard svg-formatted))))) + (clipboard/to-clipboard svg-formatted))))) (defn copy-selected-css [] @@ -362,7 +336,7 @@ (let [objects (dsh/lookup-page-objects state) selected (->> (dsh/lookup-selected state) (mapv (d/getf objects))) css (css/generate-style objects selected selected {:with-prelude? false})] - (wapi/write-to-clipboard css))))) + (clipboard/to-clipboard css))))) (defn copy-selected-css-nested [] @@ -374,7 +348,7 @@ (cfh/selected-with-children objects) (mapv (d/getf objects))) css (css/generate-style objects selected selected {:with-prelude? false})] - (wapi/write-to-clipboard css))))) + (clipboard/to-clipboard css))))) (defn copy-selected-text [] @@ -405,7 +379,7 @@ (-> shape :content txt/content->text)))) (str/join "\n"))] - (wapi/write-to-clipboard text))))) + (clipboard/to-clipboard text))))) (defn copy-selected-props [] @@ -474,7 +448,7 @@ (rx/map #(wapi/create-blob % "text/plain")) (rx/subs! resolve reject))))] - (->> (rx/from (wapi/write-to-clipboard-promise "text/plain" resolve-data-promise)) + (->> (rx/from (clipboard/to-clipboard-promise "text/plain" resolve-data-promise)) (rx/catch on-copy-error) (rx/ignore))) ;; FIXME: this is to support Firefox versions below 116 that don't support @@ -482,7 +456,7 @@ ;; https://caniuse.com/?search=ClipboardItem (->> (rx/of copy-data) (rx/mapcat resolve-images) - (rx/map #(wapi/write-to-clipboard (t/encode-str % {:type :json-verbose}))) + (rx/map #(clipboard/to-clipboard (t/encode-str % {:type :json-verbose}))) (rx/catch on-copy-error) (rx/ignore)))))))))))) @@ -502,7 +476,8 @@ (js/console.error "Clipboard error:" cause)) (rx/empty)))] - (->> (wapi/read-from-clipboard) + (->> (clipboard/from-clipboard) + (rx/mapcat #(.text %)) (rx/map decode-entry) (rx/take 1) (rx/catch on-error))))))) @@ -968,17 +943,21 @@ (deref ms/mouse-position))) (defn- paste-html-text - [html text] + [html] + (js/console.log html) (dm/assert! (string? html)) (ptk/reify ::paste-html-text ptk/WatchEvent (watch [_ state _] (let [style (deref refs/workspace-clipboard-style) root (dwtxt/create-root-from-html html style) + text (.-textContent root) content (tc/dom->cljs root)] + (js/console.log "root" root "content" content) (when (types.text/valid-content? content) + (js/console.log "valid-content") (let [id (uuid/next) - width (max 8 (min (* 7 (count text)) 700)) + width (max 8 (min (* 7 (count text)) 700)) height 16 {:keys [x y]} (calculate-paste-position state) @@ -1051,4 +1030,4 @@ (ptk/reify ::copy-link-to-clipboard ptk/WatchEvent (watch [_ _ _] - (wapi/write-to-clipboard (rt/get-current-href))))) + (clipboard/to-clipboard (rt/get-current-href))))) diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index bdb595cf2c..07283230b5 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -18,6 +18,7 @@ [app.main.store :as st] [app.main.ui.context :as ctx] [app.main.ui.debug.icons-preview :refer [icons-preview]] + [app.main.ui.debug.playground :refer [playground]] [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.error-boundary :refer [error-boundary*]] [app.main.ui.exports.files] @@ -209,6 +210,10 @@ (when *assert* [:& icons-preview]) + :debug-playground + (when *assert* + [:& playground]) + (:dashboard-search :dashboard-recent :dashboard-files diff --git a/frontend/src/app/main/ui/components/copy_button.cljs b/frontend/src/app/main/ui/components/copy_button.cljs index f9fc01f92f..d8a7b89883 100644 --- a/frontend/src/app/main/ui/components/copy_button.cljs +++ b/frontend/src/app/main/ui/components/copy_button.cljs @@ -10,10 +10,10 @@ [app.common.data.macros :as dm] [app.main.data.event :as-alias ev] [app.main.ui.icons :as deprecated-icon] + [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.timers :as tm] - [app.util.webapi :as wapi] [rumext.v2 :as mf])) (mf/defc copy-button* @@ -34,7 +34,7 @@ (reset! active* true) (tm/schedule 1000 #(reset! active* false)) (when (fn? on-copied) (on-copied event)) - (wapi/write-to-clipboard + (clipboard/to-clipboard (if (fn? data) (data) data)))))] [:button {:class class diff --git a/frontend/src/app/main/ui/debug/playground.cljs b/frontend/src/app/main/ui/debug/playground.cljs new file mode 100644 index 0000000000..16b428af64 --- /dev/null +++ b/frontend/src/app/main/ui/debug/playground.cljs @@ -0,0 +1,51 @@ +(ns app.main.ui.debug.playground + #_(:require-macros [app.main.style :as stl]) + (:require + [app.util.clipboard :as clipboard] + [beicon.v2.core :as rx] + [rumext.v2 :as mf])) + +(mf/defc playground-clipboard + {::mf/wrap-props false + ::mf/private true} + [] + (let [on-paste (mf/use-fn + (fn [e] + (let [stream (clipboard/from-clipboard-event e)] + (rx/sub! stream + (fn [data] + (js/console.log "data" data)))))) + + on-dragover (mf/use-fn + (fn [e] + (.preventDefault e))) + + on-drop (mf/use-fn + (fn [e] + (.preventDefault e) + (let [stream (clipboard/from-drop-event e)] + (rx/sub! stream + (fn [data] + (js/console.log "data" data)))))) + + on-click (mf/use-fn + (fn [e] + (js/console.log "event" e) + (let [stream (clipboard/from-clipboard)] + (rx/sub! stream + (fn [data] + (js/console.log "data" data))))))] + + (.addEventListener js/window "paste" on-paste) + (.addEventListener js/window "drop" on-drop) + (.addEventListener js/window "dragover" on-dragover) + + [:button#paste {:on-click on-click} "Paste"])) + +(mf/defc playground + {::mf/wrap-props false + ::mf/private true} + [] + [:& playground-clipboard]) + + diff --git a/frontend/src/app/main/ui/debug/playground.scss b/frontend/src/app/main/ui/debug/playground.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/app/main/ui/inspect/code.cljs b/frontend/src/app/main/ui/inspect/code.cljs index 4bff56d439..3e93b2898a 100644 --- a/frontend/src/app/main/ui/inspect/code.cljs +++ b/frontend/src/app/main/ui/inspect/code.cljs @@ -23,11 +23,11 @@ [app.main.ui.hooks.resize :refer [use-resize-hook]] [app.main.ui.icons :as deprecated-icon] [app.main.ui.shapes.text.fontfaces :refer [shapes->fonts]] + [app.util.clipboard :as clipboard] [app.util.code-beautify :as cb] [app.util.code-gen :as cg] [app.util.dom :as dom] [app.util.http :as http] - [app.util.webapi :as wapi] [beicon.v2.core :as rx] [cuerdas.core :as str] [okulary.core :as l] @@ -202,7 +202,7 @@ (mf/use-fn (mf/deps style-code markup-code images-data) (fn [] - (wapi/write-to-clipboard (gen-all-code style-code markup-code images-data)) + (clipboard/to-clipboard (gen-all-code style-code markup-code images-data)) (let [origin (if (= :workspace from) "workspace" "viewer")] diff --git a/frontend/src/app/main/ui/inspect/styles/panels/text.cljs b/frontend/src/app/main/ui/inspect/styles/panels/text.cljs index bd81ef9b39..86e36d2048 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/text.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/text.cljs @@ -16,8 +16,8 @@ [app.main.ui.inspect.styles.property-detail-copiable :refer [property-detail-copiable*]] [app.main.ui.inspect.styles.rows.color-properties-row :refer [color-properties-row*]] [app.main.ui.inspect.styles.rows.properties-row :refer [properties-row*]] + [app.util.clipboard :as clipboard] [app.util.timers :as tm] - [app.util.webapi :as wapi] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -88,7 +88,7 @@ (.toUpperCase text) text)] (reset! copied* true) - (wapi/write-to-clipboard formatted-text) + (clipboard/to-clipboard formatted-text) (tm/schedule 1000 #(reset! copied* false))))) composite-typography-token (get-resolved-token :typography shape resolved-tokens)] [:div {:class (stl/css :text-properties)} diff --git a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs index 8d8c4833c5..b9ec8ff343 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs +++ b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs @@ -14,10 +14,10 @@ [app.main.ui.ds.tooltip :refer [tooltip*]] [app.main.ui.formats :as fmt] [app.main.ui.inspect.styles.property-detail-copiable :refer [property-detail-copiable*]] + [app.util.clipboard :as clipboard] [app.util.color :as uc] [app.util.i18n :refer [tr]] [app.util.timers :as tm] - [app.util.webapi :as wapi] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -86,7 +86,7 @@ (mf/deps copied formatted-color-value) (fn [] (reset! copied* true) - (wapi/write-to-clipboard copiable-value) + (clipboard/to-clipboard copiable-value) (tm/schedule 1000 #(reset! copied* false))))] [:* [:dl {:class [(stl/css :property-row) class] diff --git a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs index 1ffc5a0b88..9f053952cd 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs +++ b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs @@ -12,9 +12,9 @@ format-token-value]] [app.main.ui.ds.tooltip :refer [tooltip*]] [app.main.ui.inspect.styles.property-detail-copiable :refer [property-detail-copiable*]] + [app.util.clipboard :as clipboard] [app.util.i18n :refer [tr]] [app.util.timers :as tm] - [app.util.webapi :as wapi] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -43,7 +43,7 @@ (mf/deps copied) (fn [] (reset! copied* true) - (wapi/write-to-clipboard copiable-value) + (clipboard/to-clipboard copiable-value) (tm/schedule 1000 #(reset! copied* false))))] [:dl {:class [(stl/css :property-row) class] :data-testid "property-row"} diff --git a/frontend/src/app/main/ui/inspect/styles/style_box.cljs b/frontend/src/app/main/ui/inspect/styles/style_box.cljs index 6251d647ea..2a5f95d127 100644 --- a/frontend/src/app/main/ui/inspect/styles/style_box.cljs +++ b/frontend/src/app/main/ui/inspect/styles/style_box.cljs @@ -10,8 +10,8 @@ [app.common.data :as d] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.util.clipboard :as clipboard] [app.util.i18n :refer [tr]] - [app.util.webapi :as wapi] [rumext.v2 :as mf])) (defn- panel->title @@ -50,7 +50,7 @@ (mf/use-fn (mf/deps shorthand) (fn [] - (wapi/write-to-clipboard (str shorthand))))] + (clipboard/to-clipboard (str shorthand))))] [:article {:class (stl/css :style-box)} [:header {:class (stl/css :disclosure-header)} [:button {:class (stl/css :disclosure-button) diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 2cde2b60df..8e3736f69f 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -48,6 +48,9 @@ (when *assert* ["/debug/icons-preview" :debug-icons-preview]) + (when *assert* + ["/debug/playground" :debug-playground]) + ;; Used for export ["/render-sprite/:file-id" :render-sprite] diff --git a/frontend/src/app/main/ui/settings/access_tokens.cljs b/frontend/src/app/main/ui/settings/access_tokens.cljs index b75f6ef4e1..29a09476b0 100644 --- a/frontend/src/app/main/ui/settings/access_tokens.cljs +++ b/frontend/src/app/main/ui/settings/access_tokens.cljs @@ -16,10 +16,10 @@ [app.main.ui.components.context-menu-a11y :refer [context-menu*]] [app.main.ui.components.forms :as fm] [app.main.ui.icons :as deprecated-icon] + [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] - [app.util.webapi :as wapi] [okulary.core :as l] [rumext.v2 :as mf])) @@ -97,7 +97,7 @@ (mf/deps created) (fn [event] (dom/prevent-default event) - (wapi/write-to-clipboard (:token created)) + (clipboard/to-clipboard (:token created)) (st/emit! (ntf/show {:level :info :type :toast :content (tr "dashboard.access-tokens.copied-success") diff --git a/frontend/src/app/main/ui/viewer/share_link.cljs b/frontend/src/app/main/ui/viewer/share_link.cljs index 0a53d77e69..646405676c 100644 --- a/frontend/src/app/main/ui/viewer/share_link.cljs +++ b/frontend/src/app/main/ui/viewer/share_link.cljs @@ -21,9 +21,9 @@ [app.main.store :as st] [app.main.ui.components.select :refer [select]] [app.main.ui.icons :as deprecated-icon] + [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] - [app.util.webapi :as wapi] [potok.v2.core :as ptk] [rumext.v2 :as mf])) @@ -133,7 +133,7 @@ copy-link (fn [_] - (wapi/write-to-clipboard current-link) + (clipboard/to-clipboard current-link) (st/emit! (ntf/show {:level :info :type :toast :content (tr "common.share-link.link-copied-success") diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 29e802b567..afa34710e2 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -34,11 +34,11 @@ [app.main.ui.context :as ctx] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.workspace.sidebar.assets.common :as cmm] + [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :refer [tr] :as i18n] [app.util.shape-icon :as usi] [app.util.timers :as timers] - [app.util.webapi :as wapi] [beicon.v2.core :as rx] [okulary.core :as l] [potok.v2.core :as ptk] @@ -181,7 +181,8 @@ handle-hover-copy-paste (mf/use-callback (fn [] - (->> (wapi/read-from-clipboard) + (->> (clipboard/from-clipboard) + (rx/mapcat #(.text %)) (rx/take 1) (rx/subs! (fn [data] diff --git a/frontend/src/app/util/clipboard.cljs b/frontend/src/app/util/clipboard.cljs new file mode 100644 index 0000000000..63d632aa76 --- /dev/null +++ b/frontend/src/app/util/clipboard.cljs @@ -0,0 +1,59 @@ +;; 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 + +(ns app.util.clipboard + (:require + ["./clipboard.js" :as clipboard] + [app.common.transit :as t] + [beicon.v2.core :as rx])) + +(def image-types + ["image/webp" + "image/png" + "image/jpeg" + "image/svg+xml"]) + +(def clipboard-settings #js {:decodeTransit t/decode-str}) + +(defn from-clipboard [] + (->> (rx/from (clipboard/fromClipboard clipboard-settings)) + (rx/mapcat #(rx/from %)))) + +(defn from-data-transfer [data-transfer] + (->> (rx/from (clipboard/fromDataTransfer data-transfer clipboard-settings)) + (rx/mapcat #(rx/from %)))) + +(defn from-clipboard-data [clipboard-data] + (from-data-transfer clipboard-data)) + +(defn from-clipboard-event [event] + (from-clipboard-data (.-clipboardData event))) + +(defn from-synthetic-clipboard-event [event] + (let [target (.-target ^js event)] + (when (and (not (.-isContentEditable ^js target)) ;; ignore when pasting into + (not= (.-tagName ^js target) "INPUT")) ;; an editable control + (from-clipboard-event (. ^js event getBrowserEvent))))) + +(defn from-drop-event [event] + (from-data-transfer (.-dataTransfer event))) + +(defn to-clipboard + [data] + (assert (string? data) "`data` should be string") + (let [clipboard (unchecked-get js/navigator "clipboard")] + (.writeText ^js clipboard data))) + +(defn- create-clipboard-item + [mimetype promise] + (js/ClipboardItem. + (js-obj mimetype promise))) + +(defn to-clipboard-promise + [mimetype promise] + (let [clipboard (unchecked-get js/navigator "clipboard") + data (create-clipboard-item mimetype promise)] + (.write ^js clipboard #js [data]))) diff --git a/frontend/src/app/util/clipboard.js b/frontend/src/app/util/clipboard.js new file mode 100644 index 0000000000..fc1f994a53 --- /dev/null +++ b/frontend/src/app/util/clipboard.js @@ -0,0 +1,151 @@ +/** + * + * 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 + */ +const maxParseableSize = 16 * 1024 * 1024; + +const allowedTypes = [ + "image/webp", + "image/png", + "image/jpeg", + "image/svg+xml", + "application/transit+json", + "text/html", + "text/plain", +]; + +const exclusiveTypes = [ + "application/transit+json", + "text/html", + "text/plain" +]; + +/** + * @typedef {Object} ClipboardSettings + * @property {Function} [decodeTransit] + */ + +/** + * + * @param {string} text + * @param {ClipboardSettings} options + * @param {Blob} [defaultReturn] + * @returns {Blob} + */ +function parseClipboardItemText( + text, + options, + defaultReturn = new Blob([text], { type: "text/plain" }), +) { + let decodedTransit = false; + try { decodedTransit = options?.decodeTransit?.(text) ?? false } + catch (error) { /* NOOP */ } + if (/^]/i.test(text)) { + return new Blob([text], { type: "image/svg+xml" }); + } else if (decodedTransit) { + return new Blob([text], { type: "application/transit+json" }); + } + return defaultReturn; +} + +/** + * + * @param {ClipboardSettings} [options] + * @returns {Promise>} + */ +export async function fromClipboard(options) { + const items = await navigator.clipboard.read(); + console.log("items", items); + return Promise.all( + Array.from(items).map(async (item) => { + const itemAllowedTypes = Array.from(item.types) + .filter((type) => allowedTypes.includes(type)) + .sort((a, b) => allowedTypes.indexOf(a) - allowedTypes.indexOf(b)); + + if ( + itemAllowedTypes.length === 1 && + itemAllowedTypes.at(0) === "text/plain" + ) { + const blob = await item.getType("text/plain"); + if (blob.size < maxParseableSize) { + const text = await blob.text(); + return parseClipboardItemText(text, options); + } + return blob; + } + + const type = itemAllowedTypes.at(0); + return item.getType(type); + }), + ); +} + +/** + * + * @param {DataTransfer} dataTransfer + * @param {ClipboardSettings} [options] + * @returns {Promise>} + */ +export async function fromDataTransfer(dataTransfer, options) { + const items = await Promise.all( + Array.from(dataTransfer.items) + .filter((item) => allowedTypes.includes(item.type)) + .sort( + (a, b) => allowedTypes.indexOf(a.type) - allowedTypes.indexOf(b.type), + ) + .map(async (item) => { + if (item.kind === "file") { + return Promise.resolve(item.getAsFile()); + } else if (item.kind === "string") { + return new Promise((resolve) => { + const type = item.type; + item.getAsString((text) => { + if (type === "text/plain") { + return resolve(parseClipboardItemText(text, options)); + } + return resolve(new Blob([text], { type })); + }); + }); + } + return Promise.resolve(null); + }), + ); + return items + .filter((item) => !!item) + .reduce((filtered, item) => { + if ( + exclusiveTypes.includes(item.type) && + filtered.find((filteredItem) => + exclusiveTypes.includes(filteredItem.type), + ) + ) { + return filtered; + } + filtered.push(item); + return filtered; + }, []); +} + +/** + * + * @param {*} clipboardData + * @param {ClipboardSettings} [options] + * @returns {Promise>} + */ +export function fromClipboardData(clipboardData, options) { + return fromDataTransfer(clipboardData, options); +} + +/** + * + * @param {*} e + * @param {ClipboardSettings} [options] + * @returns {Promise>} + */ +export function fromClipboardEvent(e, options) { + return fromClipboardData(e.clipboardData, options); +} diff --git a/frontend/src/app/util/text/content/from_dom.cljs b/frontend/src/app/util/text/content/from_dom.cljs index 840568bc6d..ceeb0f5853 100644 --- a/frontend/src/app/util/text/content/from_dom.cljs +++ b/frontend/src/app/util/text/content/from_dom.cljs @@ -37,34 +37,36 @@ (.-textContent element))) (defn get-attrs-from-styles - [element attrs] + [element attrs defaults] (reduce (fn [acc key] (let [style (.-style element)] (if (contains? styles/mapping key) (let [style-name (styles/get-style-name-as-css-variable key) [_ style-decode] (get styles/mapping key) value (style-decode (.getPropertyValue style style-name))] - (assoc acc key value)) - (let [style-name (styles/get-style-name key)] - (assoc acc key (styles/normalize-attr-value key (.getPropertyValue style style-name))))))) {} attrs)) + (assoc acc key (if (empty? value) (get defaults key) value))) + (let [style-name (styles/get-style-name key) + value (styles/normalize-attr-value key (.getPropertyValue style style-name))] + (assoc acc key (if (empty? value) (get defaults key) value)))))) {} attrs)) (defn get-inline-styles [element] - (get-attrs-from-styles element txt/text-node-attrs)) + (get-attrs-from-styles element txt/text-node-attrs (txt/get-default-text-attrs))) (defn get-paragraph-styles [element] - (get-attrs-from-styles element (d/concat-set txt/paragraph-attrs txt/text-node-attrs))) + (get-attrs-from-styles element (d/concat-set txt/paragraph-attrs txt/text-node-attrs) (d/merge txt/default-paragraph-attrs txt/default-text-attrs))) (defn get-root-styles [element] - (get-attrs-from-styles element txt/root-attrs)) + (get-attrs-from-styles element txt/root-attrs txt/default-root-attrs)) (defn create-inline [element] - (d/merge {:text (get-inline-text element) - :key (.-id element)} - (get-inline-styles element))) + (let [text (get-inline-text element)] + (d/merge {:text text + :key (.-id element)} + (get-inline-styles element)))) (defn create-paragraph [element] @@ -76,7 +78,7 @@ (defn create-root [element] (let [root-styles (get-root-styles element)] - (d/merge {:type "root", + (d/merge {:type "root" :key (.-id element) :children [{:type "paragraph-set" :children (mapv create-paragraph (.-children element))}]} diff --git a/frontend/src/app/util/webapi.cljs b/frontend/src/app/util/webapi.cljs index 1b6b423c15..0833ac70d0 100644 --- a/frontend/src/app/util/webapi.cljs +++ b/frontend/src/app/util/webapi.cljs @@ -7,7 +7,6 @@ (ns app.util.webapi "HTML5 web api helpers." (:require - [app.common.data :as d] [app.common.exceptions :as ex] [app.common.logging :as log] [app.util.globals :as globals] @@ -115,68 +114,6 @@ [] (.. js/window getSelection toString)) -(defn write-to-clipboard - [data] - (assert (string? data) "`data` should be string") - (let [cboard (unchecked-get js/navigator "clipboard")] - (.writeText ^js cboard data))) - -(defn write-to-clipboard-promise - [mimetype promise] - (let [cboard (unchecked-get js/navigator "clipboard") - data (js/ClipboardItem. - (-> (obj/create) - (obj/set! mimetype promise)))] - (.write ^js cboard #js [data]))) - -(defn read-from-clipboard - [] - (try - (let [cboard (unchecked-get js/navigator "clipboard")] - (if (.-readText ^js cboard) - (rx/from (.readText ^js cboard)) - (rx/throw (ex-info "This browser does not implement read from clipboard protocol" - {:not-implemented true})))) - (catch :default cause - (rx/throw cause)))) - -(defn read-image-from-clipboard - [] - (try - (let [cboard (unchecked-get js/navigator "clipboard") - read-item (fn [item] - (let [img-type (->> (.-types ^js item) - (d/seek #(str/starts-with? % "image/")))] - (if img-type - (rx/from (.getType ^js item img-type)) - (rx/empty))))] - (->> (rx/from (.read ^js cboard)) ;; Get a stream of item lists - (rx/mapcat identity) ;; Convert each item into an emission - (rx/switch-map read-item))) - (catch :default cause - (rx/throw cause)))) - -(defn read-from-paste-event - [event] - (let [target (.-target ^js event)] - (when (and (not (.-isContentEditable ^js target)) ;; ignore when pasting into - (not= (.-tagName ^js target) "INPUT")) ;; an editable control - (.. ^js event getBrowserEvent -clipboardData)))) - -(defn extract-html-text - [clipboard-data] - (.getData clipboard-data "text/html")) - -(defn extract-text - [clipboard-data] - (.getData clipboard-data "text")) - -(defn extract-images - "Get image files from clipboard data. Returns a native js array." - [clipboard-data] - (let [files (obj/into-array (.-files ^js clipboard-data))] - (.filter ^js files #(str/starts-with? (obj/get % "type") "image/")))) - (defn create-canvas-element [width height] (let [canvas (.createElement js/document "canvas")]