diff --git a/CHANGES.md b/CHANGES.md index c257bb64e..df5ae4b93 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -22,11 +22,11 @@ - Constraints are not well assigned when default and multiselection [Taiga #3069](https://tree.taiga.io/project/penpot/issue/3069) - Exporting big files flow [Taiga #2218](https://tree.taiga.io/project/penpot/us/2218) - Multiexport from main menu [Taiga #520](https://tree.taiga.io/project/penpot/us/28541) -- Multipexport assets (aka bulk export) [Taiga #520](https://tree.taiga.io/project/penpot/us/520) +- Multiexport assets (aka bulk export) [Taiga #520](https://tree.taiga.io/project/penpot/us/520) - Set the artboard layer fixed at the top side of the layers [Taiga #2636](https://tree.taiga.io/project/penpot/us/2636) - Set an artboard as the file thumbnail [Taiga #1526](https://tree.taiga.io/project/penpot/us/1526) - Social login redesign [Taiga #2974](https://tree.taiga.io/project/penpot/task/2974) -- Add border radius to our artboars [Taiga #2056](https://tree.taiga.io/project/penpot/us/2056) +- Add border radius to our artboards [Taiga #2056](https://tree.taiga.io/project/penpot/us/2056) - Allow send multiple team invitations at once [Taiga #2798](https://tree.taiga.io/project/penpot/us/2798) - Persist color palette and color picker across refresh [Taiga #1660](https://tree.taiga.io/project/penpot/issue/1660) - Ability to add multiple strokes to a shape [Taiga #2778](https://tree.taiga.io/project/penpot/us/2778) @@ -42,9 +42,13 @@ - Allow registration with invitation token when registration is disabled - Add the ability to disable standard, password login [Taiga #2999](https://tree.taiga.io/project/penpot/us/2999) - Don't stop SVG import when an image cannot be imported [#1531](https://github.com/penpot/penpot/issues/1531) +- Fix paste shapes while editing text [Taiga #2396](https://tree.taiga.io/project/penpot/issue/2396) ### :bug: Bugs fixed +- Avoid numeric inputs to allow big numbers [Taiga #2858](https://tree.taiga.io/project/penpot/issue/2858) +- Fix component contex menu size [Taiga #2480](https://tree.taiga.io/project/penpot/issue/2480) +- Add shadow to artboard make it lose the fill [Taiga #3139](https://tree.taiga.io/project/penpot/issue/3139) - Avoid numeric inputs to change its value without focusing them [Taiga #3140](https://tree.taiga.io/project/penpot/issue/3140) - Fix comments modal when changing pages [Taiga #2597](https://tree.taiga.io/project/penpot/issue/2508) - Copy paste inside a text layer leaves pasted text transparent [Taiga #3096](https://tree.taiga.io/project/penpot/issue/3096) @@ -71,10 +75,19 @@ - Fix shift+2 shortcut in MacOS with non-english keyboards [Taiga #3038](https://tree.taiga.io/project/penpot/issue/3038) - Some fixes to SVG imports [Taiga #3122](https://tree.taiga.io/project/penpot/issue/3122) [#1720](https://github.com/penpot/penpot/issues/1720) [Taiga #2884](https://tree.taiga.io/project/penpot/issue/2884) - Fix drag guides to delete target area [#1679](https://github.com/penpot/penpot/issues/1679) +- Fix undo when rotating groups [Taiga #3136](https://tree.taiga.io/project/penpot/issue/3136) +- Fix component name in sidebar widget [Taiga #3144](https://tree.taiga.io/project/penpot/issue/3144) ### :arrow_up: Deps updates ### :heart: Community contributions by (Thank you!) +## 1.12.3-beta + +### :bug: Bugs fixed + +- Fix issue with shift+select to deselect shapes [Taiga #3154](https://tree.taiga.io/project/penpot/issue/3154) +- Fix issue with drag-select shapes [Taiga #3165](https://tree.taiga.io/project/penpot/issue/3165) + ## 1.12.2-beta ### :bug: Bugs fixed diff --git a/backend/deps.edn b/backend/deps.edn index 70251e7fa..0911ba205 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -1,5 +1,6 @@ {:deps {penpot/common {:local/root "../common"} + org.clojure/clojure {:mvn/version "1.10.3"} org.clojure/core.async {:mvn/version "1.5.648"} ;; Logging diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index 11d6116ad..be90bec85 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -17,10 +17,11 @@ [app.srepl.dev :as dev] [app.util.blob :as blob] [app.util.time :as dt] - [fipp.edn :refer [pprint]] [clojure.spec.alpha :as s] + [clojure.walk :as walk] [cuerdas.core :as str] - [expound.alpha :as expound])) + [expound.alpha :as expound] + [fipp.edn :refer [pprint]])) (defn update-file ([system id f] (update-file system id f false)) @@ -66,86 +67,48 @@ (db/insert! conn :file params) (:id file)))))) -(defn verify-files - [system {:keys [age sleep chunk-size max-chunks stop-on-error? verbose?] - :or {sleep 1000 - age "72h" - chunk-size 10 - verbose? false - stop-on-error? true - max-chunks ##Inf}}] +;; (defn check-image-shapes +;; [{:keys [data] :as file} stats] +;; (println "=> analizing file:" (:name file) (:id file)) +;; (swap! stats update :total-files (fnil inc 0)) +;; (let [affected? (atom false)] +;; (walk/prewalk (fn [obj] +;; (when (and (map? obj) (= :image (:type obj))) +;; (when-let [fcolor (some-> obj :fill-color str/upper)] +;; (when (or (= fcolor "#B1B2B5") +;; (= fcolor "#7B7D85")) +;; (reset! affected? true) +;; (swap! stats update :affected-shapes (fnil inc 0)) +;; (println "--> image shape:" ((juxt :id :name :fill-color :fill-opacity) obj))))) +;; obj) +;; data) +;; (when @affected? +;; (swap! stats update :affected-files (fnil inc 0))))) - (letfn [(retrieve-chunk [conn cursor] - (let [sql (str "select id, name, modified_at, data from file " - " where modified_at > ? and deleted_at is null " - " order by modified_at asc limit ?") - age (if cursor - cursor - (-> (dt/now) (dt/minus age)))] - (seq (db/exec! conn [sql age chunk-size])))) +(defn analyze-files + [system {:keys [sleep chunk-size max-chunks on-file] + :or {sleep 1000 chunk-size 10 max-chunks ##Inf}}] + (let [stats (atom {})] + (letfn [(retrieve-chunk [conn cursor] + (let [sql (str "select id, name, modified_at, data from file " + " where modified_at < ? and deleted_at is null " + " order by modified_at desc limit ?")] + (->> (db/exec! conn [sql cursor chunk-size]) + (map #(update % :data blob/decode))))) - (validate-item [{:keys [id data modified-at] :as file}] - (let [data (blob/decode data) - valid? (s/valid? ::spec.file/data data)] + (process-chunk [chunk] + (loop [items chunk] + (when-let [item (first items)] + (on-file item stats) + (recur (rest items)))))] - (l/debug :hint "validated file" - :file-id id - :age (-> (dt/diff modified-at (dt/now)) - (dt/truncate :minutes) - (str) - (subs 2) - (str/lower)) - :valid valid?) - - (when (and (not valid?) verbose?) - (let [edata (-> (s/explain-data ::spec.file/data data) - (update ::s/problems #(take 5 %)))] - (binding [s/*explain-out* expound/printer] - (l/warn ::l/raw (with-out-str (s/explain-out edata)))))) - - (when (and (not valid?) stop-on-error?) - (throw (ex-info "penpot/abort" {}))) - - valid?)) - - (validate-chunk [chunk] - (loop [items chunk - success 0 - errored 0] - - (if-let [item (first items)] - (if (validate-item item) - (recur (rest items) (inc success) errored) - (recur (rest items) success (inc errored))) - [(:modified-at (last chunk)) - success - errored]))) - - (fmt-result [ns ne] - {:total (+ ns ne) - :errors ne - :success ns}) - - ] - - (try (db/with-atomic [conn (:app.db/pool system)] - (loop [cursor nil - chunks 0 - success 0 - errors 0] - (if (< chunks max-chunks) - (if-let [chunk (retrieve-chunk conn cursor)] - (let [[cursor success' errors'] (validate-chunk chunk)] + (loop [cursor (dt/now) + chunks 0] + (when (< chunks max-chunks) + (when-let [chunk (retrieve-chunk conn cursor)] + (let [cursor (-> chunk last :modified-at)] + (process-chunk chunk) (Thread/sleep (inst-ms (dt/duration sleep))) - (recur cursor - (inc chunks) - (+ success success') - (+ errors errors'))) - (fmt-result success errors)) - (fmt-result success errors)))) - (catch Throwable cause - (when (not= "penpot/abort" (ex-message cause)) - (throw cause)) - :error)))) - + (recur cursor (inc chunks)))))) + @stats)))) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 77f39b694..a67869186 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -6,7 +6,8 @@ (ns app.common.data "Data manipulation and query helper functions." - (:refer-clojure :exclude [read-string hash-map merge name parse-double group-by iteration]) + (:refer-clojure :exclude [read-string hash-map merge name update-vals + parse-double group-by iteration]) #?(:cljs (:require-macros [app.common.data])) (:require @@ -198,6 +199,23 @@ ([mfn coll] (into {} (mapm mfn) coll))) +;; TEMPORARY COPY of clojure.core/update-vals until we migrate to clojure 1.11 + +(defn update-vals + "m f => {k (f v) ...} + Given a map m and a function f of 1-argument, returns a new map where the keys of m + are mapped to result of applying f to the corresponding values of m." + [m f] + (with-meta + (persistent! + (reduce-kv (fn [acc k v] (assoc! acc k (f v))) + (if #?(:clj (instance? clojure.lang.IEditableCollection m) + :cljs (implements? core/IEditableCollection m)) + (transient m) + (transient {})) + m)) + (meta m))) + (defn removev "Returns a vector of the items in coll for which (fn item) returns logical false" [fn coll] @@ -653,3 +671,13 @@ (recur acc (step k)) acc))) acc)))))) +(defn toggle-selection + ([set value] + (toggle-selection set value false)) + + ([set value toggle?] + (if-not toggle? + (conj (ordered-set) value) + (if (contains? set value) + (disj set value) + (conj set value))))) diff --git a/common/src/app/common/geom/matrix.cljc b/common/src/app/common/geom/matrix.cljc index f95726a16..b8e0bf19e 100644 --- a/common/src/app/common/geom/matrix.cljc +++ b/common/src/app/common/geom/matrix.cljc @@ -26,7 +26,7 @@ (toString [_] (str "matrix(" a "," b "," c "," d "," e "," f ")"))) -(defn ^boolean matrix? +(defn matrix? "Return true if `v` is Matrix instance." [v] (instance? Matrix v)) @@ -57,6 +57,15 @@ (map (comp d/parse-double first)))] (apply matrix params))) +(defn close? + [m1 m2] + (and (mth/close? (.-a m1) (.-a m2)) + (mth/close? (.-b m1) (.-b m2)) + (mth/close? (.-c m1) (.-c m2)) + (mth/close? (.-d m1) (.-d m2)) + (mth/close? (.-e m1) (.-e m2)) + (mth/close? (.-f m1) (.-f m2)))) + (defn multiply ([^Matrix m1 ^Matrix m2] (let [m1a (.-a m1) @@ -111,7 +120,7 @@ ([{x :x y :y :as pt}] (assert (gpt/point? pt)) (Matrix. 1 0 0 1 x y)) - + ([x y] (translate-matrix (gpt/point x y)))) diff --git a/common/src/app/common/geom/point.cljc b/common/src/app/common/geom/point.cljc index f79743f7a..9472fdce3 100644 --- a/common/src/app/common/geom/point.cljc +++ b/common/src/app/common/geom/point.cljc @@ -21,7 +21,7 @@ (defn s [{:keys [x y]}] (str "(" x "," y ")")) -(defn ^boolean point? +(defn point? "Return true if `v` is Point instance." [v] (or (instance? Point v) @@ -33,8 +33,7 @@ (s/def ::point (s/and (s/keys :req-un [::x ::y]) point?)) - -(defn ^boolean point-like? +(defn point-like? [{:keys [x y] :as v}] (and (map? v) (not (nil? x)) @@ -61,6 +60,11 @@ ([x y] (Point. x y))) +(defn close? + [p1 p2] + (and (mth/close? (:x p1) (:x p2)) + (mth/close? (:y p1) (:y p2)))) + (defn angle->point [{:keys [x y]} angle distance] (point (+ x (* distance (mth/cos angle))) diff --git a/common/src/app/common/geom/shapes/rect.cljc b/common/src/app/common/geom/shapes/rect.cljc index 0d36ad4a7..692c1d2ba 100644 --- a/common/src/app/common/geom/shapes/rect.cljc +++ b/common/src/app/common/geom/shapes/rect.cljc @@ -34,6 +34,24 @@ :width width :height height}))) +(defn close-rect? + [rect1 rect2] + (and (mth/close? (:x rect1) (:x rect2)) + (mth/close? (:y rect1) (:y rect2)) + (mth/close? (:width rect1) (:width rect2)) + (mth/close? (:height rect1) (:height rect2)))) + +(defn close-selrect? + [selrect1 selrect2] + (and (mth/close? (:x selrect1) (:x selrect2)) + (mth/close? (:y selrect1) (:y selrect2)) + (mth/close? (:x1 selrect1) (:x1 selrect2)) + (mth/close? (:y1 selrect1) (:y1 selrect2)) + (mth/close? (:x2 selrect1) (:x2 selrect2)) + (mth/close? (:y2 selrect1) (:y2 selrect2)) + (mth/close? (:width selrect1) (:width selrect2)) + (mth/close? (:height selrect1) (:height selrect2)))) + (defn rect->points [{:keys [x y width height]}] (when (d/num? x y) (let [width (max width 0.01) diff --git a/common/src/app/common/pages/changes_builder.cljc b/common/src/app/common/pages/changes_builder.cljc index e30f19dcd..96d9562c1 100644 --- a/common/src/app/common/pages/changes_builder.cljc +++ b/common/src/app/common/pages/changes_builder.cljc @@ -8,8 +8,12 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.geom.shapes.bool :as gshb] + [app.common.geom.shapes.rect :as gshr] + [app.common.math :as mth] [app.common.pages :as cp] [app.common.pages.helpers :as cph] [app.common.uuid :as uuid])) @@ -379,8 +383,24 @@ generate-operation (fn [operations attr old new] (let [old-val (get old attr) - new-val (get new attr)] - (if (= old-val new-val) + new-val (get new attr) + + equal? (cond + (and (number? old-val) (number? new-val)) + (mth/close? old-val new-val) + + (and (gmt/matrix? old-val) (gmt/matrix? new-val)) + (gmt/close? old-val new-val) + + (= attr :points) + (every? #(apply gpt/close? %) (d/zip old-val new-val)) + + (= attr :selrect) + (gshr/close-selrect? old-val new-val) + + :else + (= old-val new-val))] + (if equal? operations (-> operations (update :rops conj {:type :set :attr attr :val new-val :ignore-touched true}) @@ -390,8 +410,8 @@ (fn [changes parent] (let [children (->> parent :shapes (map (d/getf objects))) resized-parent (cond - (empty? children) - changes + (empty? children) ;; a parent with no children will be deleted, + nil ;; so it does not need resize (= (:type parent) :bool) (gshb/update-bool-selrect parent children objects) @@ -399,21 +419,22 @@ (= (:type parent) :group) (if (:masked-group? parent) (gsh/update-mask-selrect parent children) - (gsh/update-group-selrect parent children))) + (gsh/update-group-selrect parent children)))] + (if resized-parent + (let [{rops :rops uops :uops} + (reduce #(generate-operation %1 %2 parent resized-parent) + {:rops [] :uops []} + (keys parent)) - {rops :rops uops :uops} - (reduce #(generate-operation %1 %2 parent resized-parent) - {:rops [] :uops []} - (keys parent)) + change {:type :mod-obj + :page-id page-id + :id (:id parent)}] - change {:type :mod-obj - :page-id page-id - :id (:id parent)}] - - (if (seq rops) - (-> changes - (update :redo-changes conj (assoc change :operations rops)) - (update :undo-changes conj (assoc change :operations uops))) + (if (seq rops) + (-> changes + (update :redo-changes conj (assoc change :operations rops)) + (update :undo-changes d/preconj (assoc change :operations uops))) + changes)) changes)))] (-> (reduce resize-parent changes all-parents) diff --git a/common/src/app/common/pages/common.cljc b/common/src/app/common/pages/common.cljc index 8cd547d62..a8221f7f0 100644 --- a/common/src/app/common/pages/common.cljc +++ b/common/src/app/common/pages/common.cljc @@ -9,7 +9,7 @@ [app.common.colors :as clr] [app.common.uuid :as uuid])) -(def file-version 16) +(def file-version 17) (def default-color clr/gray-20) (def root uuid/zero) diff --git a/common/src/app/common/pages/helpers.cljc b/common/src/app/common/pages/helpers.cljc index ce5dd18a4..b042d1c2d 100644 --- a/common/src/app/common/pages/helpers.cljc +++ b/common/src/app/common/pages/helpers.cljc @@ -17,24 +17,28 @@ ;; GENERIC SHAPE SELECTORS AND PREDICATES ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn ^boolean root-frame? +(defn root-frame? [{:keys [id type]}] (and (= type :frame) (= id uuid/zero))) -(defn ^boolean frame-shape? +(defn frame-shape? [{:keys [type]}] (= type :frame)) -(defn ^boolean group-shape? +(defn group-shape? [{:keys [type]}] (= type :group)) -(defn ^boolean text-shape? +(defn text-shape? [{:keys [type]}] (= type :text)) -(defn ^boolean unframed-shape? +(defn image-shape? + [{:keys [type]}] + (= type :image)) + +(defn unframed-shape? "Checks if it's a non-frame shape in the top level." [shape] (and (not (frame-shape? shape)) @@ -214,7 +218,7 @@ ([libraries library-id component-id] (get-in libraries [library-id :data :components component-id]))) -(defn ^boolean is-main-of? +(defn is-main-of? [shape-main shape-inst] (and (:shape-ref shape-inst) (or (= (:shape-ref shape-inst) (:id shape-main)) diff --git a/common/src/app/common/pages/migrations.cljc b/common/src/app/common/pages/migrations.cljc index d70d0e345..95e0a1840 100644 --- a/common/src/app/common/pages/migrations.cljc +++ b/common/src/app/common/pages/migrations.cljc @@ -10,26 +10,26 @@ [app.common.geom.matrix :as gmt] [app.common.geom.shapes :as gsh] [app.common.geom.shapes.path :as gsp] + [app.common.logging :as l] [app.common.math :as mth] [app.common.pages :as cp] - [app.common.uuid :as uuid])) + [app.common.pages.helpers :as cph] + [app.common.uuid :as uuid] + [cuerdas.core :as str])) ;; TODO: revisit this and rename to file-migrations (defmulti migrate :version) (defn migrate-data - ([data] - (if (= (:version data) cp/file-version) + ([data] (migrate-data data cp/file-version)) + ([data to-version] + (if (= (:version data) to-version) data - (reduce #(migrate-data %1 %2 (inc %2)) - data - (range (:version data 0) cp/file-version)))) - - ([data _ to-version] - (-> data - (assoc :version to-version) - (migrate)))) + (let [migrate-fn #(do + (l/trace :hint "migrate file" :id (:id %) :version-from %2 :version-to (inc %2)) + (migrate (assoc %1 :version (inc %2))))] + (reduce migrate-fn data (range (:version data 0) to-version)))))) (defn migrate-file [file] @@ -45,17 +45,16 @@ ;; Ensure that all :shape attributes on shapes are vectors. (defmethod migrate 2 [data] - (letfn [(update-object [_ object] + (letfn [(update-object [object] (d/update-when object :shapes (fn [shapes] (if (seq? shapes) (into [] shapes) shapes)))) + (update-page [page] + (update page :objects d/update-vals update-object))] - (update-page [_ page] - (update page :objects #(d/mapm update-object %)))] - - (update data :pages-index #(d/mapm update-page %)))) + (update data :pages-index d/update-vals update-page))) ;; Changes paths formats (defmethod migrate 3 @@ -89,7 +88,7 @@ (empty? (:points shape)) (assoc :points (gsh/rect->points (:selrect shape)))))) - (update-object [_ object] + (update-object [object] (cond-> object (= :curve (:type object)) (assoc :type :path) @@ -97,25 +96,22 @@ (#{:curve :path} (:type object)) (migrate-path) - (= :frame (:type object)) + (cph/frame-shape? object) (fix-frames-selrects) (and (empty? (:points object)) (not= (:id object) uuid/zero)) (fix-empty-points) + ;; Setup an empty transformation to re-calculate selrects + ;; and points data :always - (-> - ;; Setup an empty transformation to re-calculate selrects - ;; and points data - (assoc :modifiers {:displacement (gmt/matrix)}) - (gsh/transform-shape)) + (-> (assoc :modifiers {:displacement (gmt/matrix)}) + (gsh/transform-shape)))) - )) + (update-page [page] + (update page :objects d/update-vals update-object))] - (update-page [_ page] - (update page :objects #(d/mapm update-object %)))] - - (update data :pages-index #(d/mapm update-page %)))) + (update data :pages-index d/update-vals update-page))) ;; We did rollback version 4 migration. ;; Keep this in order to remember the next version to be 5 @@ -124,61 +120,55 @@ ;; Put the id of the local file in :component-file in instances of local components (defmethod migrate 5 [data] - (letfn [(update-object [_ object] + (letfn [(update-object [object] (if (and (some? (:component-id object)) (nil? (:component-file object))) (assoc object :component-file (:id data)) object)) - (update-page [_ page] - (update page :objects #(d/mapm update-object %)))] - - (update data :pages-index #(d/mapm update-page %)))) - -(defn fix-line-paths - "Fixes issues with selrect/points for shapes with width/height = 0 (line-like paths)" - [_ shape] - (if (= (:type shape) :path) - (let [{:keys [width height]} (gsh/points->rect (:points shape))] - (if (or (mth/almost-zero? width) (mth/almost-zero? height)) - (let [selrect (gsh/content->selrect (:content shape)) - points (gsh/rect->points selrect) - transform (gmt/matrix) - transform-inv (gmt/matrix)] - (assoc shape - :selrect selrect - :points points - :transform transform - :transform-inverse transform-inv)) - shape)) - shape)) + (update-page [page] + (update page :objects d/update-vals update-object))] + (update data :pages-index d/update-vals update-page))) (defmethod migrate 6 [data] - (letfn [(update-container [_ container] - (-> container - (update :objects #(d/mapm fix-line-paths %))))] + ;; Fixes issues with selrect/points for shapes with width/height = 0 (line-like paths)" + (letfn [(fix-line-paths [shape] + (if (= (:type shape) :path) + (let [{:keys [width height]} (gsh/points->rect (:points shape))] + (if (or (mth/almost-zero? width) (mth/almost-zero? height)) + (let [selrect (gsh/content->selrect (:content shape)) + points (gsh/rect->points selrect) + transform (gmt/matrix) + transform-inv (gmt/matrix)] + (assoc shape + :selrect selrect + :points points + :transform transform + :transform-inverse transform-inv)) + shape)) + shape)) + + (update-container [container] + (update container :objects d/update-vals fix-line-paths))] (-> data - (update :components #(d/mapm update-container %)) - (update :pages-index #(d/mapm update-container %))))) - + (update :pages-index d/update-vals update-container) + (update :components d/update-vals update-container)))) ;; Remove interactions pointing to deleted frames (defmethod migrate 7 [data] - (letfn [(update-object [page _ object] + (letfn [(update-object [page object] (d/update-when object :interactions - (fn [interactions] - (filterv #(get-in page [:objects (:destination %)]) - interactions)))) + (fn [interactions] + (filterv #(get-in page [:objects (:destination %)]) interactions)))) - (update-page [_ page] - (update page :objects #(d/mapm (partial update-object page) %)))] - - (update data :pages-index #(d/mapm update-page %)))) + (update-page [page] + (update page :objects d/update-vals (partial update-object page)))] + (update data :pages-index d/update-vals update-page))) ;; Remove groups without any shape, both in pages and components @@ -210,7 +200,7 @@ [(count deleted) (d/mapm #(clean-parents %2 deleted) result)])))) - (clean-container [_ container] + (clean-container [container] (loop [n 0 objects (:objects container)] (let [[deleted objects] (clean-objects objects)] @@ -219,8 +209,8 @@ (assoc container :objects objects)))))] (-> data - (update :pages-index #(d/mapm clean-container %)) - (d/update-when :components #(d/mapm clean-container %))))) + (update :pages-index d/update-vals clean-container) + (update :components d/update-vals clean-container)))) (defmethod migrate 9 [data] @@ -252,35 +242,35 @@ (defmethod migrate 10 [data] - (letfn [(update-page [_ page] + (letfn [(update-page [page] (d/update-in-when page [:objects uuid/zero] dissoc :points :selrect))] - (update data :pages-index #(d/mapm update-page %)))) + (update data :pages-index d/update-vals update-page))) (defmethod migrate 11 [data] - (letfn [(update-object [objects _id shape] - (if (= :frame (:type shape)) + (letfn [(update-object [objects shape] + (if (cph/frame-shape? shape) (d/update-when shape :shapes (fn [shapes] (filterv (fn [id] (contains? objects id)) shapes))) shape)) - (update-page [_ page] - (update page :objects #(d/mapm (partial update-object %) %)))] - - (update data :pages-index #(d/mapm update-page %)))) + (update-page [page] + (update page :objects (fn [objects] + (d/update-vals objects (partial update-object objects)))))] + (update data :pages-index d/update-vals update-page))) (defmethod migrate 12 [data] - (letfn [(update-grid [_key grid] + (letfn [(update-grid [grid] (cond-> grid (= :auto (:size grid)) (assoc :size nil))) - (update-page [_id page] - (d/update-in-when page [:options :saved-grids] #(d/mapm update-grid %)))] + (update-page [page] + (d/update-in-when page [:options :saved-grids] d/update-vals update-grid))] - (update data :pages-index #(d/mapm update-page %)))) + (update data :pages-index d/update-vals update-page))) ;; Add rx and ry to images (defmethod migrate 13 @@ -291,83 +281,124 @@ (assoc :rx 0) (assoc :ry 0)) shape)) - (update-object [_ object] + + (update-object [object] (cond-> object - (= :image (:type object)) + (cph/image-shape? object) (fix-radius))) - (update-page [_ page] - (update page :objects #(d/mapm update-object %)))] + (update-page [page] + (update page :objects d/update-vals update-object))] - (update data :pages-index #(d/mapm update-page %)))) + (update data :pages-index d/update-vals update-page))) -(defn set-fills - [shape] - (let [attrs {:fill-color (:fill-color shape) - :fill-color-gradient (:fill-color-gradient shape) - :fill-color-ref-file (:fill-color-ref-file shape) - :fill-color-ref-id (:fill-color-ref-id shape) - :fill-opacity (:fill-opacity shape)} - - clean-attrs (d/without-nils attrs)] - (cond-> shape - (d/not-empty? clean-attrs) - (assoc :fills [clean-attrs])))) - -;; Add fills to shapes (defmethod migrate 14 [data] - (letfn [(update-object [_ object] - (cond-> object - (and (not (= :text (:type object))) (nil? (:fills object))) - (set-fills))) + (letfn [(process-shape [shape] + (let [fill-color (str/upper (:fill-color shape)) + fill-opacity (:fill-opacity shape)] + (cond-> shape + (and (= 1 fill-opacity) + (or (= "#B1B2B5" fill-color) + (= "#7B7D85" fill-color))) + (dissoc :fill-color :fill-opacity)))) - (update-page [_ page] - (update page :objects #(d/mapm update-object %)))] - (update data :pages-index #(d/mapm update-page %)))) + (update-container [{:keys [objects] :as container}] + (loop [objects objects + shapes (->> (vals objects) + (filter cph/image-shape?))] + (if-let [shape (first shapes)] + (let [{:keys [id frame-id] :as shape'} (process-shape shape)] + (if (identical? shape shape') + (recur objects (rest shapes)) + (recur (-> objects + (assoc id shape') + (d/update-when frame-id dissoc :thumbnail)) + (rest shapes)))) + (assoc container :objects objects))))] -(defn set-strokes - [shape] - (let [attrs {:stroke-style (:stroke-style shape) - :stroke-alignment (:stroke-alignment shape) - :stroke-width (:stroke-width shape) - :stroke-color (:stroke-color shape) - :stroke-color-ref-id (:stroke-color-ref-id shape) - :stroke-color-ref-file (:stroke-color-ref-file shape) - :stroke-opacity (:stroke-opacity shape) - :stroke-color-gradient (:stroke-color-gradient shape) - :stroke-cap-start (:stroke-cap-start shape) - :stroke-cap-end (:stroke-cap-end shape)} + (-> data + (update :pages-index d/update-vals update-container) + (update :components d/update-vals update-container)))) - clean-attrs (d/without-nils attrs)] - (cond-> shape - (d/not-empty? clean-attrs) - (assoc :strokes [clean-attrs])))) -;; Add strokes to shapes -(defmethod migrate 15 - [data] - (letfn [(update-object [_ object] - (cond-> object - (and (not (= :text (:type object))) (nil? (:strokes object))) - (set-strokes))) - - (update-page [_ page] - (update page :objects #(d/mapm update-object %)))] - (update data :pages-index #(d/mapm update-page %)))) - -;; Add fills and strokes to components +(defmethod migrate 15 [data] data) +;; Add fills and strokes (defmethod migrate 16 [data] - (letfn [(update-object [_ object] - (cond-> object - (and (not (= :text (:type object))) (nil? (:strokes object))) - (set-strokes) + (letfn [(assign-fills [shape] + (let [attrs {:fill-color (:fill-color shape) + :fill-color-gradient (:fill-color-gradient shape) + :fill-color-ref-file (:fill-color-ref-file shape) + :fill-color-ref-id (:fill-color-ref-id shape) + :fill-opacity (:fill-opacity shape)} + clean-attrs (d/without-nils attrs)] + (cond-> shape + (d/not-empty? clean-attrs) + (assoc :fills [clean-attrs])))) + + (assign-strokes [shape] + (let [attrs {:stroke-style (:stroke-style shape) + :stroke-alignment (:stroke-alignment shape) + :stroke-width (:stroke-width shape) + :stroke-color (:stroke-color shape) + :stroke-color-ref-id (:stroke-color-ref-id shape) + :stroke-color-ref-file (:stroke-color-ref-file shape) + :stroke-opacity (:stroke-opacity shape) + :stroke-color-gradient (:stroke-color-gradient shape) + :stroke-cap-start (:stroke-cap-start shape) + :stroke-cap-end (:stroke-cap-end shape)} + clean-attrs (d/without-nils attrs)] + (cond-> shape + (d/not-empty? clean-attrs) + (assoc :strokes [clean-attrs])))) + + (update-object [object] + (cond-> object + (and (not (cph/text-shape? object)) + (not (contains? object :strokes))) + (assign-strokes) + + (and (not (cph/text-shape? object)) + (not (contains? object :fills))) + (assign-fills))) + + (update-container [container] + (update container :objects d/update-vals update-object))] - (and (not (= :text (:type object))) (nil? (:fills object))) - (set-fills))) - (update-container [_ container] - (update container :objects #(d/mapm update-object %)))] (-> data - (update :components #(d/mapm update-container %))))) + (update :pages-index d/update-vals update-container) + (update :components d/update-vals update-container)))) + +(defmethod migrate 17 + [data] + (letfn [(affected-object? [object] + (and (cph/image-shape? object) + (some? (:fills object)) + (= 1 (count (:fills object))) + (some? (:fill-color object)) + (some? (:fill-opacity object)) + (let [color-old (str/upper (:fill-color object)) + color-new (str/upper (get-in object [:fills 0 :fill-color])) + opacity-old (:fill-opacity object) + opacity-new (get-in object [:fills 0 :fill-opacity])] + (and (= color-old color-new) + (or (= "#B1B2B5" color-old) + (= "#7B7D85" color-old)) + (= 1 opacity-old opacity-new))))) + + (update-object [object] + (cond-> object + (affected-object? object) + (assoc :fills []))) + + (update-container [container] + (update container :objects d/update-vals update-object))] + + (-> data + (update :pages-index d/update-vals update-container) + (update :components d/update-vals update-container)))) + +;; TODO: pending to do a migration for delete already not used fill +;; and stroke props. This should be done for >1.14.x version. diff --git a/common/src/app/common/text.cljc b/common/src/app/common/text.cljc index b58f6b79a..df39fe717 100644 --- a/common/src/app/common/text.cljc +++ b/common/src/app/common/text.cljc @@ -63,16 +63,16 @@ (filter match?) (seq)))) -(defn ^boolean is-text-node? +(defn is-text-node? [node] (and (string? (:text node)) (not= (:text node) ""))) -(defn ^boolean is-paragraph-node? +(defn is-paragraph-node? [node] (= "paragraph" (:type node))) -(defn ^boolean is-root-node? +(defn is-root-node? [node] (= "root" (:type node))) diff --git a/common/test/app/common/pages_migrations_test.cljc b/common/test/app/common/pages_migrations_test.cljc index 89cbbcc9b..ada1c97e4 100644 --- a/common/test/app/common/pages_migrations_test.cljc +++ b/common/test/app/common/pages_migrations_test.cljc @@ -38,7 +38,7 @@ :components {} :version 7} - res (cpm/migrate-data data nil 8)] + res (cpm/migrate-data data 8)] ;; (pprint data) ;; (pprint res) @@ -81,7 +81,7 @@ (let [id (uuid/custom 1 2)] (into [] (remove #(= id %)) shapes))))) - res (cpm/migrate-data data nil 8)] + res (cpm/migrate-data data 8)] ;; (pprint res) ;; (pprint expect) diff --git a/exporter/deps.edn b/exporter/deps.edn index ccfc4c407..b171da17c 100644 --- a/exporter/deps.edn +++ b/exporter/deps.edn @@ -1,6 +1,7 @@ {:paths ["src" "vendor" "resources" "test"] :deps {penpot/common {:local/root "../common"} + org.clojure/clojure {:mvn/version "1.10.3"} binaryage/devtools {:mvn/version "RELEASE"} metosin/reitit-core {:mvn/version "0.5.16"} funcool/beicon {:mvn/version "2021.07.05-1"} diff --git a/frontend/deps.edn b/frontend/deps.edn index bfd18a4bb..e3e6000c8 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -3,6 +3,7 @@ {penpot/common {:local/root "../common"} + org.clojure/clojure {:mvn/version "1.10.3"} binaryage/devtools {:mvn/version "RELEASE"} metosin/reitit-core {:mvn/version "0.5.15"} diff --git a/frontend/resources/styles/main/partials/modal.scss b/frontend/resources/styles/main/partials/modal.scss index 2291c7ca0..26f352471 100644 --- a/frontend/resources/styles/main/partials/modal.scss +++ b/frontend/resources/styles/main/partials/modal.scss @@ -1209,7 +1209,8 @@ } .modal-container { - background-image: url("../images/deco-left.png"), url("../images/deco-right.png"); + background-image: url("../images/deco-left.png"), + url("../images/deco-right.png"); background-repeat: no-repeat; background-position: 10% 50px, 90% 50px; background-size: 65px; @@ -1236,8 +1237,18 @@ --checkbox-border-radius: 3px; --dropdown-option-background-color: rgba(0, 195, 139, 1); --dropdown-option-active-background-color: rgba(0, 138, 98, 1); - --invalid-field-background-color: rgba(238.51780000000002, 205.7178, 204.11780000000002, 1); - --message-fail-background-color: rgba(238.51780000000002, 205.7178, 204.11780000000002, 1); + --invalid-field-background-color: rgba( + 238.51780000000002, + 205.7178, + 204.11780000000002, + 1 + ); + --message-fail-background-color: rgba( + 238.51780000000002, + 205.7178, + 204.11780000000002, + 1 + ); --message-success-background-color: rgba(171, 232, 197, 1); } } diff --git a/frontend/resources/styles/main/partials/sidebar-element-options.scss b/frontend/resources/styles/main/partials/sidebar-element-options.scss index 5820b7658..58ff19fc0 100644 --- a/frontend/resources/styles/main/partials/sidebar-element-options.scss +++ b/frontend/resources/styles/main/partials/sidebar-element-options.scss @@ -523,6 +523,12 @@ right: 0.5rem; left: unset; top: 0; + + .context-menu-action { + overflow-wrap: break-word; + min-width: 223px; + white-space: break-spaces; + } } } } diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 8d85661df..fbf201760 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -194,7 +194,8 @@ (ptk/reify ::initialize-page ptk/WatchEvent (watch [_ state _] - (when-not (contains? (get-in state [:workspace-data :pages-index]) page-id) + (if (contains? (get-in state [:workspace-data :pages-index]) page-id) + (rx/of (dwp/preload-data-uris)) (let [default-page-id (get-in state [:workspace-data :pages 0])] (rx/of (go-to-page default-page-id))))) @@ -1356,26 +1357,28 @@ edit-id (get-in state [:workspace-local :edition]) is-editing-text? (and edit-id (= :text (get-in objects [edit-id :type])))] - (cond - (and (string? text-data) - (str/includes? text-data "> (rx/of decoded-data) - (rx/filter #(= :copied-shapes (:type %))) - (rx/map #(paste-shape % in-viewport?))) + (coll? decoded-data) + (->> (rx/of decoded-data) + (rx/filter #(= :copied-shapes (:type %))) + (rx/map #(paste-shape % in-viewport?))) - ;; Some paste events can be fired while we're editing a text - ;; we forbid that scenario so the default behaviour is executed - (and (string? text-data) (not is-editing-text?)) - (rx/of (paste-text text-data)) + (string? text-data) + (rx/of (paste-text text-data)) + + :else + (rx/empty)))) - :else - (rx/empty))) (catch :default err (js/console.error "Clipboard error:" err)))))) diff --git a/frontend/src/app/main/data/workspace/persistence.cljs b/frontend/src/app/main/data/workspace/persistence.cljs index 9a6793b86..1069d00a8 100644 --- a/frontend/src/app/main/data/workspace/persistence.cljs +++ b/frontend/src/app/main/data/workspace/persistence.cljs @@ -14,6 +14,7 @@ [app.common.spec.change :as spec.change] [app.common.spec.file :as spec.file] [app.common.uuid :as uuid] + [app.config :as cfg] [app.main.data.dashboard :as dd] [app.main.data.events :as ev] [app.main.data.fonts :as df] @@ -653,6 +654,9 @@ frame-changes (->> stream (rx/filter dch/commit-changes?) + + ;; Async so we wait for additional side-effects of commit-changes + (rx/observe-on :async) (rx/filter (comp not thumbnail-change?)) (rx/with-latest-from objects-stream) (rx/map extract-frame-changes) @@ -678,3 +682,26 @@ (rx/buffer-until (->> frame-changes (rx/debounce 1000))) (rx/flat-map #(reduce set/union %)) (rx/map #(update-frame-thumbnail %))))))))) + +(defn preload-data-uris + "Preloads the image data so it's ready when necesary" + [] + (ptk/reify ::preload-data-uris + ptk/WatchEvent + (watch [_ state _] + (let [extract-urls + (fn [{:keys [metadata fill-image]}] + (cond + (some? metadata) + [(cfg/resolve-file-media metadata)] + + (some? fill-image) + [(cfg/resolve-file-media fill-image)])) + + uris (into #{} + (comp (mapcat extract-urls) + (filter some?)) + (vals (wsh/lookup-page-objects state)))] + (->> (rx/from uris) + (rx/merge-map #(http/fetch-data-uri % false)) + (rx/ignore)))))) diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index 762397938..73fe95518 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -110,7 +110,10 @@ (rx/dedupe) (rx/map #(select-shapes-by-current-selrect preserve? ignore-groups?)))) - (rx/of (update-selrect nil))))))) + (->> (rx/of (update-selrect nil)) + ;; We need the async so the current event finishes before updating the selrect + ;; otherwise the `on-click` event will trigger with a `nil` selrect + (rx/observe-on :async))))))) ;; --- Toggle shape's selection status (selected or deselected) @@ -123,13 +126,7 @@ (ptk/reify ::select-shape ptk/UpdateEvent (update [_ state] - (update-in state [:workspace-local :selected] - (fn [selected] - (if-not toggle? - (conj (d/ordered-set) id) - (if (contains? selected id) - (disj selected id) - (conj selected id)))))) + (update-in state [:workspace-local :selected] d/toggle-selection id toggle?)) ptk/WatchEvent (watch [_ state _] @@ -506,6 +503,7 @@ id-duplicated (when (= (count selected) 1) (first selected))] + ;; Warning: This order is important for the focus mode. (rx/of (dch/commit-changes changes) (select-shapes selected) (memorize-duplicated id-original id-duplicated))))))) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 63dc4ac7c..6cf7f5511 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -592,31 +592,46 @@ (defn start-move-selected "Enter mouse move mode, until mouse button is released." - [] - (ptk/reify ::start-move-selected - ptk/WatchEvent - (watch [_ state stream] - (let [initial (deref ms/mouse-position) - selected (wsh/lookup-selected state {:omit-blocked? true}) - stopper (rx/filter ms/mouse-up? stream) - zoom (get-in state [:workspace-local :zoom] 1)] - (when-not (empty? selected) - (->> ms/mouse-position - (rx/map #(gpt/to-vec initial %)) - (rx/map #(gpt/length %)) - (rx/filter #(> % (/ 10 zoom))) - (rx/take 1) - (rx/with-latest vector ms/mouse-position-alt) - (rx/mapcat - (fn [[_ alt?]] - (if alt? - ;; When alt is down we start a duplicate+move - (rx/of (start-move-duplicate initial) - (dws/duplicate-selected false)) - ;; Otherwise just plain old move - (rx/of (start-move initial selected))))) - (rx/take-until stopper))))))) + ([] + (start-move-selected nil false)) + ([id shift?] + (ptk/reify ::start-move-selected + ptk/WatchEvent + (watch [_ state stream] + (let [initial (deref ms/mouse-position) + + stopper (rx/filter ms/mouse-up? stream) + zoom (get-in state [:workspace-local :zoom] 1) + + ;; We toggle the selection so we don't have to wait for the event + selected + (cond-> (wsh/lookup-selected state {:omit-blocked? true}) + (some? id) + (d/toggle-selection id shift?))] + + (when (or (d/not-empty? selected) (some? id)) + (->> ms/mouse-position + (rx/map #(gpt/to-vec initial %)) + (rx/map #(gpt/length %)) + (rx/filter #(> % (/ 10 zoom))) + (rx/take 1) + (rx/with-latest vector ms/mouse-position-alt) + (rx/mapcat + (fn [[_ alt?]] + (rx/concat + (if (some? id) + (rx/of (dws/select-shape id shift?)) + (rx/empty)) + + (if alt? + ;; When alt is down we start a duplicate+move + (rx/of (start-move-duplicate initial) + (dws/duplicate-selected false)) + + ;; Otherwise just plain old move + (rx/of (start-move initial selected)))))) + (rx/take-until stopper)))))))) (defn- start-move-duplicate [from-position] (ptk/reify ::start-move-duplicate diff --git a/frontend/src/app/main/ui/components/numeric_input.cljs b/frontend/src/app/main/ui/components/numeric_input.cljs index 90dcf9628..ab4acb30b 100644 --- a/frontend/src/app/main/ui/components/numeric_input.cljs +++ b/frontend/src/app/main/ui/components/numeric_input.cljs @@ -73,44 +73,44 @@ parse-value (mf/use-callback - (mf/deps ref min-val max-val value nillable default-val) - (fn [] - (let [input-node (mf/ref-val ref) - new-value (-> (dom/get-value input-node) - (str/strip-suffix ".") - (sm/expr-eval value))] - (cond - (d/num? new-value) - (-> new-value - (cljs.core/max us/min-safe-int) - (cljs.core/min us/max-safe-int) - (cond-> - (d/num? min-val) - (cljs.core/max min-val) + (mf/deps ref min-val max-val value nillable default-val) + (fn [] + (let [input-node (mf/ref-val ref) + new-value (-> (dom/get-value input-node) + (str/strip-suffix ".") + (sm/expr-eval value))] + (cond + (d/num? new-value) + (-> new-value + (cljs.core/max (/ us/min-safe-int 2)) + (cljs.core/min (/ us/max-safe-int 2)) + (cond-> + (d/num? min-val) + (cljs.core/max min-val) - (d/num? max-val) - (cljs.core/min max-val))) + (d/num? max-val) + (cljs.core/min max-val))) - nillable - default-val + nillable + default-val - :else value)))) + :else value)))) update-input (mf/use-callback - (mf/deps ref) - (fn [new-value] - (let [input-node (mf/ref-val ref)] - (dom/set-value! input-node (fmt/format-number new-value))))) + (mf/deps ref) + (fn [new-value] + (let [input-node (mf/ref-val ref)] + (dom/set-value! input-node (fmt/format-number new-value))))) apply-value (mf/use-callback - (mf/deps on-change update-input value) - (fn [new-value] - (mf/set-ref-val! dirty-ref false) - (when (and (not= new-value value) (some? on-change)) - (on-change new-value)) - (update-input new-value))) + (mf/deps on-change update-input value) + (fn [new-value] + (mf/set-ref-val! dirty-ref false) + (when (and (not= new-value value) (some? on-change)) + (on-change new-value)) + (update-input new-value))) set-delta (mf/use-callback diff --git a/frontend/src/app/main/ui/render.cljs b/frontend/src/app/main/ui/render.cljs index 5572587d1..e731d4555 100644 --- a/frontend/src/app/main/ui/render.cljs +++ b/frontend/src/app/main/ui/render.cljs @@ -33,10 +33,10 @@ (let [xf-get-bounds (comp (map #(get objects %)) (map #(calc-bounds % objects))) padding (filters/calculate-padding object) obj-bounds (-> (filters/get-filters-bounds object) - (update :x - padding) - (update :y - padding) - (update :width + (* 2 padding)) - (update :height + (* 2 padding)))] + (update :x - (:horizontal padding)) + (update :y - (:vertical padding)) + (update :width + (* 2 (:horizontal padding))) + (update :height + (* 2 (:vertical padding))))] (cond (and (= :group (:type object)) diff --git a/frontend/src/app/main/ui/shapes/attrs.cljs b/frontend/src/app/main/ui/shapes/attrs.cljs index 2a0f3a129..aeafe56b1 100644 --- a/frontend/src/app/main/ui/shapes/attrs.cljs +++ b/frontend/src/app/main/ui/shapes/attrs.cljs @@ -175,12 +175,12 @@ (obj/set! styles "fill" (str "url(#fill-0-" render-id ")")) ;; imported svgs can have fill and fill-opacity attributes - (obj/contains? svg-styles "fill") + (and (some? svg-styles) (obj/contains? svg-styles "fill")) (-> styles (obj/set! "fill" (obj/get svg-styles "fill")) (obj/set! "fillOpacity" (obj/get svg-styles "fillOpacity"))) - (obj/contains? svg-attrs "fill") + (and (some? svg-attrs) (obj/contains? svg-attrs "fill")) (-> styles (obj/set! "fill" (obj/get svg-attrs "fill")) (obj/set! "fillOpacity" (obj/get svg-attrs "fillOpacity"))) diff --git a/frontend/src/app/main/ui/shapes/custom_stroke.cljs b/frontend/src/app/main/ui/shapes/custom_stroke.cljs index 2c951ecdf..28522282a 100644 --- a/frontend/src/app/main/ui/shapes/custom_stroke.cljs +++ b/frontend/src/app/main/ui/shapes/custom_stroke.cljs @@ -9,6 +9,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.geom.shapes :as gsh] + [app.common.pages.helpers :as cph] [app.main.ui.context :as muc] [app.main.ui.shapes.attrs :as attrs] [app.main.ui.shapes.gradients :as grad] @@ -327,8 +328,12 @@ (obj/clone)) props (cond-> props - (d/not-empty? (:shadow shape)) - (obj/set! "filter" (dm/fmt "url(#filter_%)" render-id))) + (or + ;; There are any shadows + (and (d/not-empty? (:shadow shape)) (not (cph/frame-shape? shape))) + ;; There are no strokes and a blur + (and (some? (:blur shape)) (not (cph/frame-shape? shape)) (empty? (:strokes shape)))) + (obj/set! "filter" (dm/fmt "url(#filter_%)" render-id))) svg-defs (:svg-defs shape {}) svg-attrs (:svg-attrs shape {}) @@ -345,7 +350,7 @@ (obj/without ["fill" "fillOpacity"])))] (obj/set! props "fill" (dm/fmt "url(#fill-0-%)" render-id))) - (obj/contains? svg-styles "fill") + (and (some? svg-styles) (obj/contains? svg-styles "fill")) (let [style (-> (obj/get props "style") (obj/clone) @@ -354,7 +359,7 @@ (-> props (obj/set! "style" style))) - (obj/contains? svg-attrs "fill") + (and (some? svg-attrs) (obj/contains? svg-attrs "fill")) (let [style (-> (obj/get props "style") (obj/clone) @@ -374,10 +379,7 @@ (cond-> (obj/merge! props fill-props) (some? style) - (obj/set! "style" style))) - - :else - props))) + (obj/set! "style" style)))))) (defn build-stroke-props [position child value render-id] (let [props (-> (obj/get child "props") @@ -391,7 +393,19 @@ (obj/set! "fillOpacity" "none"))) (add-style (obj/get (attrs/extract-stroke-attrs value position render-id) "style"))))) -(mf/defc shape-custom-strokes + +(mf/defc shape-fills + {::mf/wrap-props false} + [props] + (let [child (obj/get props "children") + shape (obj/get props "shape") + elem-name (obj/get child "type") + render-id (mf/use-ctx muc/render-ctx)] + + [:g {:id (dm/fmt "fills-%" (:id shape))} + [:> elem-name (build-fill-props shape child render-id)]])) + +(mf/defc shape-strokes {::mf/wrap-props false} [props] (let [child (obj/get props "children") @@ -401,13 +415,9 @@ stroke-props (-> (obj/new) (obj/set! "id" (dm/fmt "strokes-%" (:id shape))) (cond-> - (some? (:blur shape)) + (and (some? (:blur shape)) (not (cph/frame-shape? shape))) (obj/set! "filter" (dm/fmt "url(#filter_blur_%)" render-id))))] - [:* - [:g {:id (dm/fmt "fills-%" (:id shape))} - [:> elem-name (build-fill-props shape child render-id)]] - (when (d/not-empty? (:strokes shape)) [:> :g stroke-props @@ -416,3 +426,16 @@ shape (assoc value :points (:points shape))] [:& shape-custom-stroke {:shape shape :index index} [:> elem-name props]]))])])) + +(mf/defc shape-custom-strokes + {::mf/wrap-props false} + [props] + (let [child (obj/get props "children") + shape (obj/get props "shape")] + + [:* + [:& shape-fills {:shape shape} + child] + + [:& shape-strokes {:shape shape} + child]])) diff --git a/frontend/src/app/main/ui/shapes/filters.cljs b/frontend/src/app/main/ui/shapes/filters.cljs index a1370b887..378a22397 100644 --- a/frontend/src/app/main/ui/shapes/filters.cljs +++ b/frontend/src/app/main/ui/shapes/filters.cljs @@ -170,7 +170,6 @@ ([shape filters blur-value] (let [svg-root? (and (= :svg-raw (:type shape)) (not= :svg (get-in shape [:content :tag]))) - frame? (= :frame (:type shape)) {:keys [x y width height]} (:selrect shape)] (if svg-root? ;; When is a raw-svg but not the root we use the whole svg as bound for the filter. Is the maximum @@ -183,6 +182,7 @@ (map (partial filter-bounds shape))) ;; We add the selrect so the minimum size will be the selrect filter-bounds (conj filter-bounds (-> shape :points gsh/points->selrect)) + x1 (apply min (map :x1 filter-bounds)) y1 (apply min (map :y1 filter-bounds)) x2 (apply max (map :x2 filter-bounds)) @@ -195,18 +195,30 @@ ;; We should move the frame filter coordinates because they should be ;; relative with the frame. By default they come as absolute - {:x (if frame? (- x1 x) x1) - :y (if frame? (- y1 y) y1) + {:x x1 + :y y1 :width (- x2 x1) :height (- y2 y1)}))))) (defn calculate-padding [shape] (let [stroke-width (apply max 0 (map #(case (:stroke-alignment % :center) - :center (/ (:stroke-width % 0) 2) - :outer (:stroke-width % 0) - 0) (:strokes shape))) - margin (apply max 0 (map #(gsh/shape-stroke-margin % stroke-width) (:strokes shape)))] - (+ stroke-width margin))) + :center (/ (:stroke-width % 0) 2) + :outer (:stroke-width % 0) + 0) (:strokes shape))) + + margin (apply max 0 (map #(gsh/shape-stroke-margin % stroke-width) (:strokes shape))) + + + shadow-width (apply max 0 (map #(case (:style % :drop-shadow) + :drop-shadow (+ (mth/abs (:offset-x %)) (* (:spread %) 2) (* (:blur %) 2) 10) + 0) (:shadow shape))) + + shadow-height (apply max 0 (map #(case (:style % :drop-shadow) + :drop-shadow (+ (mth/abs (:offset-y %)) (* (:spread %) 2) (* (:blur %) 2) 10) + 0) (:shadow shape)))] + + {:horizontal (+ stroke-width margin shadow-width) + :vertical (+ stroke-width margin shadow-height)})) (defn change-filter-in "Adds the previous filter as `filter-in` parameter" @@ -220,10 +232,10 @@ bounds (get-filters-bounds shape filters (or (-> shape :blur :value) 0)) padding (calculate-padding shape) selrect (:selrect shape) - filter-x (/ (- (:x bounds) (:x selrect) padding) (:width selrect)) - filter-y (/ (- (:y bounds) (:y selrect) padding) (:height selrect)) - filter-width (/ (+ (:width bounds) (* 2 padding)) (:width selrect)) - filter-height (/ (+ (:height bounds) (* 2 padding)) (:height selrect))] + filter-x (/ (- (:x bounds) (:x selrect) (:horizontal padding)) (:width selrect)) + filter-y (/ (- (:y bounds) (:y selrect) (:vertical padding)) (:height selrect)) + filter-width (/ (+ (:width bounds) (* 2 (:horizontal padding))) (:width selrect)) + filter-height (/ (+ (:height bounds) (* 2 (:vertical padding))) (:height selrect))] (when (> (count filters) 2) [:filter {:id filter-id :x filter-x diff --git a/frontend/src/app/main/ui/shapes/frame.cljs b/frontend/src/app/main/ui/shapes/frame.cljs index 42be91bc9..1e2d85df5 100644 --- a/frontend/src/app/main/ui/shapes/frame.cljs +++ b/frontend/src/app/main/ui/shapes/frame.cljs @@ -7,9 +7,10 @@ (ns app.main.ui.shapes.frame (:require [app.common.data.macros :as dm] + [app.common.geom.shapes :as gsh] + [app.main.ui.context :as muc] [app.main.ui.shapes.attrs :as attrs] - [app.main.ui.shapes.custom-stroke :refer [shape-custom-strokes]] - [app.main.ui.shapes.filters :as filters] + [app.main.ui.shapes.custom-stroke :refer [shape-fills shape-strokes]] [app.util.object :as obj] [debug :refer [debug?]] [rumext.alpha :as mf])) @@ -27,13 +28,12 @@ [{:keys [shape render-id]}] (when (= :frame (:type shape)) (let [{:keys [x y width height]} shape - padding (filters/calculate-padding shape) props (-> (attrs/extract-style-attrs shape) (obj/merge! - #js {:x (- x padding) - :y (- y padding) - :width (+ width (* 2 padding)) - :height (+ height (* 2 padding))})) + #js {:x x + :y y + :width width + :height height})) path? (some? (.-d props))] [:clipPath {:id (frame-clip-id shape render-id) :class "frame-clip"} (if path? @@ -63,22 +63,32 @@ (let [childs (unchecked-get props "childs") shape (unchecked-get props "shape") {:keys [x y width height]} shape + transform (gsh/transform-matrix shape) props (-> (attrs/extract-style-attrs shape) (obj/merge! #js {:x x :y y + :transform transform :width width :height height :className "frame-background"})) - path? (some? (.-d props))] - - [:* - [:& shape-custom-strokes {:shape shape} - (if path? - [:> :path props] - [:> :rect props])] - (for [item childs] - [:& shape-wrapper {:shape item - :key (dm/str (:id item))}])]))) + path? (some? (.-d props)) + render-id (mf/use-ctx muc/render-ctx)] + + [:* + [:g {:clip-path (frame-clip-url shape render-id)} + [:* + [:& shape-fills {:shape shape} + (if path? + [:> :path props] + [:> :rect props])] + + (for [item childs] + [:& shape-wrapper {:shape item + :key (dm/str (:id item))}]) + [:& shape-strokes {:shape shape} + (if path? + [:> :path props] + [:> :rect props])]]]]))) diff --git a/frontend/src/app/main/ui/shapes/shape.cljs b/frontend/src/app/main/ui/shapes/shape.cljs index a4948df44..7a24ba008 100644 --- a/frontend/src/app/main/ui/shapes/shape.cljs +++ b/frontend/src/app/main/ui/shapes/shape.cljs @@ -50,14 +50,11 @@ wrapper-props (cond-> wrapper-props - (some #(= (:type shape) %) [:group :svg-raw]) + (some #(= (:type shape) %) [:group :svg-raw :frame]) (obj/set! "filter" (filters/filter-str filter-id shape))) wrapper-props (cond-> wrapper-props - (= :frame type) - (obj/set! "clipPath" (frame/frame-clip-url shape render-id)) - (= :group type) (attrs/add-style-attrs shape render-id))] diff --git a/frontend/src/app/main/ui/shapes/svg_defs.cljs b/frontend/src/app/main/ui/shapes/svg_defs.cljs index 0b875bcfb..a3662e49c 100644 --- a/frontend/src/app/main/ui/shapes/svg_defs.cljs +++ b/frontend/src/app/main/ui/shapes/svg_defs.cljs @@ -43,24 +43,24 @@ transform-mask? (and (= :mask tag) (= "userSpaceOnUse" (get attrs :maskUnits "objectBoundingBox"))) - attrs (-> attrs - (usvg/update-attr-ids prefix-id) - (usvg/clean-attrs) - ;; This clasname will be used to change the transform on the viewport - ;; only necessary for groups because shapes have their own transform - (cond-> (and (or transform-gradient? - transform-pattern? - transform-clippath? - transform-filter? - transform-mask?) - (= :group type)) - (update :className #(if % (dm/str % " svg-def") "svg-def"))) - (cond-> - transform-gradient? (add-matrix :gradientTransform transform) - transform-pattern? (add-matrix :patternTransform transform) - transform-clippath? (add-matrix :transform transform) - (or transform-filter? - transform-mask?) (merge attrs bounds))) + attrs + (-> attrs + (usvg/update-attr-ids prefix-id) + (usvg/clean-attrs) + ;; This clasname will be used to change the transform on the viewport + ;; only necessary for groups because shapes have their own transform + (cond-> (and (or transform-gradient? + transform-pattern? + transform-clippath? + transform-filter? + transform-mask?) + (= :group type)) + (update :className #(if % (dm/str % " svg-def") "svg-def"))) + (cond-> + transform-gradient? (add-matrix :gradientTransform transform) + transform-pattern? (add-matrix :patternTransform transform) + transform-clippath? (add-matrix :transform transform) + (or transform-filter? transform-mask?) (merge bounds))) [wrapper wrapper-props] (if (= tag :mask) ["g" #js {:className "svg-mask-wrapper" diff --git a/frontend/src/app/main/ui/shapes/text/fontfaces.cljs b/frontend/src/app/main/ui/shapes/text/fontfaces.cljs index 5f2ae54fc..9e2730138 100644 --- a/frontend/src/app/main/ui/shapes/text/fontfaces.cljs +++ b/frontend/src/app/main/ui/shapes/text/fontfaces.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.shapes.text.fontfaces (:require [app.common.data :as d] + [app.common.pages.helpers :as cph] [app.main.fonts :as fonts] [app.main.ui.hooks :as hooks] [app.main.ui.shapes.embed :as embed] @@ -76,14 +77,10 @@ {::mf/wrap-props false ::mf/wrap [#(mf/memo' % (mf/check-props ["shapes"]))]} [props] - (let [shapes (->> (obj/get props "shapes") - (filterv #(= :text (:type %)))) - - content (->> shapes (mapv :content)) - - ;; Retrieve the fonts ids used by the text shapes - fonts (->> content - (mapv fonts/get-content-fonts) + (let [;; Retrieve the fonts ids used by the text shapes + fonts (->> (obj/get props "shapes") + (filterv cph/text-shape?) + (mapv (comp fonts/get-content-fonts :content)) (reduce set/union #{}) (hooks/use-equal-memo))] diff --git a/frontend/src/app/main/ui/viewer.cljs b/frontend/src/app/main/ui/viewer.cljs index a7c3e860d..3bb8ad2c9 100644 --- a/frontend/src/app/main/ui/viewer.cljs +++ b/frontend/src/app/main/ui/viewer.cljs @@ -9,9 +9,12 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.geom.point :as gpt] + [app.common.pages.helpers :as cph] + [app.common.text :as txt] [app.main.data.comments :as dcm] [app.main.data.viewer :as dv] [app.main.data.viewer.shortcuts :as sc] + [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.hooks :as hooks] @@ -32,12 +35,17 @@ (defn- calculate-size [frame zoom] - (let [{:keys [_ _ width height]} (filters/get-filters-bounds frame)] + (let [{:keys [_ _ width height]} (filters/get-filters-bounds frame) + padding (filters/calculate-padding frame) + x (- (:horizontal padding)) + y (- (:vertical padding)) + width (+ width (* 2 (:horizontal padding))) + height (+ height (* 2 (:vertical padding)))] {:base-width width :base-height height :width (* width zoom) :height (* height zoom) - :vbox (str "0 0 " width " " height)})) + :vbox (str x " " y " " width " " height)})) (defn- calculate-wrapper [size1 size2 zoom] @@ -70,6 +78,12 @@ (fn [] (get-in data [:pages page-id]))) + text-shapes + (hooks/use-equal-memo + (->> (:objects page) + (vals) + (filter cph/text-shape?))) + zoom (:zoom local) frames (:frames page) frame (get frames index) @@ -214,6 +228,13 @@ nil)))) + (mf/use-effect + (mf/deps text-shapes) + (fn [] + (let [text-nodes (->> text-shapes (mapcat #(txt/node-seq txt/is-text-node? (:content %)))) + fonts (into #{} (keep :font-id) text-nodes)] + (run! fonts/ensure-loaded! fonts)))) + [:div#viewer-layout {:class (dom/classnames :force-visible (:show-thumbnails local) :viewer-layout (not= section :handoff) diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes.cljs b/frontend/src/app/main/ui/viewer/handoff/attributes.cljs index 52518b738..ae9e5fe93 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes.cljs +++ b/frontend/src/app/main/ui/viewer/handoff/attributes.cljs @@ -21,7 +21,7 @@ (def type->options {:multiple [:fill :stroke :image :text :shadow :blur] - :frame [:layout :fill :stroke] + :frame [:layout :fill :stroke :shadow :blur] :group [:layout :svg] :rect [:layout :fill :stroke :shadow :blur :svg] :circle [:layout :fill :stroke :shadow :blur :svg] diff --git a/frontend/src/app/main/ui/viewer/handoff/render.cljs b/frontend/src/app/main/ui/viewer/handoff/render.cljs index 5fb32104b..c7e4bfe02 100644 --- a/frontend/src/app/main/ui/viewer/handoff/render.cljs +++ b/frontend/src/app/main/ui/viewer/handoff/render.cljs @@ -13,6 +13,7 @@ [app.main.store :as st] [app.main.ui.shapes.bool :as bool] [app.main.ui.shapes.circle :as circle] + [app.main.ui.shapes.filters :as filters] [app.main.ui.shapes.frame :as frame] [app.main.ui.shapes.group :as group] [app.main.ui.shapes.image :as image] @@ -190,9 +191,18 @@ frame (get objects (:id frame)) zoom (:zoom local 1) - width (* (:width frame) zoom) - height (* (:height frame) zoom) - vbox (str "0 0 " (:width frame 0) " " (:height frame 0)) + + {:keys [_ _ width height]} (filters/get-filters-bounds frame) + padding (filters/calculate-padding frame) + x (- (:horizontal padding)) + y (- (:vertical padding)) + width (+ width (* 2 (:horizontal padding))) + height (+ height (* 2 (:vertical padding))) + + vbox (str x " " y " " width " " height) + + width (* width zoom) + height (* height zoom) render (mf/use-memo (mf/deps objects) diff --git a/frontend/src/app/main/ui/viewer/shapes.cljs b/frontend/src/app/main/ui/viewer/shapes.cljs index ab39c835e..c7390b6a7 100644 --- a/frontend/src/app/main/ui/viewer/shapes.cljs +++ b/frontend/src/app/main/ui/viewer/shapes.cljs @@ -403,9 +403,9 @@ modifier-ids (into [frame-id] (cph/get-children-ids objects frame-id)) objects (reduce update-fn objects modifier-ids) frame (assoc-in frame [:modifiers :displacement] modifier) - width (* (:width frame) zoom) height (* (:height frame) zoom) + vbox (str "0 0 " (:width frame 0) " " (:height frame 0)) wrapper (mf/use-memo diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 4552b3c40..269688989 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -36,7 +36,7 @@ ;; NOTE: this is necessary because the `cph/get-component` ;; expects a map of all libraries, including the local one. - libraries (assoc libraries (:id local-file) local-file) + libraries (assoc libraries (:id local-file) {:data local-file}) component (cph/get-component libraries library-id component-id) show? (some? component-id) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 4f058a79f..900dd304b 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -88,6 +88,7 @@ ;; REFS viewport-ref (mf/use-ref nil) + overlays-ref (mf/use-ref nil) ;; VARS disable-paste (mf/use-var false) @@ -121,7 +122,7 @@ node-editing? (and edition (not= :text (get-in base-objects [edition :type]))) text-editing? (and edition (= :text (get-in base-objects [edition :type]))) - on-click (actions/on-click hover selected edition drawing-path? drawing-tool space?) + on-click (actions/on-click hover selected edition drawing-path? drawing-tool space? selrect) on-context-menu (actions/on-context-menu hover hover-ids) on-double-click (actions/on-double-click hover hover-ids drawing-path? base-objects edition) on-drag-enter (actions/on-drag-enter) @@ -169,7 +170,7 @@ disabled-guides? (or drawing-tool transform)] - (hooks/setup-dom-events viewport-ref zoom disable-paste in-viewport?) + (hooks/setup-dom-events viewport-ref overlays-ref zoom disable-paste in-viewport?) (hooks/setup-viewport-size viewport-ref) (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool drawing-path? node-editing?) (hooks/setup-keyboard alt? mod? space?) @@ -179,7 +180,7 @@ (hooks/setup-active-frames base-objects vbox hover active-frames) [:div.viewport - [:div.viewport-overlays + [:div.viewport-overlays {:ref overlays-ref} [:& wtr/frame-renderer {:objects base-objects :background background}] diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index e844451ca..111a0fa91 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -97,9 +97,7 @@ (st/emit! (dw/handle-area-selection shift? mod?)) (not drawing-tool) - (st/emit! (when (or shift? (not selected?)) - (dw/select-shape id shift?)) - (dw/start-move-selected))))))))))) + (st/emit! (dw/start-move-selected id shift?))))))))))) (defn on-move-selected [hover hover-ids selected space?] @@ -147,27 +145,25 @@ (reset! frame-hover nil)))) (defn on-click - [hover selected edition drawing-path? drawing-tool space?] + [hover selected edition drawing-path? drawing-tool space? selrect] (mf/use-callback - (mf/deps @hover selected edition drawing-path? drawing-tool @space?) + (mf/deps @hover selected edition drawing-path? drawing-tool @space? selrect) (fn [event] - (when (or (dom/class? (dom/get-target event) "viewport-controls") - (dom/class? (dom/get-target event) "viewport-selrect")) + (when (and (nil? selrect) + (or (dom/class? (dom/get-target event) "viewport-controls") + (dom/class? (dom/get-target event) "viewport-selrect"))) (let [ctrl? (kbd/ctrl? event) shift? (kbd/shift? event) alt? (kbd/alt? event) meta? (kbd/meta? event) mod? (kbd/mod? event) - hovering? (some? @hover) - frame? (= :frame (:type @hover)) - selected? (contains? selected (:id @hover))] + frame? (= :frame (:type @hover))] (st/emit! (ms/->MouseEvent :click ctrl? shift? alt? meta?)) (when (and hovering? (or (not frame?) mod?) (not @space?) - (not selected?) (not edition) (not drawing-path?) (not drawing-tool)) @@ -367,21 +363,23 @@ pt (utils/translate-point-to-viewport viewport zoom raw-pt)] (rx/push! move-stream pt))))) -(defn on-mouse-wheel [viewport-ref zoom] +(defn on-mouse-wheel [viewport-ref overlays-ref zoom] (mf/use-callback (mf/deps zoom) (fn [event] (let [viewport (mf/ref-val viewport-ref) + overlays (mf/ref-val overlays-ref) event (.getBrowserEvent ^js event) - target (dom/get-target event)] - (when (.contains ^js viewport target) + target (dom/get-target event) + mod? (kbd/mod? event)] + + (when (or (dom/is-child? viewport target) + (dom/is-child? overlays target)) (dom/prevent-default event) (dom/stop-propagation event) (let [pt (->> (dom/get-client-position event) (utils/translate-point-to-viewport viewport zoom)) - mod? (kbd/mod? event) - delta-mode (.-deltaMode ^js event) unit (cond diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index 11e4bc006..d2e70e878 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -27,11 +27,11 @@ [rumext.alpha :as mf]) (:import goog.events.EventType)) -(defn setup-dom-events [viewport-ref zoom disable-paste in-viewport?] +(defn setup-dom-events [viewport-ref overlays-ref zoom disable-paste in-viewport?] (let [on-key-down (actions/on-key-down) on-key-up (actions/on-key-up) on-mouse-move (actions/on-mouse-move viewport-ref zoom) - on-mouse-wheel (actions/on-mouse-wheel viewport-ref zoom) + on-mouse-wheel (actions/on-mouse-wheel viewport-ref overlays-ref zoom) on-paste (actions/on-paste disable-paste in-viewport?)] (mf/use-layout-effect (mf/deps on-key-down on-key-up on-mouse-move on-mouse-wheel on-paste) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 0aba75592..64ba990dc 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -530,3 +530,8 @@ (when onfinish (set! (.-onfinish animation) onfinish))))) +(defn is-child? + [^js node ^js candidate] + (and (some? node) + (some? candidate) + (.contains node candidate)))