diff --git a/CHANGES.md b/CHANGES.md
index e8effdbd0..7efcc5474 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -5,6 +5,7 @@
### :sparkles: New features
- Add integration with gitpod.io (an online IDE) [#807](https://github.com/penpot/penpot/pull/807)
+- Allow to group assets (components and graphics) [Taiga #1289](https://tree.taiga.io/project/penpot/us/1289)
- Internal: refactor of http client, replace internal xhr usage with more modern Fetch API.
diff --git a/common/app/common/pages.cljc b/common/app/common/pages.cljc
index 185a4fcd0..2776e8168 100644
--- a/common/app/common/pages.cljc
+++ b/common/app/common/pages.cljc
@@ -62,6 +62,10 @@
(d/export helpers/get-index-in-parent)
(d/export helpers/calculate-z-index)
(d/export helpers/generate-child-all-parents-index)
+(d/export helpers/parse-path-name)
+(d/export helpers/merge-path-item)
+(d/export helpers/compact-path)
+(d/export helpers/compact-name)
;; Process changes
(d/export changes/process-changes)
diff --git a/common/app/common/pages/changes.cljc b/common/app/common/pages/changes.cljc
index 458b02eaa..713ab73c9 100644
--- a/common/app/common/pages/changes.cljc
+++ b/common/app/common/pages/changes.cljc
@@ -364,21 +364,25 @@
;; -- Components
(defmethod process-change :add-component
- [data {:keys [id name shapes]}]
+ [data {:keys [id name path shapes]}]
(assoc-in data [:components id]
{:id id
:name name
+ :path path
:objects (d/index-by :id shapes)}))
(defmethod process-change :mod-component
- [data {:keys [id name objects]}]
+ [data {:keys [id name path objects]}]
(update-in data [:components id]
#(cond-> %
- (some? name)
- (assoc :name name)
+ (some? name)
+ (assoc :name name)
- (some? objects)
- (assoc :objects objects))))
+ (some? path)
+ (assoc :path path)
+
+ (some? objects)
+ (assoc :objects objects))))
(defmethod process-change :del-component
[data {:keys [id]}]
diff --git a/common/app/common/pages/helpers.cljc b/common/app/common/pages/helpers.cljc
index abaae925e..39597788d 100644
--- a/common/app/common/pages/helpers.cljc
+++ b/common/app/common/pages/helpers.cljc
@@ -9,7 +9,8 @@
[app.common.data :as d]
[app.common.geom.shapes :as gsh]
[app.common.spec :as us]
- [app.common.uuid :as uuid]))
+ [app.common.uuid :as uuid]
+ [cuerdas.core :as str]))
(defn walk-pages
"Go through all pages of a file and apply a function to each one"
@@ -332,7 +333,6 @@
(d/concat new-children new-child-objects)
(d/concat updated-children updated-child-objects))))))))
-
(defn indexed-shapes
"Retrieves a list with the indexes for each element in the layer tree.
This will be used for shift+selection."
@@ -459,3 +459,55 @@
[parent-idx _] (d/seek (fn [[idx child-id]] (= child-id shape-id))
(d/enumerate (:shapes parent)))]
parent-idx))
+
+(defn split-path
+ [path]
+ "Decompose a string in the form 'one / two / three' into
+ an array of strings, normalizing spaces."
+ (->> (str/split path "/")
+ (map str/trim)
+ (remove str/empty?)))
+
+(defn parse-path-name
+ "Parse a string in the form 'group / subgroup / name'.
+ Retrieve the path and the name in separated values, normalizing spaces."
+ [path-name]
+ (let [path-name-split (split-path path-name)
+ path (str/join " / " (butlast path-name-split))
+ name (last path-name-split)]
+ [path name]))
+
+(defn merge-path-item
+ "Put the item at the end of the path."
+ [path name]
+ (if-not (empty? path)
+ (str path " / " name)
+ name))
+
+(defn compact-path
+ "Separate last item of the path, and truncate the others if too long:
+ 'one' -> ['' 'one' false]
+ 'one / two / three' -> ['one / two' 'three' false]
+ 'one / two / three / four' -> ['one / two / ...' 'four' true]
+ 'one-item-but-very-long / two' -> ['...' 'two' true] "
+ [path max-length]
+ (let [path-split (split-path path)
+ last-item (last path-split)]
+ (loop [other-items (seq (butlast path-split))
+ other-path ""]
+ (if-let [item (first other-items)]
+ (let [full-path (-> other-path
+ (merge-path-item item)
+ (merge-path-item last-item))]
+ (if (> (count full-path) max-length)
+ [(merge-path-item other-path "...") last-item true]
+ (recur (next other-items)
+ (merge-path-item other-path item))))
+ [other-path last-item false]))))
+
+(defn compact-name
+ "Append the first item of the path and the name."
+ [path name]
+ (let [path-split (split-path path)]
+ (merge-path-item (first path-split) name)))
+
diff --git a/common/app/common/pages/spec.cljc b/common/app/common/pages/spec.cljc
index 274d6f32e..6f7be0765 100644
--- a/common/app/common/pages/spec.cljc
+++ b/common/app/common/pages/spec.cljc
@@ -16,6 +16,7 @@
(s/def ::frame-id uuid?)
(s/def ::id uuid?)
(s/def ::name string?)
+(s/def ::path string?)
(s/def ::page-id uuid?)
(s/def ::parent-id uuid?)
(s/def ::string string?)
@@ -547,7 +548,8 @@
(s/coll-of ::shape))
(defmethod change-spec :add-component [_]
- (s/keys :req-un [::id ::name :internal.changes.add-component/shapes]))
+ (s/keys :req-un [::id ::name :internal.changes.add-component/shapes]
+ :opt-un [::path]))
(defmethod change-spec :mod-component [_]
(s/keys :req-un [::id]
diff --git a/frontend/resources/images/icons/listing-enum.svg b/frontend/resources/images/icons/listing-enum.svg
new file mode 100644
index 000000000..d979e5010
--- /dev/null
+++ b/frontend/resources/images/icons/listing-enum.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/listing-thumbs.svg b/frontend/resources/images/icons/listing-thumbs.svg
new file mode 100644
index 000000000..ac5d98e47
--- /dev/null
+++ b/frontend/resources/images/icons/listing-thumbs.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/sort-ascending.svg b/frontend/resources/images/icons/sort-ascending.svg
new file mode 100644
index 000000000..a8907d170
--- /dev/null
+++ b/frontend/resources/images/icons/sort-ascending.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/sort-descending.svg b/frontend/resources/images/icons/sort-descending.svg
new file mode 100644
index 000000000..4ba6c45c0
--- /dev/null
+++ b/frontend/resources/images/icons/sort-descending.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/styles/main/partials/color-bullet.scss b/frontend/resources/styles/main/partials/color-bullet.scss
index 4c300a4e7..eb4882a51 100644
--- a/frontend/resources/styles/main/partials/color-bullet.scss
+++ b/frontend/resources/styles/main/partials/color-bullet.scss
@@ -74,7 +74,7 @@ ul.palette-menu .color-bullet {
background-size: 8px;
}
-.asset-group .group-list-item .color-bullet {
+.asset-section .asset-list-item .color-bullet {
border: 1px solid $color-gray-20;
border-radius: 10px;
height: 20px;
diff --git a/frontend/resources/styles/main/partials/sidebar-assets.scss b/frontend/resources/styles/main/partials/sidebar-assets.scss
index b2915448e..9ca1327d7 100644
--- a/frontend/resources/styles/main/partials/sidebar-assets.scss
+++ b/frontend/resources/styles/main/partials/sidebar-assets.scss
@@ -118,41 +118,89 @@
cursor: pointer;
}
- .asset-group {
+ .listing-options {
+ background-color: $color-gray-60;
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ padding: $medium $small 0 $small;
+
+ .listing-option-btn {
+ cursor: pointer;
+ margin-left: $small;
+
+ svg {
+ fill: $color-gray-20;
+ height: 16px;
+ width: 16px;
+ }
+ }
+ }
+
+ .asset-section {
background-color: $color-gray-60;
- border-top: 1px solid $color-gray-50;
padding: $small;
font-size: $fs12;
color: $color-gray-20;
/* TODO: see if this is useful, or is better to leave only
one scroll bar in the whole sidebar
- (also see .group-list) */
+ (also see .asset-list) */
// max-height: 30rem;
// overflow-y: scroll;
- .group-title {
- display: flex;
- cursor: pointer;
+ // First child is the listing options buttons
+ &:not(:nth-child(2)) {
+ border-top: 1px solid $color-gray-50;
+ }
+
+ .asset-title {
+ display: flex;
+ cursor: pointer;
& .num-assets {
color: $color-gray-30;
}
& svg {
- height: 8px;
- width: 8px;
- fill: $color-gray-30;
- margin-right: 4px;
- transform: rotate(90deg);
+ height: 8px;
+ width: 8px;
+ fill: $color-gray-30;
+ margin-right: 4px;
+ transform: rotate(90deg);
}
&.closed svg {
- transform: rotate(0deg);
- transition: transform 0.3s;
+ transform: rotate(0deg);
+ transition: transform 0.3s;
}
}
- .group-button {
+ .group-title {
+ display: flex;
+ cursor: pointer;
+ margin-top: $small;
+ margin-bottom: $x-small;
+ color: $color-white;
+
+ & svg {
+ height: 8px;
+ width: 8px;
+ fill: $color-white;
+ margin-right: 4px;
+ transform: rotate(90deg);
+ }
+
+ &.closed svg {
+ transform: rotate(0deg);
+ transition: transform 0.3s;
+ }
+
+ & .dim {
+ color: $color-gray-40;
+ }
+ }
+
+ .assets-button {
margin-left: auto;
cursor: pointer;
@@ -167,8 +215,11 @@
}
}
- .group-grid {
- margin-top: $medium;
+ .asset-title + .asset-grid {
+ margin-top: $small;
+ }
+
+ .asset-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-auto-rows: 6vh;
@@ -192,6 +243,7 @@
.grid-cell {
background-color: $color-canvas;
border-radius: 4px;
+ border: 2px solid transparent;
overflow: hidden;
display: flex;
align-items: center;
@@ -242,26 +294,80 @@
}
.grid-cell:hover {
- border: 1px solid $color-primary;
+ border: 2px solid $color-primary;
& .cell-name {
display: block;
}
}
+ .grid-cell.selected {
+ border: 2px solid $color-primary;
+ }
+
+ .asset-title + .asset-enum {
+ margin-top: $small;
+ }
+
+ .asset-enum {
+ .enum-item {
+ display: flex;
+ align-items: center;
+ margin-bottom: $small;
+ cursor: pointer;
+
+ & > svg,
+ & > img {
+ background-color: $color-canvas;
+ border-radius: 4px;
+ border: 2px solid transparent;
+ height: 24px;
+ width: 24px;
+ margin-right: $small;
+ }
+
+ .item-name {
+ width: calc(100% - 24px - #{$small});
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: block;
+
+ &.editing {
+ display: flex;
+ align-items: center;
+
+ .editable-label-input {
+ height: 24px;
+ }
+
+ .editable-label-close {
+ display: none;
+ }
+ }
+ }
+ }
+
+ .enum-item:hover,
+ .enum-item.selected,
+ {
+ color: $color-primary;
+ }
+ }
+
/* TODO: see if this is useful, or is better to leave only
one scroll bar in the whole sidebar
- (also see .asset-group) */
- // .group-list {
+ (also see .asset-section) */
+ // .asset-list {
// max-height: 30rem;
// overflow-y: scroll;
// }
- .group-list {
+ .asset-list {
margin-top: $medium;
}
- .group-list-item {
+ .asset-list-item {
display: flex;
align-items: center;
margin-top: $small;
diff --git a/frontend/resources/styles/main/partials/sidebar-element-options.scss b/frontend/resources/styles/main/partials/sidebar-element-options.scss
index e0770692d..9ef30810d 100644
--- a/frontend/resources/styles/main/partials/sidebar-element-options.scss
+++ b/frontend/resources/styles/main/partials/sidebar-element-options.scss
@@ -983,7 +983,7 @@
display: flex;
}
-.asset-group {
+.asset-section {
.typography-entry {
margin: 0.25rem 0;
}
diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs
index 675e50a1f..fa9d3b279 100644
--- a/frontend/src/app/main/data/workspace/libraries.cljs
+++ b/frontend/src/app/main/data/workspace/libraries.cljs
@@ -157,14 +157,17 @@
ptk/WatchEvent
(watch [_ state stream]
(let [object (get-in state [:workspace-data :media id])
+ [path name] (cp/parse-path-name new-name)
rchanges [{:type :mod-media
:object {:id id
- :name new-name}}]
+ :name name
+ :path path}}]
uchanges [{:type :mod-media
:object {:id id
- :name (:name object)}}]]
+ :name (:name object)
+ :path (:path object)}}]]
(rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}))))))
@@ -190,7 +193,7 @@
ptk/WatchEvent
(watch [_ state s]
(let [rchg {:type :add-typography
- :typography (assoc typography :ts (.now js/Date))}
+ :typography typography}
uchg {:type :del-typography
:id (:id typography)}]
(rx/of (dwc/commit-changes [rchg] [uchg] {:commit-local? true})
@@ -250,20 +253,24 @@
(ptk/reify ::rename-component
ptk/WatchEvent
(watch [_ state stream]
- (let [component (get-in state [:workspace-data :components id])
+ (let [[path name] (cp/parse-path-name new-name)
+ component (get-in state [:workspace-data :components id])
objects (get component :objects)
+ ; Give the same name to the root shape
new-objects (assoc-in objects
[(:id component) :name]
- new-name)
+ name)
rchanges [{:type :mod-component
:id id
- :name new-name
+ :name name
+ :path path
:objects new-objects}]
uchanges [{:type :mod-component
:id id
:name (:name component)
+ :path (:path component)
:objects objects}]]
(rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}))))))
@@ -288,6 +295,7 @@
rchanges [{:type :add-component
:id (:id new-shape)
:name new-name
+ :path (:path component)
:shapes new-shapes}]
uchanges [{:type :del-component
@@ -310,6 +318,7 @@
uchanges [{:type :add-component
:id id
:name (:name component)
+ :path (:path component)
:shapes (vals (:objects component))}]]
(rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}))))))
diff --git a/frontend/src/app/main/ui/components/editable_label.cljs b/frontend/src/app/main/ui/components/editable_label.cljs
index d5d7188ed..60e5feea4 100644
--- a/frontend/src/app/main/ui/components/editable_label.cljs
+++ b/frontend/src/app/main/ui/components/editable_label.cljs
@@ -14,8 +14,10 @@
[rumext.alpha :as mf]))
(mf/defc editable-label
- [{:keys [value on-change on-cancel editing? disable-dbl-click? class-name]}]
- (let [input (mf/use-ref nil)
+ [{:keys [value on-change on-cancel editing? disable-dbl-click? class-name] :as props}]
+ (let [display-value (get props :display-value value)
+ tooltip (get props :tooltip)
+ input (mf/use-ref nil)
state (mf/use-state (:editing false))
is-editing (:editing @state)
start-editing (fn []
@@ -53,4 +55,5 @@
:on-blur cancel-editing}]
[:span.editable-label-close {:on-click cancel-editing} i/close]]
[:span.editable-label {:class class-name
- :on-double-click on-dbl-click} value])))
+ :title tooltip
+ :on-double-click on-dbl-click} display-value])))
diff --git a/frontend/src/app/main/ui/components/forms.cljs b/frontend/src/app/main/ui/components/forms.cljs
index 230f521b1..2827f5a6e 100644
--- a/frontend/src/app/main/ui/components/forms.cljs
+++ b/frontend/src/app/main/ui/components/forms.cljs
@@ -23,6 +23,7 @@
(let [input-type (get props :type "text")
input-name (get props :name)
more-classes (get props :class)
+ auto-focus? (get props :auto-focus? false)
form (or form (mf/use-ctx form-ctx))
@@ -84,6 +85,7 @@
(dissoc :help-icon :form :trim)
(assoc :id (name input-name)
:value value
+ :auto-focus auto-focus?
:on-focus on-focus
:on-blur on-blur
:placeholder label
diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs
index 5c41f28ef..0003704c0 100644
--- a/frontend/src/app/main/ui/icons.cljs
+++ b/frontend/src/app/main/ui/icons.cljs
@@ -60,6 +60,8 @@
(def libraries (icon-xref :libraries))
(def library (icon-xref :library))
(def line (icon-xref :line))
+(def listing-enum (icon-xref :listing-enum))
+(def listing-thumbs (icon-xref :listing-thumbs))
(def line-height (icon-xref :line-height))
(def loader (icon-xref :loader))
(def lock (icon-xref :lock))
@@ -117,6 +119,8 @@
(def shape-vdistribute (icon-xref :shape-vdistribute))
(def size-horiz (icon-xref :size-horiz))
(def size-vert (icon-xref :size-vert))
+(def sort-ascending (icon-xref :sort-ascending))
+(def sort-descending (icon-xref :sort-descending))
(def strikethrough (icon-xref :strikethrough))
(def stroke (icon-xref :stroke))
(def sublevel (icon-xref :sublevel))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs
index 6a2abb5b0..24cb1d1fc 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs
@@ -7,6 +7,7 @@
(ns app.main.ui.workspace.sidebar.assets
(:require
[app.common.data :as d]
+ [app.common.spec :as us]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as geom]
[app.common.media :as cm]
@@ -17,6 +18,7 @@
[app.main.data.colors :as dc]
[app.main.data.modal :as modal]
[app.main.data.workspace :as dw]
+ [app.main.data.workspace.common :as dwc]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.texts :as dwt]
[app.main.exports :as exports]
@@ -26,6 +28,7 @@
[app.main.ui.components.context-menu :refer [context-menu]]
[app.main.ui.components.editable-label :refer [editable-label]]
[app.main.ui.components.file-uploader :refer [file-uploader]]
+ [app.main.ui.components.forms :as fm]
[app.main.ui.components.tab-container :refer [tab-container tab-element]]
[app.main.ui.context :as ctx]
[app.main.ui.icons :as i]
@@ -37,28 +40,149 @@
[app.util.keyboard :as kbd]
[app.util.router :as rt]
[app.util.timers :as timers]
+ [cljs.spec.alpha :as s]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.alpha :as mf]))
+
+;; ---- Assets selection management
+
+(def empty-selection #{})
+
+(defn toggle-select
+ [selected asset-id]
+ (if (contains? selected asset-id)
+ (disj selected asset-id)
+ (conj selected asset-id)))
+
+(defn replace-select
+ [selected asset-id]
+ #{asset-id})
+
+(defn extend-select
+ [selected asset-id groups]
+ (let [assets (->> groups vals flatten)
+ clicked-idx (d/index-of-pred assets #(= (:id %) asset-id))
+ selected-idx (->> selected
+ (map (fn [id] (d/index-of-pred assets
+ #(= (:id %) id)))))
+ min-idx (apply min (conj selected-idx clicked-idx))
+ max-idx (apply max (conj selected-idx clicked-idx))]
+
+ (->> assets
+ d/enumerate
+ (filter #(<= min-idx (first %) max-idx))
+ (map #(-> % second :id))
+ set)))
+
+
+;; ---- Group assets management ----
+
+(s/def ::asset-name ::us/not-empty-string)
+(s/def ::create-group-form
+ (s/keys :req-un [::asset-name]))
+
+(defn group-assets
+ [assets]
+ (reduce (fn [groups asset]
+ (update groups (or (:path asset) "")
+ #(conj (or % []) asset)))
+ (sorted-map)
+ assets))
+
+(def empty-folded-groups #{})
+
+(defn toggle-folded-group
+ [folded-groups path]
+ (if (contains? folded-groups path)
+ (disj folded-groups path)
+ (conj folded-groups path)))
+
+(mf/defc create-group-dialog
+ {::mf/register modal/components
+ ::mf/register-as :create-group-dialog}
+ [{:keys [create] :as ctx}]
+ (let [form (fm/use-form :spec ::create-group-form
+ :initial {})
+
+ close #(modal/hide!)
+
+ on-accept
+ (mf/use-callback
+ (mf/deps form)
+ (fn [event]
+ (let [asset-name (get-in @form [:clean-data :asset-name])]
+ (create asset-name)
+ (modal/hide!))))]
+
+ [:div.modal-overlay
+ [:div.modal-container.confirm-dialog
+ [:div.modal-header
+ [:div.modal-header-title
+ [:h2 (tr "workspace.assets.create-group")]]
+ [:div.modal-close-button
+ {:on-click close} i/close]]
+
+ [:div.modal-content.generic-form
+ [:& fm/form {:form form}
+ [:& fm/input {:name :asset-name
+ :auto-focus? true
+ :label (tr "workspace.assets.group-name")
+ :hint (tr "workspace.assets.create-group-hint")}]]]
+
+ [:div.modal-footer
+ [:div.action-buttons
+ [:input.cancel-button
+ {:type "button"
+ :value (tr "labels.cancel")
+ :on-click close}]
+
+ [:input.accept-button.primary
+ {:type "button"
+ :class (when-not (:valid @form) "btn-disabled")
+ :disabled (not (:valid @form))
+ :value (tr "labels.create")
+ :on-click on-accept}]]]]]))
+
+
+;; ---- Components box ----
+
(mf/defc components-box
- [{:keys [file-id local? components open?] :as props}]
+ [{:keys [file-id local? components listing-thumbs? open?] :as props}]
(let [state (mf/use-state {:menu-open false
:renaming nil
:top nil
:left nil
- :component-id nil})
+ :component-id nil
+ :selected empty-selection
+ :folded-groups empty-folded-groups})
+
+ groups (group-assets components)
+ selected (:selected @state)
+ folded-groups (:folded-groups @state)
on-duplicate
(mf/use-callback
(mf/deps state)
- (st/emitf (dwl/duplicate-component {:id (:component-id @state)})))
+ (fn []
+ (if (empty? selected)
+ (st/emit! (dwl/duplicate-component {:id (:component-id @state)}))
+ (do
+ (st/emit! (dwc/start-undo-transaction))
+ (apply st/emit! (map #(dwl/duplicate-component {:id %}) selected))
+ (st/emit! (dwc/commit-undo-transaction))))))
on-delete
(mf/use-callback
(mf/deps state)
(fn []
- (st/emit! (dwl/delete-component {:id (:component-id @state)}))
+ (if (empty? selected)
+ (st/emit! (dwl/delete-component {:id (:component-id @state)}))
+ (do
+ (st/emit! (dwc/start-undo-transaction))
+ (apply st/emit! (map #(dwl/delete-component {:id %}) selected))
+ (st/emit! (dwc/commit-undo-transaction))))
(st/emit! (dwl/sync-file file-id file-id))))
on-rename
@@ -94,6 +218,60 @@
:left left
:component-id component-id))))))
+ unselect-all
+ (mf/use-callback
+ (fn [event]
+ (swap! state assoc :selected empty-selection)))
+
+ on-select
+ (mf/use-callback
+ (mf/deps state)
+ (fn [component-id]
+ (fn [event]
+ (dom/stop-propagation event)
+ (swap! state update :selected
+ (fn [selected]
+ (cond
+ (kbd/ctrl? event)
+ (toggle-select selected component-id)
+
+ (kbd/shift? event)
+ (extend-select selected component-id groups)
+
+ :default
+ (replace-select selected component-id)))))))
+
+ create-group
+ (mf/use-callback
+ (mf/deps components selected)
+ (fn [name]
+ (swap! state assoc :selected empty-selection)
+ (st/emit! (dwc/start-undo-transaction))
+ (apply st/emit!
+ (->> components
+ (filter #(contains? selected (:id %)))
+ (map #(dwl/rename-component
+ (:id %)
+ (str name " / "
+ (cp/merge-path-item (:path %) (:name %)))))))
+ (st/emit! (dwc/commit-undo-transaction))))
+
+ on-fold-group
+ (mf/use-callback
+ (mf/deps groups folded-groups)
+ (fn [path]
+ (fn [event]
+ (dom/stop-propagation event)
+ (swap! state update :folded-groups
+ toggle-folded-group path))))
+
+ on-group
+ (mf/use-callback
+ (mf/deps components selected)
+ (fn [event]
+ (dom/stop-propagation event)
+ (modal/show! :create-group-dialog {:create create-group})))
+
on-drag-start
(mf/use-callback
(fn [component event]
@@ -101,30 +279,60 @@
:component component})
(dnd/set-allowed-effect! event "move")))]
- [:div.asset-group
- [:div.group-title {:class (when (not open?) "closed")}
+ [:div.asset-section {:on-click unselect-all}
+ [:div.asset-title {:class (when (not open?) "closed")}
[:span {:on-click (st/emitf (dwl/set-assets-box-open file-id :components (not open?)))}
i/arrow-slide (tr "workspace.assets.components")]
[:span (str "\u00A0(") (count components) ")"]] ;; Unicode 00A0 is non-breaking space
(when open?
- [:div.group-grid.big
- (for [component components]
- (let [renaming? (= (:renaming @state)(:id component))]
- [:div.grid-cell {:key (:id component)
- :draggable true
- :on-context-menu (on-context-menu (:id component))
- :on-drag-start (partial on-drag-start component)}
- [:& exports/component-svg {:group (get-in component [:objects (:id component)])
- :objects (:objects component)}]
- [:& editable-label
- {:class-name (dom/classnames
- :cell-name true
- :editing renaming?)
- :value (:name component)
- :editing? renaming?
- :disable-dbl-click? true
- :on-change do-rename
- :on-cancel cancel-rename}]]))])
+ (for [group groups]
+ (let [path (first group)
+ components (second group)
+ group-open? (not (contains? folded-groups path))]
+ [:*
+ (when-not (empty? path)
+ (let [[other-path last-path truncated] (cp/compact-path path 35)]
+ [:div.group-title {:class (when-not group-open? "closed")
+ :on-click (on-fold-group path)}
+ [:span i/arrow-slide]
+ (when-not (empty? other-path)
+ [:span.dim {:title (when truncated path)}
+ other-path "\u00A0/\u00A0"])
+ [:span {:title (when truncated path)}
+ last-path]]))
+ (when group-open?
+ [:div {:class-name (dom/classnames
+ :asset-grid @listing-thumbs?
+ :big @listing-thumbs?
+ :asset-enum (not @listing-thumbs?))}
+ (for [component components]
+ (let [renaming? (= (:renaming @state)(:id component))]
+ [:div {:key (:id component)
+ :class-name (dom/classnames
+ :selected (contains? selected (:id component))
+ :grid-cell @listing-thumbs?
+ :enum-item (not @listing-thumbs?))
+ :draggable true
+ :on-click (on-select (:id component))
+ :on-context-menu (on-context-menu (:id component))
+ :on-drag-start (partial on-drag-start component)}
+ [:& exports/component-svg {:group (get-in component [:objects (:id component)])
+ :objects (:objects component)}]
+ [:& editable-label
+ {:class-name (dom/classnames
+ :cell-name @listing-thumbs?
+ :item-name (not @listing-thumbs?)
+ :editing renaming?)
+ :value (cp/merge-path-item (:path component) (:name component))
+ :tooltip (cp/merge-path-item (:path component) (:name component))
+ :display-value (if @listing-thumbs?
+ (:name component)
+ (cp/compact-name (:path component)
+ (:name component)))
+ :editing? renaming?
+ :disable-dbl-click? true
+ :on-change do-rename
+ :on-cancel cancel-rename}]]))])])))
(when local?
[:& context-menu
@@ -133,18 +341,29 @@
:on-close #(swap! state assoc :menu-open false)
:top (:top @state)
:left (:left @state)
- :options [[(tr "workspace.assets.rename") on-rename]
+ :options [(when (<= (count selected) 1)
+ [(tr "workspace.assets.rename") on-rename])
[(tr "workspace.assets.duplicate") on-duplicate]
- [(tr "workspace.assets.delete") on-delete]]}])]))
+ [(tr "workspace.assets.delete") on-delete]
+ [(tr "workspace.assets.group") on-group]]}])]))
+
+
+;; ---- Graphics box ----
(mf/defc graphics-box
- [{:keys [file-id local? objects open?] :as props}]
+ [{:keys [file-id local? objects listing-thumbs? open?] :as props}]
(let [input-ref (mf/use-ref nil)
state (mf/use-state {:menu-open false
:renaming nil
:top nil
:left nil
- :object-id nil})
+ :object-id nil
+ :selected empty-selection
+ :folded-groups empty-folded-groups})
+
+ groups (group-assets objects)
+ selected (:selected @state)
+ folded-groups (:folded-groups @state)
add-graphic
(mf/use-callback
@@ -164,8 +383,12 @@
(mf/use-callback
(mf/deps state)
(fn []
- (let [params {:id (:object-id @state)}]
- (st/emit! (dwl/delete-media params)))))
+ (if (empty? selected)
+ (st/emit! (dwl/delete-media {:id (:object-id @state)}))
+ (do
+ (st/emit! (dwc/start-undo-transaction))
+ (apply st/emit! (map #(dwl/delete-media {:id %}) selected))
+ (st/emit! (dwc/commit-undo-transaction))))))
on-rename
(mf/use-callback
@@ -200,6 +423,60 @@
:left left
:object-id object-id))))))
+ unselect-all
+ (mf/use-callback
+ (fn [event]
+ (swap! state assoc :selected empty-selection)))
+
+ on-select
+ (mf/use-callback
+ (mf/deps state)
+ (fn [object-id]
+ (fn [event]
+ (dom/stop-propagation event)
+ (swap! state update :selected
+ (fn [selected]
+ (cond
+ (kbd/ctrl? event)
+ (toggle-select selected object-id)
+
+ (kbd/shift? event)
+ (extend-select selected object-id groups)
+
+ :default
+ (replace-select selected object-id)))))))
+
+ create-group
+ (mf/use-callback
+ (mf/deps objects selected)
+ (fn [name]
+ (swap! state assoc :selected empty-selection)
+ (st/emit! (dwc/start-undo-transaction))
+ (apply st/emit!
+ (->> objects
+ (filter #(contains? selected (:id %)))
+ (map #(dwl/rename-media
+ (:id %)
+ (str name " / "
+ (cp/merge-path-item (:path %) (:name %)))))))
+ (st/emit! (dwc/commit-undo-transaction))))
+
+ on-fold-group
+ (mf/use-callback
+ (mf/deps groups folded-groups)
+ (fn [path]
+ (fn [event]
+ (dom/stop-propagation event)
+ (swap! state update :folded-groups
+ toggle-folded-group path))))
+
+ on-group
+ (mf/use-callback
+ (mf/deps objects selected)
+ (fn [event]
+ (dom/stop-propagation event)
+ (modal/show! :create-group-dialog {:create create-group})))
+
on-drag-start
(mf/use-callback
(fn [{:keys [name id mtype]} event]
@@ -208,49 +485,82 @@
(dnd/set-data! event "text/asset-type" mtype)
(dnd/set-allowed-effect! event "move")))]
- [:div.asset-group
- [:div.group-title {:class (when (not open?) "closed")}
+ [:div.asset-section {:on-click unselect-all}
+ [:div.asset-title {:class (when (not open?) "closed")}
[:span {:on-click (st/emitf (dwl/set-assets-box-open file-id :graphics (not open?)))}
i/arrow-slide (tr "workspace.assets.graphics")]
[:span.num-assets (str "\u00A0(") (count objects) ")"] ;; Unicode 00A0 is non-breaking space
(when local?
- [:div.group-button {:on-click add-graphic}
+ [:div.assets-button {:on-click add-graphic}
i/plus
[:& file-uploader {:accept cm/str-media-types
:multi true
:input-ref input-ref
:on-selected on-selected}]])]
(when open?
- [:div.group-grid
- (for [object objects]
- [:div.grid-cell {:key (:id object)
- :draggable true
- :on-context-menu (on-context-menu (:id object))
- :on-drag-start (partial on-drag-start object)}
- [:img {:src (cfg/resolve-file-media object true)
- :draggable false}] ;; Also need to add css pointer-events: none
+ (for [group groups]
+ (let [path (first group)
+ objects (second group)
+ group-open? (not (contains? folded-groups path))]
+ [:*
+ (when-not (empty? path)
+ (let [[other-path last-path truncated] (cp/compact-path path 35)]
+ [:div.group-title {:class (when-not group-open? "closed")
+ :on-click (on-fold-group path)}
+ [:span i/arrow-slide]
+ (when-not (empty? other-path)
+ [:span.dim {:title (when truncated path)}
+ other-path "\u00A0/\u00A0"])
+ [:span {:title (when truncated path)}
+ last-path]]))
+ (when group-open?
+ [:div {:class-name (dom/classnames
+ :asset-grid @listing-thumbs?
+ :asset-enum (not @listing-thumbs?))}
+ (for [object objects]
+ [:div {:key (:id object)
+ :class-name (dom/classnames
+ :selected (contains? selected (:id object))
+ :grid-cell @listing-thumbs?
+ :enum-item (not @listing-thumbs?))
+ :draggable true
+ :on-click (on-select (:id object))
+ :on-context-menu (on-context-menu (:id object))
+ :on-drag-start (partial on-drag-start object)}
+ [:img {:src (cfg/resolve-file-media object true)
+ :draggable false}] ;; Also need to add css pointer-events: none
- #_[:div.cell-name (:name object)]
- (let [renaming? (= (:renaming @state) (:id object))]
- [:& editable-label
- {:class-name (dom/classnames
- :cell-name true
- :editing renaming?)
- :value (:name object)
- :editing? renaming?
- :disable-dbl-click? true
- :on-change do-rename
- :on-cancel cancel-rename}])])
+ (let [renaming? (= (:renaming @state) (:id object))]
+ [:& editable-label
+ {:class-name (dom/classnames
+ :cell-name @listing-thumbs?
+ :item-name (not @listing-thumbs?)
+ :editing renaming?)
+ :value (cp/merge-path-item (:path object) (:name object))
+ :tooltip (cp/merge-path-item (:path object) (:name object))
+ :display-value (if @listing-thumbs?
+ (:name object)
+ (cp/compact-name (:path object)
+ (:name object)))
+ :editing? renaming?
+ :disable-dbl-click? true
+ :on-change do-rename
+ :on-cancel cancel-rename}])])])])))
- (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.rename") on-rename]
- [(tr "workspace.assets.delete") on-delete]]}])])]))
+ (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 [(when (<= (count selected) 1)
+ [(tr "workspace.assets.rename") on-rename])
+ [(tr "workspace.assets.delete") on-delete]
+ [(tr "workspace.assets.group") on-group]]}])]))
+
+
+;; ---- Colors box ----
(mf/defc color-item
[{:keys [color local? file-id locale] :as props}]
@@ -337,7 +647,7 @@
(dom/select-text! input))
nil))
- [:div.group-list-item {:on-context-menu on-context-menu}
+ [:div.asset-list-item {:on-context-menu on-context-menu}
[:& bc/color-bullet {:color color
:on-click click-color}]
@@ -387,15 +697,15 @@
:data {:color "#406280"
:opacity 1}
:position :right})))]
- [:div.asset-group
- [:div.group-title {:class (when (not open?) "closed")}
+ [:div.asset-section
+ [:div.asset-title {:class (when (not open?) "closed")}
[:span {:on-click (st/emitf (dwl/set-assets-box-open file-id :colors (not open?)))}
i/arrow-slide (t locale "workspace.assets.colors")]
[:span.num-assets (str "\u00A0(") (count colors) ")"] ;; Unicode 00A0 is non-breaking space
(when local?
- [:div.group-button {:on-click add-color-clicked} i/plus])]
+ [:div.assets-button {:on-click add-color-clicked} i/plus])]
(when open?
- [:div.group-list
+ [:div.asset-list
(for [color colors]
(let [color (cond-> color
(:value color) (assoc :color (:value color) :opacity 1)
@@ -407,6 +717,9 @@
:local? local?
:locale locale}]))])]))
+
+;; ---- Typography box ----
+
(mf/defc typography-box
[{:keys [file file-id local? typographies locale open?] :as props}]
@@ -480,13 +793,13 @@
(when (:edit-typography local)
(st/emit! #(update % :workspace-local dissoc :edit-typography)))))
- [:div.asset-group
- [:div.group-title {:class (when (not open?) "closed")}
+ [:div.asset-section
+ [:div.asset-title {:class (when (not open?) "closed")}
[:span {:on-click (st/emitf (dwl/set-assets-box-open file-id :typographies (not open?)))}
i/arrow-slide (t locale "workspace.assets.typography")]
[:span.num-assets (str "\u00A0(") (count typographies) ")"] ;; Unicode 00A0 is non-breaking space
(when local?
- [:div.group-button {:on-click add-typography} i/plus])]
+ [:div.assets-button {:on-click add-typography} i/plus])]
[:& context-menu
{:selectable false
@@ -498,8 +811,8 @@
[(t locale "workspace.assets.edit") handle-edit-typography-clicked]
[(t locale "workspace.assets.delete") handle-delete-typography]]}]
(when open?
- [:div.group-list
- (for [typography (sort-by :ts typographies)]
+ [:div.asset-list
+ (for [typography typographies]
[:& typography-entry
{:key (:id typography)
:typography typography
@@ -511,6 +824,9 @@
:editting? (= editting-id (:id typography))
:focus-name? (= (:rename-typography local) (:id typography))}])])]))
+
+;; --- Assets toolbox ----
+
(defn file-colors-ref
[id]
(l/derived (fn [state]
@@ -554,43 +870,58 @@
(l/derived refs/workspace-local)))
(defn apply-filters
- [coll filters]
- (->> coll
- (filter (fn [item]
- (or (matches-search (:name item "!$!") (:term filters))
- (matches-search (:value item "!$!") (:term filters)))))
- (sort-by #(str/lower (:name %)))))
+ [coll filters reverse-sort?]
+ (let [comp-fn (if reverse-sort? > <)]
+ (->> coll
+ (filter (fn [item]
+ (or (matches-search (:name item "!$!") (:term filters))
+ (matches-search (:value item "!$!") (:term filters)))))
+ (sort-by #(str/lower (:name %)) comp-fn))))
(mf/defc file-library
- [{:keys [file local? default-open? filters locale] :as props}]
- (let [open-file (mf/deref (open-file-ref (:id file)))
- open? (-> open-file
- :library
- (d/nilv default-open?))
- open-box? (fn [box]
- (-> open-file
- box
- (d/nilv true)))
- shared? (:is-shared file)
- router (mf/deref refs/router)
- toggle-open (st/emitf (dwl/set-assets-box-open (:id file) :library (not open?)))
+ [{:keys [file local? default-open? filters locale] :as props}]
+ (let [open-file (mf/deref (open-file-ref (:id file)))
+ open? (-> open-file
+ :library
+ (d/nilv default-open?))
+ open-box? (fn [box]
+ (-> open-file
+ box
+ (d/nilv true)))
+ shared? (:is-shared file)
+ router (mf/deref refs/router)
- url (rt/resolve router :workspace
- {:project-id (:project-id file)
- :file-id (:id file)}
- {:page-id (get-in file [:data :pages 0])})
+ reverse-sort? (mf/use-state false)
+ listing-thumbs? (mf/use-state true)
- colors-ref (mf/use-memo (mf/deps (:id file)) #(file-colors-ref (:id file)))
- colors (apply-filters (mf/deref colors-ref) filters)
+ toggle-open (st/emitf (dwl/set-assets-box-open (:id file) :library (not open?)))
- typography-ref (mf/use-memo (mf/deps (:id file)) #(file-typography-ref (:id file)))
- typographies (apply-filters (mf/deref typography-ref) filters)
+ url (rt/resolve router :workspace
+ {:project-id (:project-id file)
+ :file-id (:id file)}
+ {:page-id (get-in file [:data :pages 0])})
- media-ref (mf/use-memo (mf/deps (:id file)) #(file-media-ref (:id file)))
- media (apply-filters (mf/deref media-ref) filters)
+ colors-ref (mf/use-memo (mf/deps (:id file)) #(file-colors-ref (:id file)))
+ colors (apply-filters (mf/deref colors-ref) filters @reverse-sort?)
- components-ref (mf/use-memo (mf/deps (:id file)) #(file-components-ref (:id file)))
- components (apply-filters (mf/deref components-ref) filters)]
+ typography-ref (mf/use-memo (mf/deps (:id file)) #(file-typography-ref (:id file)))
+ typographies (apply-filters (mf/deref typography-ref) filters @reverse-sort?)
+
+ media-ref (mf/use-memo (mf/deps (:id file)) #(file-media-ref (:id file)))
+ media (apply-filters (mf/deref media-ref) filters @reverse-sort?)
+
+ components-ref (mf/use-memo (mf/deps (:id file)) #(file-components-ref (:id file)))
+ components (apply-filters (mf/deref components-ref) filters @reverse-sort?)
+
+ toggle-sort
+ (mf/use-callback
+ (fn [event]
+ (swap! reverse-sort? not)))
+
+ toggle-listing
+ (mf/use-callback
+ (fn [event]
+ (swap! listing-thumbs? not)))]
[:div.tool-window
[:div.tool-window-bar.library-bar
@@ -630,15 +961,26 @@
(or (> (count typographies) 0)
(str/empty? (:term filters))))]
[:div.tool-window-content
+ [:div.listing-options
+ [:div.listing-option-btn {:on-click toggle-sort}
+ (if @reverse-sort?
+ i/sort-descending
+ i/sort-ascending)]
+ [:div.listing-option-btn {:on-click toggle-listing}
+ (if @listing-thumbs?
+ i/listing-thumbs
+ i/listing-thumbs)]]
(when show-components?
[:& components-box {:file-id (:id file)
:local? local?
:components components
+ :listing-thumbs? listing-thumbs?
:open? (open-box? :components)}])
(when show-graphics?
[:& graphics-box {:file-id (:id file)
:local? local?
:objects media
+ :listing-thumbs? listing-thumbs?
:open? (open-box? :graphics)}])
(when show-colors?
[:& colors-box {:file-id (:id file)
@@ -656,8 +998,8 @@
:open? (open-box? :typographies)}])
(when (and (not show-components?) (not show-graphics?) (not show-colors?))
- [:div.asset-group
- [:div.group-title (t locale "workspace.assets.not-found")]])]))]))
+ [:div.asset-section
+ [:div.asset-title (t locale "workspace.assets.not-found")]])]))]))
(mf/defc assets-toolbox
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index 06bd26481..7067eb619 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -780,6 +780,10 @@ msgstr "Confirm password"
msgid "labels.content"
msgstr "Content"
+#: src/app/main/ui/workspace/sidebar/assets.cljs
+msgid "labels.create"
+msgstr "Create"
+
#: src/app/main/ui/dashboard/team_form.cljs, src/app/main/ui/dashboard/team_form.cljs
msgid "labels.create-team"
msgstr "Create new team"
@@ -1439,6 +1443,22 @@ msgstr "File library"
msgid "workspace.assets.graphics"
msgstr "Graphics"
+#: src/app/main/ui/workspace/sidebar/assets.cljs
+msgid "workspace.assets.group"
+msgstr "Group"
+
+#: src/app/main/ui/workspace/sidebar/assets.cljs
+msgid "workspace.assets.create-group"
+msgstr "Create a group"
+
+#: src/app/main/ui/workspace/sidebar/assets.cljs
+msgid "workspace.assets.group-name"
+msgstr "Group name"
+
+#: src/app/main/ui/workspace/sidebar/assets.cljs
+msgid "workspace.assets.create-group-hint"
+msgstr "Your items are going to be named automatically as \"group name / item name\""
+
#: src/app/main/ui/workspace/sidebar/assets.cljs
msgid "workspace.assets.libraries"
msgstr "Libraries"
@@ -2437,4 +2457,4 @@ msgid "workspace.updates.update"
msgstr "Update"
msgid "workspace.viewport.click-to-close-path"
-msgstr "Click to close the path"
\ No newline at end of file
+msgstr "Click to close the path"
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index 52d06a8b1..a20c36d5b 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -776,6 +776,10 @@ msgstr "Confirmar contraseña"
msgid "labels.content"
msgstr "Contenido"
+#: src/app/main/ui/workspace/sidebar/assets.cljs
+msgid "labels.create"
+msgstr "Crear"
+
#: src/app/main/ui/dashboard/team_form.cljs, src/app/main/ui/dashboard/team_form.cljs
msgid "labels.create-team"
msgstr "Crea un nuevo equipo"
@@ -1419,6 +1423,22 @@ msgstr "Biblioteca del archivo"
msgid "workspace.assets.graphics"
msgstr "Gráficos"
+#: src/app/main/ui/workspace/sidebar/assets.cljs
+msgid "workspace.assets.group"
+msgstr "Agrupar"
+
+#: src/app/main/ui/workspace/sidebar/assets.cljs
+msgid "workspace.assets.create-group"
+msgstr "Crear un grupo"
+
+#: src/app/main/ui/workspace/sidebar/assets.cljs
+msgid "workspace.assets.group-name"
+msgstr "Nombre del grupo"
+
+#: src/app/main/ui/workspace/sidebar/assets.cljs
+msgid "workspace.assets.create-group-hint"
+msgstr "Tus elementos se renombrarán automáticamente a \"nombre grupo / nombre elemento\""
+
#: src/app/main/ui/workspace/sidebar/assets.cljs
msgid "workspace.assets.libraries"
msgstr "Bibliotecas"
@@ -2419,4 +2439,4 @@ msgid "workspace.updates.update"
msgstr "Actualizar"
msgid "workspace.viewport.click-to-close-path"
-msgstr "Pulsar para cerrar la ruta"
\ No newline at end of file
+msgstr "Pulsar para cerrar la ruta"