Copy/paste properties an CSS

This commit is contained in:
alonso.torres 2025-01-10 11:50:32 +01:00
parent 80d6968156
commit 714a274789
11 changed files with 1485 additions and 23 deletions

View file

@ -81,6 +81,7 @@
[app.main.streams :as ms]
[app.main.worker :as uw]
[app.render-wasm :as wasm]
[app.util.code-gen.style-css :as css]
[app.util.dom :as dom]
[app.util.globals :as ug]
[app.util.http :as http]
@ -1411,7 +1412,8 @@
(rx/catch on-copy-error)
(rx/ignore))))))))))
(declare ^:private paste-transit)
(declare ^:private paste-transit-shapes)
(declare ^:private paste-transit-props)
(declare ^:private paste-html-text)
(declare ^:private paste-text)
(declare ^:private paste-image)
@ -1441,7 +1443,7 @@
(rx/of (paste-text data)))
:transit
(rx/of (paste-transit data))))
(rx/of (paste-transit-shapes data))))
(on-error [cause]
(let [data (ex-data cause)]
@ -1462,7 +1464,6 @@
(rx/take 1)
(rx/catch on-error))))))
(defn paste-from-event
"Perform a `paste` operation from user emmited event."
[event in-viewport?]
@ -1491,7 +1492,7 @@
(rx/map paste-image))
(coll? transit-data)
(rx/of (paste-transit (assoc transit-data :in-viewport in-viewport?)))
(rx/of (paste-transit-shapes (assoc transit-data :in-viewport in-viewport?)))
(string? html-data)
(rx/of (paste-html-text html-data text-data))
@ -1502,6 +1503,122 @@
:else
(rx/empty))))))))
(defn copy-selected-css
[]
(ptk/reify ::copy-selected-css
ptk/EffectEvent
(effect [_ state _]
(let [objects (wsh/lookup-page-objects state)
selected (->> (wsh/lookup-selected state) (mapv (d/getf objects)))
css (css/generate-style objects selected selected {:with-prelude? false})]
(wapi/write-to-clipboard css)))))
(defn copy-selected-css-nested
[]
(ptk/reify ::copy-selected-css-nested
ptk/EffectEvent
(effect [_ state _]
(let [objects (wsh/lookup-page-objects state)
selected (->> (wsh/lookup-selected state)
(cfh/selected-with-children objects)
(mapv (d/getf objects)))
css (css/generate-style objects selected selected {:with-prelude? false})]
(wapi/write-to-clipboard css)))))
(defn copy-selected-props
[]
(ptk/reify ::copy-selected-props
ptk/WatchEvent
(watch [_ state _]
(letfn [(fetch-image [entry]
(let [url (cf/resolve-file-media entry)]
(->> (http/send! {:method :get
:uri url
:response-type :blob})
(rx/map :body)
(rx/mapcat wapi/read-file-as-data-url)
(rx/map #(assoc entry :data %)))))
(resolve-images [data]
(let [images
(concat
(->> data :props :fills (keep :fill-image))
(->> data :props :strokes (keep :stroke-image)))]
(if (seq images)
(->> (rx/from images)
(rx/mapcat fetch-image)
(rx/reduce conj #{})
(rx/map #(assoc data :images %)))
(rx/of data))))
(on-copy-error [error]
(js/console.error "clipboard blocked:" error)
(rx/empty))]
(let [selected (->> (wsh/lookup-selected state) first)
objects (wsh/lookup-page-objects state)]
(when-let [shape (get objects selected)]
(let [props (cts/extract-props shape)
features (-> (features/get-team-enabled-features state)
(set/difference cfeat/frontend-only-features))
version (dm/get-in state [:workspace-file :version])
copy-data {:type :copied-props
:features features
:version version
:props props
:images #{}}]
;; The clipboard API doesn't handle well asynchronous calls because it expects to use
;; the clipboard in an user interaction. If you do an async call the callback is outside
;; the thread of the UI and so Safari blocks the copying event.
;; We use the API `ClipboardItem` that allows promises to be passed and so the event
;; will wait for the promise to resolve and everything should work as expected.
;; This only works in the current versions of the browsers.
(if (some? (unchecked-get ug/global "ClipboardItem"))
(let [resolve-data-promise
(p/create
(fn [resolve reject]
(->> (rx/of copy-data)
(rx/mapcat resolve-images)
(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/catch on-copy-error)
(rx/ignore)))
;; FIXME: this is to support Firefox versions below 116 that don't support
;; `ClipboardItem` after the version 116 is less common we could remove this.
;; 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/catch on-copy-error)
(rx/ignore))))))))))
(defn paste-selected-props
[]
(ptk/reify ::paste-selected-props
ptk/WatchEvent
(watch [_ _ _]
(letfn [(decode-entry [entry]
(-> entry t/decode-str paste-transit-props))
(on-error [cause]
(let [data (ex-data cause)]
(if (:not-implemented data)
(rx/of (ntf/warn (tr "errors.clipboard-not-implemented")))
(js/console.error "Clipboard error:" cause))
(rx/empty)))]
(->> (wapi/read-from-clipboard)
(rx/map decode-entry)
(rx/take 1)
(rx/catch on-error))))))
(defn selected-frame? [state]
(let [selected (wsh/lookup-selected state)
objects (wsh/lookup-page-objects state)]
@ -1530,8 +1647,8 @@
(:width (:selrect frame-obj)))))
(def ^:private
schema:paste-data
[:map {:title "paste-data"}
schema:paste-data-shapes
[:map {:title "paste-data-shapes"}
[:type [:= :copied-shapes]]
[:features ::sm/set-of-strings]
[:version :int]
@ -1542,12 +1659,26 @@
[:images [:set :map]]
[:position {:optional true} ::gpt/point]])
(def ^:private
schema:paste-data-props
[:map {:title "paste-data-props"}
[:type [:= :copied-props]]
[:features ::sm/set-of-strings]
[:version :int]
[:props
;; todo type the properties
[:map-of :keyword :any]]])
(def schema:paste-data
[:multi {:title "paste-data" :dispatch :type}
[:copied-shapes schema:paste-data-shapes]
[:copied-props schema:paste-data-props]])
(def paste-data-valid?
(sm/lazy-validator schema:paste-data))
(defn- paste-transit
(defn- paste-transit-shapes
[{:keys [images] :as pdata}]
(letfn [(upload-media [file-id imgpart]
(->> (http/send! {:uri (:data imgpart)
:response-type :blob
@ -1562,7 +1693,7 @@
(rx/mapcat (partial rp/cmd! :upload-file-media-object))
(rx/map #(assoc % :prev-id (:id imgpart)))))]
(ptk/reify ::paste-transit
(ptk/reify ::paste-transit-shapes
ptk/WatchEvent
(watch [_ state _]
(let [file-id (:current-file-id state)
@ -1574,14 +1705,88 @@
:hibt "invalid paste data found"))
(cfeat/check-paste-features! features (:features pdata))
(if (= file-id (:file-id pdata))
(let [pdata (assoc pdata :images [])]
(rx/of (paste-shapes pdata)))
(->> (rx/from images)
(case (:type pdata)
:copied-shapes
(if (= file-id (:file-id pdata))
(let [pdata (assoc pdata :images [])]
(rx/of (paste-shapes pdata)))
(->> (rx/from images)
(rx/merge-map (partial upload-media file-id))
(rx/reduce conj [])
(rx/map #(assoc pdata :images %))
(rx/map paste-shapes)))
nil))))))
(defn- paste-transit-props
[pdata]
(letfn [(upload-media [file-id imgpart]
(->> (http/send! {:uri (:data imgpart)
:response-type :blob
:method :get})
(rx/map :body)
(rx/map
(fn [blob]
{:name (:name imgpart)
:file-id file-id
:content blob
:is-local true}))
(rx/mapcat (partial rp/cmd! :upload-file-media-object))
(rx/map #(vector (:id imgpart) %))))
(update-image-data
[pdata media-map]
(update
pdata :props
(fn [props]
(-> props
(d/update-when
:fills
(fn [fills]
(mapv (fn [fill]
(cond-> fill
(some? (:fill-image fill))
(update-in [:fill-image :id] #(get media-map % %))))
fills)))
(d/update-when
:strokes
(fn [strokes]
(mapv (fn [stroke]
(cond-> stroke
(some? (:stroke-image stroke))
(update-in [:stroke-image :id] #(get media-map % %))))
strokes)))))))
(upload-images
[file-id pdata]
(->> (rx/from (:images pdata))
(rx/merge-map (partial upload-media file-id))
(rx/reduce conj [])
(rx/map #(assoc pdata :images %))
(rx/map paste-shapes))))))))
(rx/reduce conj {})
(rx/map (partial update-image-data pdata))))]
(ptk/reify ::paste-transit-props
ptk/WatchEvent
(watch [_ state _]
(let [features (features/get-team-enabled-features state)
selected (wsh/lookup-selected state)]
(when (paste-data-valid? pdata)
(cfeat/check-paste-features! features (:features pdata))
(case (:type pdata)
:copied-props
(rx/concat
(->> (rx/of pdata)
(rx/mapcat (partial upload-images (:current-file-id state)))
(rx/map
#(dwsh/update-shapes
selected
(fn [shape objects] (cts/patch-props shape (:props pdata) objects))
{:with-objects? true})))
(rx/of (ptk/data-event :layout/update {:ids selected})))
;;
(rx/empty))))))))
(defn paste-shapes
[{in-viewport? :in-viewport :as pdata}]

View file

@ -85,8 +85,8 @@
:subsections [:edit]
:fn #(st/emit! (dw/copy-selected))}
:copy-link {:tooltip (ds/meta (ds/alt "C"))
:command (ds/c-mod "alt+c")
:copy-link {:tooltip (ds/shift (ds/alt "C"))
:command "shift+alt+c"
:subsections [:edit]
:fn #(st/emit! (dw/copy-link-to-clipboard))}
@ -103,6 +103,16 @@
:subsections [:edit]
:fn (constantly nil)}
:copy-props {:tooltip (ds/meta (ds/alt "c"))
:command (ds/c-mod "alt+c")
:subsections [:edit]
:fn #(st/emit! (dw/copy-selected-props))}
:paste-props {:tooltip (ds/meta (ds/alt "v"))
:command (ds/c-mod "alt+v")
:subsections [:edit]
:fn #(st/emit! (dw/paste-selected-props))}
:delete {:tooltip (ds/supr)
:command ["del" "backspace"]
:subsections [:edit]

View file

@ -321,7 +321,7 @@
.comment-input {
@include bodySmallTypography;
white-space: pre;
white-space: pre-line;
background: var(--input-background-color);
border-radius: $br-8;
border: $s-1 solid var(--input-border-color);

View file

@ -11,10 +11,12 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.transit :as t]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.page :as ctp]
[app.common.types.shape.layout :as ctl]
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.shortcuts :as scd]
@ -35,6 +37,8 @@
[app.util.dom :as dom]
[app.util.i18n :refer [tr] :as i18n]
[app.util.timers :as timers]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[okulary.core :as l]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
@ -141,12 +145,44 @@
do-cut #(st/emit! (dw/copy-selected)
(dw/delete-selected))
do-paste #(st/emit! (dw/paste-from-clipboard))
do-duplicate #(st/emit! (dw/duplicate-selected true))]
do-duplicate #(st/emit! (dw/duplicate-selected true))
enabled-paste-props* (mf/use-state false)
handle-copy-css
(mf/use-callback #(st/emit! (dw/copy-selected-css)))
handle-copy-css-nested
(mf/use-callback #(st/emit! (dw/copy-selected-css-nested)))
handle-copy-props
(mf/use-callback #(st/emit! (dw/copy-selected-props)))
handle-paste-props
(mf/use-callback #(st/emit! (dw/paste-selected-props)))
handle-hover-copy-paste
(mf/use-callback
(fn []
(->> (wapi/read-from-clipboard)
(rx/take 1)
(rx/subs!
(fn [data]
(try
(let [pdata (t/decode-str data)]
(reset! enabled-paste-props*
(and (dw/paste-data-valid? pdata)
(= :copied-props (:type pdata)))))
(catch :default _
(reset! enabled-paste-props* false))))
(fn []
(reset! enabled-paste-props* false))))))]
[:*
[:> menu-entry* {:title (tr "workspace.shape.menu.copy")
:shortcut (sc/get-tooltip :copy)
:on-click do-copy}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy_link")
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-link")
:shortcut (sc/get-tooltip :copy-link)
:on-click do-copy-link}]
[:> menu-entry* {:title (tr "workspace.shape.menu.cut")
@ -159,6 +195,23 @@
:shortcut (sc/get-tooltip :duplicate)
:on-click do-duplicate}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-paste-as")
:on-pointer-enter (when (cf/check-browser? :chrome) handle-hover-copy-paste)}
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-css")
:on-click handle-copy-css}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-css-nested")
:on-click handle-copy-css-nested}]
[:> menu-separator* {}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-props")
:shortcut (sc/get-tooltip :copy-props)
:on-click handle-copy-props}]
[:> menu-entry* {:title (tr "workspace.shape.menu.paste-props")
:shortcut (sc/get-tooltip :paste-props)
:disabled (and (cf/check-browser? :chrome) (not @enabled-paste-props*))
:on-click handle-paste-props}]]
[:> menu-separator* {}]]))
(mf/defc context-menu-layer-position*