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"