From 5e585201d330d41358e0ff0037d466dad5b939db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Tue, 1 Sep 2020 15:09:57 +0200 Subject: [PATCH] :tada: Create reusable components --- common/app/common/pages.cljc | 29 ++++ common/app/common/pages_helpers.cljc | 2 +- common/app/common/pages_migrations.cljc | 1 - frontend/resources/images/icons/component.svg | 3 + frontend/resources/locales.json | 63 +++++---- .../styles/common/dependencies/colors.scss | 2 + .../styles/main/partials/sidebar-assets.scss | 15 ++ .../styles/main/partials/sidebar-layers.scss | 80 ++++++++--- frontend/src/app/main/data/workspace.cljs | 94 ++----------- .../app/main/data/workspace/libraries.cljs | 120 +++++++++++++++- .../app/main/data/workspace/selection.cljs | 80 +++++++++++ frontend/src/app/main/exports.cljs | 32 +++++ frontend/src/app/main/store.cljs | 2 +- frontend/src/app/main/ui/icons.cljs | 1 + .../app/main/ui/workspace/context_menu.cljs | 12 +- .../app/main/ui/workspace/sidebar/assets.cljs | 131 ++++++++++++++---- .../app/main/ui/workspace/sidebar/layers.cljs | 16 ++- 17 files changed, 515 insertions(+), 168 deletions(-) create mode 100644 frontend/resources/images/icons/component.svg diff --git a/common/app/common/pages.cljc b/common/app/common/pages.cljc index 3e93ec8313..643720eab4 100644 --- a/common/app/common/pages.cljc +++ b/common/app/common/pages.cljc @@ -356,6 +356,12 @@ (defmethod change-spec :del-media [_] (s/keys :req-un [::id])) +(defmethod change-spec :add-component [_] + (s/keys :req-un [::id ::name ::new-shapes])) + +(defmethod change-spec :del-component [_] + (s/keys :req-un [::id])) + (s/def ::change (s/multi-spec change-spec :type)) (s/def ::changes (s/coll-of ::change)) @@ -473,6 +479,18 @@ :points [] :segments []))) +(defn make-minimal-group + [frame-id selection-rect group-name] + {:id (uuid/next) + :type :group + :name group-name + :shapes [] + :frame-id frame-id + :x (:x selection-rect) + :y (:y selection-rect) + :width (:width selection-rect) + :height (:height selection-rect)}) + (defn make-file-data ([] (make-file-data (uuid/next))) ([id] @@ -745,6 +763,17 @@ [data {:keys [id]}] (update data :media dissoc id)) +(defmethod process-change :add-component + [data {:keys [id name new-shapes]}] + (assoc-in data [:components id] + {:id id + :name name + :objects (d/index-by :id new-shapes)})) + +(defmethod process-change :del-component + [data {:keys [id]}] + (d/dissoc-in data [:components id])) + (defmethod process-operation :set [shape op] (let [attr (:attr op) diff --git a/common/app/common/pages_helpers.cljc b/common/app/common/pages_helpers.cljc index 27cc1836a8..fd33cbd43c 100644 --- a/common/app/common/pages_helpers.cljc +++ b/common/app/common/pages_helpers.cljc @@ -15,7 +15,7 @@ (defn get-children "Retrieve all children ids recursively for a given object" [id objects] - (let [shapes (get-in objects [id :shapes])] + (let [shapes (vec (get-in objects [id :shapes]))] (if shapes (d/concat shapes (mapcat #(get-children % objects) shapes)) []))) diff --git a/common/app/common/pages_migrations.cljc b/common/app/common/pages_migrations.cljc index a9dd9b21b2..33171c9fb2 100644 --- a/common/app/common/pages_migrations.cljc +++ b/common/app/common/pages_migrations.cljc @@ -52,4 +52,3 @@ ;; (assoc obj :parent-id parent-id))) ;; objects))))) - diff --git a/frontend/resources/images/icons/component.svg b/frontend/resources/images/icons/component.svg new file mode 100644 index 0000000000..2042881f9b --- /dev/null +++ b/frontend/resources/images/icons/component.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/locales.json b/frontend/resources/locales.json index 635cc19c91..0e96a75837 100644 --- a/frontend/resources/locales.json +++ b/frontend/resources/locales.json @@ -288,7 +288,7 @@ } }, "dashboard.grid.add-shared" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:146", "src/app/main/ui/dashboard/grid.cljs:166" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:166", "src/app/main/ui/workspace/header.cljs:146" ], "translations" : { "en" : "Add as Shared Library", "fr" : "", @@ -297,7 +297,7 @@ } }, "dashboard.grid.add-shared-accept" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:69", "src/app/main/ui/dashboard/grid.cljs:95" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:95", "src/app/main/ui/workspace/header.cljs:69" ], "translations" : { "en" : "Add as Shared Library", "fr" : "", @@ -306,7 +306,7 @@ } }, "dashboard.grid.add-shared-hint" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:68", "src/app/main/ui/dashboard/grid.cljs:94" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:94", "src/app/main/ui/workspace/header.cljs:68" ], "translations" : { "en" : "Once added as Shared Library, the assets of this file library will be available to be used among the rest of your files.", "fr" : "", @@ -315,7 +315,7 @@ } }, "dashboard.grid.add-shared-message" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:67", "src/app/main/ui/dashboard/grid.cljs:93" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:93", "src/app/main/ui/workspace/header.cljs:67" ], "translations" : { "en" : "Add ā€œ%sā€ as Shared Library", "fr" : "", @@ -342,7 +342,7 @@ } }, "dashboard.grid.remove-shared" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:144", "src/app/main/ui/dashboard/grid.cljs:165" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:165", "src/app/main/ui/workspace/header.cljs:144" ], "translations" : { "en" : "Remove as Shared Library", "fr" : "", @@ -351,7 +351,7 @@ } }, "dashboard.grid.remove-shared-accept" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:78", "src/app/main/ui/dashboard/grid.cljs:114" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:114", "src/app/main/ui/workspace/header.cljs:78" ], "translations" : { "en" : "Remove as Shared Library", "fr" : "", @@ -360,7 +360,7 @@ } }, "dashboard.grid.remove-shared-hint" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:77", "src/app/main/ui/dashboard/grid.cljs:113" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:113", "src/app/main/ui/workspace/header.cljs:77" ], "translations" : { "en" : "Once removed as Shared Library, the File Library of this file will stop being available to be used among the rest of your files.", "fr" : "", @@ -369,7 +369,7 @@ } }, "dashboard.grid.remove-shared-message" : { - "used-in" : [ "src/app/main/ui/workspace/header.cljs:76", "src/app/main/ui/dashboard/grid.cljs:112" ], + "used-in" : [ "src/app/main/ui/dashboard/grid.cljs:112", "src/app/main/ui/workspace/header.cljs:76" ], "translations" : { "en" : "Remove ā€œ%sā€ as Shared Library", "fr" : "", @@ -621,6 +621,7 @@ "unused" : true }, "ds.button.save" : { + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:66" ], "translations" : { "en" : "Save", "fr" : "Sauvegarder", @@ -774,7 +775,7 @@ } }, "errors.media-type-mismatch" : { - "used-in" : [ "src/app/main/data/media.cljs:62", "src/app/main/data/workspace/persistence.cljs:352" ], + "used-in" : [ "src/app/main/data/workspace/persistence.cljs:352", "src/app/main/data/media.cljs:62" ], "translations" : { "en" : "Seems that the contents of the image does not match the file extension.", "fr" : "", @@ -783,7 +784,7 @@ } }, "errors.media-type-not-allowed" : { - "used-in" : [ "src/app/main/data/media.cljs:59", "src/app/main/data/workspace/persistence.cljs:349" ], + "used-in" : [ "src/app/main/data/workspace/persistence.cljs:349", "src/app/main/data/media.cljs:59" ], "translations" : { "en" : "Seems that this is not a valid image.", "fr" : "", @@ -828,7 +829,7 @@ } }, "errors.unexpected-error" : { - "used-in" : [ "src/app/main/data/media.cljs:65", "src/app/main/ui/settings/change_email.cljs:51", "src/app/main/ui/auth/register.cljs:54", "src/app/main/ui/workspace/sidebar/options/exports.cljs:66" ], + "used-in" : [ "src/app/main/data/media.cljs:65", "src/app/main/ui/settings/change_email.cljs:51", "src/app/main/ui/workspace/sidebar/options/exports.cljs:66", "src/app/main/ui/auth/register.cljs:54" ], "translations" : { "en" : "An unexpected error occurred.", "fr" : "Une erreur inattendue c'est produite", @@ -873,7 +874,7 @@ } }, "media.loading" : { - "used-in" : [ "src/app/main/data/media.cljs:44", "src/app/main/data/workspace/persistence.cljs:334" ], + "used-in" : [ "src/app/main/data/workspace/persistence.cljs:334", "src/app/main/data/media.cljs:44" ], "translations" : { "en" : "Loading image...", "fr" : "Chargement de l'image...", @@ -882,6 +883,7 @@ } }, "modal.create-color.new-color" : { + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:59" ], "translations" : { "en" : "New Color", "fr" : "Nouvelle couleur", @@ -1458,7 +1460,7 @@ } }, "workspace.assets.assets" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:374" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:476" ], "translations" : { "en" : "Assets", "fr" : "", @@ -1467,7 +1469,7 @@ } }, "workspace.assets.box-filter-all" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:394" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:496" ], "translations" : { "en" : "All assets", "fr" : "", @@ -1476,7 +1478,7 @@ } }, "workspace.assets.box-filter-colors" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:396" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:498" ], "translations" : { "en" : "Colors", "fr" : "", @@ -1485,7 +1487,7 @@ } }, "workspace.assets.box-filter-graphics" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:395" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:497" ], "translations" : { "en" : "Graphics", "fr" : "", @@ -1494,7 +1496,7 @@ } }, "workspace.assets.colors" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:247" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:324" ], "translations" : { "en" : "Colors", "fr" : "", @@ -1502,8 +1504,17 @@ "es" : "Colores" } }, + "workspace.assets.components" : { + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:106" ], + "translations" : { + "en" : "Components", + "fr" : "", + "ru" : "", + "es" : "Componentes" + } + }, "workspace.assets.delete" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:125", "src/app/main/ui/workspace/sidebar/assets.cljs:224" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:125", "src/app/main/ui/workspace/sidebar/assets.cljs:210", "src/app/main/ui/workspace/sidebar/assets.cljs:304" ], "translations" : { "en" : "Delete", "fr" : "", @@ -1512,7 +1523,7 @@ } }, "workspace.assets.edit" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:223" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:303" ], "translations" : { "en" : "Edit", "fr" : "", @@ -1521,7 +1532,7 @@ } }, "workspace.assets.file-library" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:309" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:401" ], "translations" : { "en" : "File library", "fr" : "", @@ -1530,7 +1541,7 @@ } }, "workspace.assets.graphics" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:99" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:184" ], "translations" : { "en" : "Graphics", "fr" : "", @@ -1539,7 +1550,7 @@ } }, "workspace.assets.libraries" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:377" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:479" ], "translations" : { "en" : "Libraries", "fr" : "", @@ -1548,7 +1559,7 @@ } }, "workspace.assets.not-found" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:339" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:440" ], "translations" : { "en" : "No assets found", "fr" : "", @@ -1557,7 +1568,7 @@ } }, "workspace.assets.rename" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:222" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:302" ], "translations" : { "en" : "Rename", "fr" : "", @@ -1566,7 +1577,7 @@ } }, "workspace.assets.search" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:381" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:483" ], "translations" : { "en" : "Search assets", "fr" : "", @@ -1575,7 +1586,7 @@ } }, "workspace.assets.shared" : { - "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:311" ], + "used-in" : [ "src/app/main/ui/workspace/sidebar/assets.cljs:403" ], "translations" : { "en" : "SHARED", "fr" : "", diff --git a/frontend/resources/styles/common/dependencies/colors.scss b/frontend/resources/styles/common/dependencies/colors.scss index 57cc27f167..0e8a21688e 100644 --- a/frontend/resources/styles/common/dependencies/colors.scss +++ b/frontend/resources/styles/common/dependencies/colors.scss @@ -20,6 +20,8 @@ $color-warning: #FC8802; $color-danger: #E65244; $color-info: #59b9e2; $color-ocean: #4285f4; +$color-component: #76B0B8; +$color-component-highlight: #00E0FF; // Gray scale $color-gray-10: #E3E3E3; diff --git a/frontend/resources/styles/main/partials/sidebar-assets.scss b/frontend/resources/styles/main/partials/sidebar-assets.scss index 621db56e64..2324c5ade4 100644 --- a/frontend/resources/styles/main/partials/sidebar-assets.scss +++ b/frontend/resources/styles/main/partials/sidebar-assets.scss @@ -170,6 +170,21 @@ grid-auto-rows: 7vh; column-gap: 0.5rem; row-gap: 0.5rem; + + &.big { + grid-template-columns: 1fr 1fr; + grid-auto-rows: 10vh; + + .grid-cell { + background-color: transparent; + border: 1px solid $color-gray-40; + border-radius: 4px; + + & svg { + height: 10vh; + } + } + } } .grid-cell { diff --git a/frontend/resources/styles/main/partials/sidebar-layers.scss b/frontend/resources/styles/main/partials/sidebar-layers.scss index 6658ff4f2a..9a53caeb66 100644 --- a/frontend/resources/styles/main/partials/sidebar-layers.scss +++ b/frontend/resources/styles/main/partials/sidebar-layers.scss @@ -20,42 +20,42 @@ margin-right: 8px; width: 13px; } - + &.group { &.open { .toggle-content { flex-shrink: 0; - + svg { transform: rotate(270deg); } } } } - + &:hover { background-color: $color-primary; - + svg { fill: $color-gray-60 !important; } - + .element-icon, .element-actions { - + svg { fill: $color-gray-60; } } - + .element-actions > * { display: flex; } - + span { color: $color-gray-60; } - + .toggle-content { svg { fill: $color-gray-60; @@ -64,13 +64,12 @@ } &.selected { - + svg { fill: $color-primary; } - + .element-icon { - svg { fill: $color-primary; } @@ -79,10 +78,10 @@ span { color: $color-primary; } - + &:hover { background-color: $color-primary; - + .element-icon, .element-actions { svg { @@ -95,20 +94,55 @@ } } } - + &.drag-top { border-top: 40px solid $color-gray-60 !important; } - + &.drag-bottom { border-bottom: 40px solid $color-gray-60 !important; } - + &.drag-inside { border: 2px solid $color-primary !important; } } +.element-list li.component { + + .element-list-body { + .element-name { + color: $color-component; + } + + svg { + fill: $color-component; + } + + &.selected { + .element-name { + color: $color-component-highlight; + } + + svg { + fill: $color-component-highlight; + } + } + + &:hover { + background-color: $color-component-highlight; + + .element-name { + color: $color-gray-60; + } + + svg { + fill: $color-gray-60; + } + } + } +} + .element-icon { svg { fill: $color-gray-30; @@ -132,7 +166,7 @@ span.element-name { white-space: nowrap; width: 100%; } - + .element-actions { display: flex; flex-shrink: 0; @@ -149,13 +183,13 @@ span.element-name { > * { display: none; } - + .toggle-element, .block-element { left: 0; position: absolute; top: 0; - + &.selected { display: flex; @@ -177,17 +211,17 @@ span.element-name { .toggle-content { margin-left: auto; width: 12px; - + svg { fill: $color-gray-20; transform: rotate(90deg); width: 10px; } - + &.inverse { svg { transform: rotate(270deg); } } - + &:hover { svg { fill: $color-gray-60; diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index b70766f0d5..d16ab658c7 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -22,6 +22,7 @@ [app.config :as cfg] [app.main.constants :as c] [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.notifications :as dwn] [app.main.data.workspace.persistence :as dwp] [app.main.data.workspace.selection :as dws] @@ -1266,70 +1267,19 @@ ;; GROUPS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn group-shape - [id frame-id selected selection-rect] - {:id id - :type :group - :name (name (gensym "Group-")) - :shapes [] - :frame-id frame-id - :x (:x selection-rect) - :y (:y selection-rect) - :width (:width selection-rect) - :height (:height selection-rect)}) - (def group-selected (ptk/reify ::group-selected ptk/WatchEvent (watch [_ state stream] - (let [id (uuid/next) - page-id (:current-page-id state) + (let [page-id (:current-page-id state) objects (dwc/lookup-page-objects state page-id) selected (get-in state [:workspace-local :selected]) - items (->> selected - (map #(get objects %)) - (filter #(not= :frame (:type %))) - (map #(assoc % ::index (cph/position-on-parent (:id %) objects))) - (sort-by ::index))] - - (when (not-empty items) - (let [selrect (geom/selection-rect items) - frame-id (-> items first :frame-id) - parent-id (-> items first :parent-id) - group (-> (group-shape id frame-id selected selrect) - (geom/setup selrect)) - - index (::index (first items)) - - rchanges [{:type :add-obj - :id id - :page-id page-id - :frame-id frame-id - :parent-id parent-id - :obj group - :index index} - {:type :mov-objects - :page-id page-id - :parent-id id - :shapes (->> items - (map :id) - (into #{}) - (vec))}] - - uchanges - (reduce (fn [res obj] - (conj res {:type :mov-objects - :page-id page-id - :parent-id (:parent-id obj) - :index (::index obj) - :shapes [(:id obj)]})) - [] - items) - - uchanges (conj uchanges {:type :del-obj :id id :page-id page-id})] - + shapes (dws/shapes-for-grouping objects selected)] + (when-not (empty? shapes) + (let [[group rchanges uchanges] + (dws/prepare-create-group page-id shapes "Group-" false)] (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) - (dws/select-shapes (d/ordered-set id))))))))) + (dws/select-shapes (d/ordered-set (:id group)))))))))) (def ungroup-selected (ptk/reify ::ungroup-selected @@ -1342,34 +1292,11 @@ group (get objects group-id)] (when (and (= 1 (count selected)) (= (:type group) :group)) - (let [shapes (:shapes group) - parent-id (cph/get-parent group-id objects) - parent (get objects parent-id) - index-in-parent (->> (:shapes parent) - (map-indexed vector) - (filter #(#{group-id} (second %))) - (ffirst)) - rchanges [{:type :mov-objects - :page-id page-id - :parent-id parent-id - :shapes shapes - :index index-in-parent}] - uchanges [{:type :add-obj - :page-id page-id - :id group-id - :frame-id (:frame-id group) - :obj (assoc group :shapes [])} - {:type :mov-objects - :page-id page-id - :parent-id group-id - :shapes shapes} - {:type :mov-objects - :page-id page-id - :parent-id parent-id - :shapes [group-id] - :index index-in-parent}]] + (let [[rchanges uchanges] + (dws/prepare-remove-group page-id group objects)] (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Interactions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1512,6 +1439,7 @@ "+" #(st/emit! (increase-zoom nil)) "-" #(st/emit! (decrease-zoom nil)) "ctrl+g" #(st/emit! group-selected) + "ctrl+k" #(st/emit! dwl/add-component) "shift+g" #(st/emit! ungroup-selected) "shift+0" #(st/emit! reset-zoom) "shift+1" #(st/emit! zoom-to-fit-all) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 94636042dd..1ef240fa58 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -13,6 +13,7 @@ [app.common.spec :as us] [app.common.uuid :as uuid] [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.selection :as dws] [app.common.pages :as cp] [app.main.repo :as rp] [app.main.store :as st] @@ -68,7 +69,7 @@ (rx/of (dwc/commit-changes [rchg] [uchg] {:commit-local? true})))))) (defn delete-color - [{:keys [id] :as color}] + [{:keys [id] :as params}] (us/assert ::us/uuid id) (ptk/reify ::delete-color ptk/WatchEvent @@ -94,7 +95,7 @@ (defn delete-media - [{:keys [id] :as media}] + [{:keys [id] :as params}] (us/assert ::us/uuid id) (ptk/reify ::delete-media ptk/WatchEvent @@ -106,3 +107,118 @@ :object prev}] (rx/of (dwc/commit-changes [rchg] [uchg] {:commit-local? true})))))) +(declare clone-shape) + +(def add-component + (ptk/reify ::add-component + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + objects (dwc/lookup-page-objects state page-id) + selected (get-in state [:workspace-local :selected]) + shapes (dws/shapes-for-grouping objects selected)] + (when-not (empty? shapes) + (let [;; If the selected shape is a group, we can use it. If not, + ;; we need to create a group before creating the component. + [group rchanges uchanges] + (if (and (= (count shapes) 1) + (= (:type (first shapes)) :group)) + [(first shapes) [] []] + (dws/prepare-create-group page-id shapes "Component-" true)) + + [new-shape new-shapes updated-shapes] + (clone-shape group nil objects) + + rchanges (conj rchanges + {:type :add-component + :id (:id new-shape) + :name (:name new-shape) + :new-shapes new-shapes}) + + rchanges (into rchanges + (map (fn [updated-shape] + {:type :mod-obj + :page-id page-id + :id (:id updated-shape) + :operations [{:type :set + :attr :component-id + :val (:component-id updated-shape)}]}) + updated-shapes)) + + uchanges (conj uchanges + {:type :del-component + :id (:id new-shape)}) + + uchanges (into uchanges + (map (fn [updated-shape] + {:type :mod-obj + :page-id page-id + :id (:id updated-shape) + :operations [{:type :set + :attr :component-id + :val nil}]}) + updated-shapes))] + + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) + (dws/select-shapes (d/ordered-set (:id group)))))))))) + +(defn- clone-shape + "Clone the shape and all children. Generate new ids and detach + from parent and frame. Update the original shapes to have links + to the new ones." + [shape parent-id objects] + (let [new-id (uuid/next)] + (if (nil? (:shapes shape)) + + ; TODO: unify this case with the empty child-ids case. + (let [new-shape (assoc shape + :id new-id + :parent-id parent-id + :frame-id nil)] + [new-shape + [new-shape] + [(assoc shape :component-id (:id new-shape))]]) + + (loop [child-ids (seq (:shapes shape)) + new-children [] + updated-children []] + + (if (empty? child-ids) + (let [new-shape (assoc shape + :id new-id + :parent-id parent-id + :frame-id nil + :shapes (map :id new-children))] + [new-shape + (conj new-children new-shape) + (conj updated-children + (assoc shape :component-id (:id new-shape)))]) + + (let [child-id (first child-ids) + child (get objects child-id) + + [new-child new-child-shapes updated-child-shapes] + (clone-shape child new-id objects)] + + (recur + (next child-ids) + (into new-children new-child-shapes) + (into updated-children updated-child-shapes)))))))) + +(defn delete-component + [{:keys [id] :as params}] + (ptk/reify ::delete-component + ptk/WatchEvent + (watch [_ state stream] + (let [component (get-in state [:workspace-data :components id]) + + rchanges [{:type :del-component + :id id}] + + uchanges [{:type :add-component + :id id + :name (:name component) + :new-shapes (:objects component)}]] + + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))) + diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index 77e0ad2dd8..c60fed1b70 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -183,6 +183,86 @@ (rx/of deselect-all (select-shape (:id selected)))))))) +;; --- Group shapes + +(defn shapes-for-grouping + [objects selected] + (->> selected + (map #(get objects %)) + (filter #(not= :frame (:type %))) + (map #(assoc % ::index (cph/position-on-parent (:id %) objects))) + (sort-by ::index))) + +(defn- make-group + [shapes prefix keep-name] + (let [selrect (geom/selection-rect shapes) + frame-id (-> shapes first :frame-id) + parent-id (-> shapes first :parent-id) + group-name (if (and keep-name (= (count shapes) 1)) + (:name (first shapes)) + (name (gensym prefix)))] + (-> (cp/make-minimal-group frame-id selrect group-name) + (geom/setup selrect) + (assoc :shapes (map :id shapes))))) + +(defn prepare-create-group + [page-id shapes prefix keep-name] + (let [group (make-group shapes prefix keep-name) + rchanges [{:type :add-obj + :id (:id group) + :page-id page-id + :frame-id (:frame-id (first shapes)) + :parent-id (:parent-id (first shapes)) + :obj group + :index (::index (first shapes))} + {:type :mov-objects + :page-id page-id + :parent-id (:id group) + :shapes (map :id shapes)}] + + uchanges (conj + (map (fn [obj] {:type :mov-objects + :page-id page-id + :parent-id (:parent-id obj) + :index (::index obj) + :shapes [(:id obj)]}) + shapes) + {:type :del-obj + :id (:id group) + :page-id page-id})] + [group rchanges uchanges])) + +(defn prepare-remove-group + [page-id group objects] + (let [shapes (:shapes group) + parent-id (cph/get-parent (:id group) objects) + parent (get objects parent-id) + index-in-parent (->> (:shapes parent) + (map-indexed vector) + (filter #(#{(:id group)} (second %))) + (ffirst)) + rchanges [{:type :mov-objects + :page-id page-id + :parent-id parent-id + :shapes shapes + :index index-in-parent}] + uchanges [{:type :add-obj + :page-id page-id + :id (:id group) + :frame-id (:frame-id group) + :obj (assoc group :shapes [])} + {:type :mov-objects + :page-id page-id + :parent-id (:id group) + :shapes shapes} + {:type :mov-objects + :page-id page-id + :parent-id parent-id + :shapes [(:id group)] + :index index-in-parent}]] + [rchanges uchanges])) + + ;; --- Duplicate Shapes (declare prepare-duplicate-change) (declare prepare-duplicate-frame-change) diff --git a/frontend/src/app/main/exports.cljs b/frontend/src/app/main/exports.cljs index a72b72b2d3..5cfbc1243f 100644 --- a/frontend/src/app/main/exports.cljs +++ b/frontend/src/app/main/exports.cljs @@ -156,3 +156,35 @@ :xmlnsXlink "http://www.w3.org/1999/xlink" :xmlns "http://www.w3.org/2000/svg"} [:& wrapper {:shape frame :view-box vbox}]])) + +;; TODO: unify with frame-svg? +(mf/defc component-svg + {::mf/wrap [mf/memo]} + [{:keys [objects group zoom] :or {zoom 1} :as props}] + (let [modifier (-> (gpt/point (:x group) (:y group)) + (gpt/negate) + (gmt/translate-matrix)) + + group-id (:id group) + + modifier-ids (concat [group-id] (cph/get-children group-id objects)) + update-fn #(assoc-in %1 [%2 :modifiers :displacement] modifier) + objects (reduce update-fn objects modifier-ids) + group (assoc-in group [:modifiers :displacement] modifier) + + width (* (:width group) zoom) + height (* (:height group) zoom) + vbox (str "0 0 " (:width group 0) + " " (:height group 0)) + wrapper (mf/use-memo + (mf/deps objects) + #(group-wrapper-factory objects))] + + [:svg {:view-box vbox + :width width + :height height + :version "1.1" + :xmlnsXlink "http://www.w3.org/1999/xlink" + :xmlns "http://www.w3.org/2000/svg"} + [:& wrapper {:shape group :view-box vbox}]])) + diff --git a/frontend/src/app/main/store.cljs b/frontend/src/app/main/store.cljs index b65165f3be..cf645ca837 100644 --- a/frontend/src/app/main/store.cljs +++ b/frontend/src/app/main/store.cljs @@ -67,4 +67,4 @@ (defn ^:export dump-objects [] (let [page-id (get @state :current-page-id)] - (logjs "state" (get-in @state [:workspace-data page-id :objects])))) + (logjs "state" (get-in @state [:workspace-data :pages-index page-id :objects])))) diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 5368f6cae5..157bcddb2f 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -31,6 +31,7 @@ (def chat (icon-xref :chat)) (def circle (icon-xref :circle)) (def close (icon-xref :close)) +(def component (icon-xref :component)) (def copy (icon-xref :copy)) (def curve (icon-xref :curve)) (def download (icon-xref :download)) diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 4488023f80..5277e32b32 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -20,6 +20,7 @@ [app.main.ui.icons :as i] [app.util.dom :as dom] [app.main.data.workspace :as dw] + [app.main.data.workspace.libraries :as dwl] [app.main.ui.hooks :refer [use-rxsub]] [app.main.ui.components.dropdown :refer [dropdown]])) @@ -59,7 +60,8 @@ do-lock-shape #(st/emit! (dw/update-shape-flags id {:blocked true})) do-unlock-shape #(st/emit! (dw/update-shape-flags id {:blocked false})) do-create-group #(st/emit! dw/group-selected) - do-remove-group #(st/emit! dw/ungroup-selected)] + do-remove-group #(st/emit! dw/ungroup-selected) + do-add-component #(st/emit! dwl/add-component)] [:* [:& menu-entry {:title "Copy" :shortcut "Ctrl + c" @@ -101,13 +103,17 @@ [:& menu-entry {:title "Hide" :on-click do-hide-shape}]) - - (if (:blocked shape) [:& menu-entry {:title "Unlock" :on-click do-unlock-shape}] [:& menu-entry {:title "Lock" :on-click do-lock-shape}]) + + [:& menu-separator] + [:& menu-entry {:title "Create component" + :shortcut "Ctrl + K" + :on-click do-add-component}] + [:& menu-separator] [:& menu-entry {:title "Delete" :shortcut "Supr" diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs index 7de10fcf53..b3d6484a04 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs @@ -14,6 +14,7 @@ [app.common.geom.shapes :as geom] [app.common.media :as cm] [app.common.pages :as cp] + [app.common.pages-helpers :as cph] [app.common.uuid :as uuid] [app.config :as cfg] [app.main.data.workspace :as dw] @@ -21,6 +22,7 @@ [app.main.data.colors :as dc] [app.main.refs :as refs] [app.main.store :as st] + [app.main.exports :as exports] [app.main.ui.components.context-menu :refer [context-menu]] [app.main.ui.components.file-uploader :refer [file-uploader]] [app.main.ui.components.tab-container :refer [tab-container tab-element]] @@ -38,6 +40,62 @@ [okulary.core :as l] [rumext.alpha :as mf])) +(mf/defc components-box + [{:keys [file-id local? components] :as props}] + (let [state (mf/use-state {:menu-open false + :top nil + :left nil + :component-id nil}) + on-delete + (mf/use-callback + (mf/deps state) + (fn [] + (let [params {:id (:component-id @state)}] + (st/emit! (dwl/delete-component params))))) + + on-context-menu + (mf/use-callback + (fn [component-id] + (fn [event] + (when local? + (let [pos (dom/get-client-position event) + top (:y pos) + left (- (:x pos) 20)] + (dom/prevent-default event) + (swap! state assoc :menu-open true + :top top + :left left + :component-id component-id)))))) + + on-drag-start + (mf/use-callback + (fn [path event] + (dnd/set-data! event "text/uri-list" (cfg/resolve-media-path path)) + (dnd/set-allowed-effect! event "move")))] + + [:div.asset-group + [:div.group-title + (tr "workspace.assets.components") + [:span (str "\u00A0(") (count components) ")"]] ;; Unicode 00A0 is non-breaking space + [:div.group-grid.big + (for [component components] + [:div.grid-cell {:key (:id component) + :draggable true + :on-context-menu (on-context-menu (:id component)) + :on-drag-start (partial on-drag-start (:path component))} + [:& exports/component-svg {:group (get-in component [:objects (:id component)]) + :objects (:objects component)}] + [:div.cell-name (:name component)]]) + + (when local? + [:& context-menu + {:selectable false + :show (:menu-open @state) + :on-close #(swap! state assoc :menu-open false) + :top (:top @state) + :left (:left @state) + :options [[(tr "workspace.assets.delete") on-delete]]}])]])) + (mf/defc graphics-box [{:keys [file-id local? objects open? on-open on-close] :as props}] (let [input-ref (mf/use-ref nil) @@ -126,7 +184,6 @@ :left (:left @state) :options [[(tr "workspace.assets.delete") on-delete]]}])])])) - (mf/defc color-item [{:keys [color local? locale file-id] :as props}] (let [rename? (= (:color-for-rename @refs/workspace-local) (:id color)) @@ -287,32 +344,45 @@ (vals (get-in state [:workspace-libraries id :data :media]))))) st/state =)) +(defn file-components-ref + [id] + (l/derived (fn [state] + (let [wfile (:workspace-file state)] + (if (= (:id wfile) id) + (vals (get-in wfile [:data :components])) + (vals (get-in state [:workspace-libraries id :data :components]))))) + st/state =)) + (defn apply-filters [coll filters] - (filter (fn [item] - (or (matches-search (:name item "!$!") (:term filters)) - (matches-search (:value item "!$!") (:term filters)))) - coll)) + (->> coll + (filter (fn [item] + (or (matches-search (:name item "!$!") (:term filters)) + (matches-search (:value item "!$!") (:term filters))))) + (sort-by #(str/lower (:name %))))) (mf/defc file-library [{:keys [file local? open? filters locale] :as props}] - (let [open? (mf/use-state open?) - shared? (:is-shared file) - router (mf/deref refs/router) - toggle-open #(swap! open? not) + (let [open? (mf/use-state open?) + shared? (:is-shared file) + router (mf/deref refs/router) + toggle-open #(swap! open? not) toggles (mf/use-state #{:graphics :colors}) - url (rt/resolve router :workspace - {:project-id (:project-id file) - :file-id (:id file)} - {:page-id (get-in file [:data :pages 0])}) + url (rt/resolve router :workspace + {:project-id (:project-id file) + :file-id (:id file)} + {:page-id (get-in file [:data :pages 0])}) - colors-ref (mf/use-memo (mf/deps (:id file)) #(file-colors-ref (:id file))) - colors (apply-filters (mf/deref colors-ref) filters) + colors-ref (mf/use-memo (mf/deps (:id file)) #(file-colors-ref (:id file))) + colors (apply-filters (mf/deref colors-ref) filters) - media-ref (mf/use-memo (mf/deps (:id file)) #(file-media-ref (:id file))) - media (apply-filters (mf/deref media-ref) filters)] + media-ref (mf/use-memo (mf/deps (:id file)) #(file-media-ref (:id file))) + media (apply-filters (mf/deref media-ref) filters) + + components-ref (mf/use-memo (mf/deps (:id file)) #(file-components-ref (:id file))) + components (apply-filters (mf/deref components-ref) filters)] [:div.tool-window [:div.tool-window-bar @@ -332,15 +402,23 @@ [:a {:href (str "#" url) :target "_blank"} i/chain]]])] (when @open? - (let [show-graphics? (and (or (= (:box filters) :all) - (= (:box filters) :graphics)) - (or (> (count media) 0) - (str/empty? (:term filters)))) - show-colors? (and (or (= (:box filters) :all) - (= (:box filters) :colors)) - (or (> (count colors) 0) - (str/empty? (:term filters))))] + (let [show-components? (and (or (= (:box filters) :all) + (= (:box filters) :components)) + (or (> (count components) 0) + (str/empty? (:term filters)))) + show-graphics? (and (or (= (:box filters) :all) + (= (:box filters) :graphics)) + (or (> (count media) 0) + (str/empty? (:term filters)))) + show-colors? (and (or (= (:box filters) :all) + (= (:box filters) :colors)) + (or (> (count colors) 0) + (str/empty? (:term filters))))] [:div.tool-window-content + (when show-components? + [:& components-box {:file-id (:id file) + :local? local? + :components components}]) (when show-graphics? [:& graphics-box {:file-id (:id file) :local? local? @@ -357,10 +435,11 @@ :on-open #(swap! toggles conj :colors) :on-close #(swap! toggles disj :colors)}]) - (when (and (not show-graphics?) (not show-colors?)) + (when (and (not show-components?) (not show-graphics?) (not show-colors?)) [:div.asset-group [:div.group-title (t locale "workspace.assets.not-found")]])]))])) + (mf/defc assets-toolbox [{:keys [team-id file] :as props}] (let [libraries (mf/deref refs/workspace-libraries) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index c5da4ad8d2..77fcd0e249 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -43,7 +43,9 @@ :rect i/box :curve i/curve :text i/text - :group i/folder + :group (if (nil? (:component-id shape)) + i/folder + i/component) nil)) ;; --- Layer Name @@ -186,6 +188,7 @@ [:li {:on-context-menu on-context-menu :ref dref :class (dom/classnames + :component (not (nil? (:component-id item))) :dnd-over (= (:over dprops) :center) :dnd-over-top (= (:over dprops) :top) :dnd-over-bot (= (:over dprops) :bot) @@ -285,7 +288,16 @@ (defn- strip-objects [objects] - (let [strip-data #(select-keys % [:id :name :blocked :hidden :shapes :type :content :parent-id :metadata])] + (let [strip-data #(select-keys % [:id + :name + :blocked + :hidden + :shapes + :type + :content + :parent-id + :component-id + :metadata])] (persistent! (reduce-kv (fn [res id obj] (assoc! res id (strip-data obj)))