From c9b61745a01bdbe9459118208bdd5839a0989893 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Tue, 23 Sep 2025 11:31:57 +0200 Subject: [PATCH] :tada: Switch several variant copies at the same time --- CHANGES.md | 1 + common/src/app/common/types/variant.cljc | 9 -- .../src/app/main/data/workspace/variants.cljs | 56 +++++++++ .../src/app/main/ui/ds/controls/select.cljs | 7 +- .../src/app/main/ui/ds/controls/select.mdx | 14 ++- .../ds/controls/shared/options_dropdown.cljs | 2 +- .../sidebar/options/menus/component.cljs | 118 +++++++++--------- frontend/translations/en.po | 5 +- frontend/translations/es.po | 5 +- 9 files changed, 137 insertions(+), 80 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 7dcca70633..0e29377db9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,7 @@ ### :sparkles: New features & Enhancements - Show current Penpot version [Taiga #11603](https://tree.taiga.io/project/penpot/us/11603) +- Switch several variant copies at the same time [Taiga #11411](https://tree.taiga.io/project/penpot/us/11411) ### :bug: Bugs fixed diff --git a/common/src/app/common/types/variant.cljc b/common/src/app/common/types/variant.cljc index abad9f4b5f..318af79ffb 100644 --- a/common/src/app/common/types/variant.cljc +++ b/common/src/app/common/types/variant.cljc @@ -50,7 +50,6 @@ (def property-max-length 60) (def value-prefix "Value ") - (defn properties-to-name "Transform the properties into a name, with the values separated by comma" [properties] @@ -59,7 +58,6 @@ (remove str/empty?) (str/join ", "))) - (defn next-property-number "Returns the next property number, to avoid duplicates on the property names" [properties] @@ -100,7 +98,6 @@ remaining (drop (count properties) cpath)] (add-new-props assigned remaining)))) - (defn properties-map->formula "Transforms a map of properties to a formula of properties omitting the empty ones" [properties] @@ -110,7 +107,6 @@ (str name "=" value)))) (str/join ", "))) - (defn properties-formula->map "Transforms a formula of properties to a map of properties" [s] @@ -121,7 +117,6 @@ {:name (str/trim k) :value (str/trim v)})))) - (defn valid-properties-formula? "Checks if a formula is valid" [s] @@ -138,21 +133,18 @@ (let [upd-names (set (map :name upd-props))] (filterv #(not (contains? upd-names (:name %))) prev-props))) - (defn find-properties-to-update "Compares two property maps to find which properties should be updated" [prev-props upd-props] (filterv #(some (fn [prop] (and (= (:name %) (:name prop)) (not= (:value %) (:value prop)))) prev-props) upd-props)) - (defn find-properties-to-add "Compares two property maps to find which properties should be added" [prev-props upd-props] (let [prev-names (set (map :name prev-props))] (filterv #(not (contains? prev-names (:name %))) upd-props))) - (defn- split-base-name-and-number "Extract the number in parentheses from an item, if present, and return both the base name and the number" [item] @@ -192,7 +184,6 @@ :value (:value prop)})) []))) - (defn find-index-for-property-name "Finds the index of a name in a property map" [props name] diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index 33b11396ce..b918faf9c4 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -7,6 +7,7 @@ (ns app.main.data.workspace.variants (:require [app.common.data :as d] + [app.common.data.macros :as dm] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.variant :as cfv] @@ -16,6 +17,7 @@ [app.common.types.color :as clr] [app.common.types.component :as ctc] [app.common.types.components-list :as ctkl] + [app.common.types.file :as ctf] [app.common.types.shape.layout :as ctsl] [app.common.types.variant :as ctv] [app.common.uuid :as uuid] @@ -653,3 +655,57 @@ (let [selected (dsh/lookup-selected state)] (rx/of (combine-as-variants selected options)))))) +(defn- variant-switch + "Switch the shape (that must be a variant copy head) for the closest one with the property value passed as parameter" + [shape {:keys [pos val] :as params}] + (ptk/reify ::variant-switch + ptk/WatchEvent + (watch [_ state _] + (let [libraries (dsh/lookup-libraries state) + component-id (:component-id shape) + component (ctf/get-component libraries (:component-file shape) component-id :include-deleted? false)] + ;; If the value is already val, do nothing + (when (not= val (dm/get-in component [:variant-properties pos :value])) + (let [current-page-objects (dsh/lookup-page-objects state) + variant-id (:variant-id component) + component-file-data (dm/get-in libraries [(:component-file shape) :data]) + component-page-objects (-> (dsh/get-page component-file-data (:main-instance-page component)) + (get :objects)) + variant-comps (cfv/find-variant-components component-file-data component-page-objects variant-id) + target-props (-> (:variant-properties component) + (update pos assoc :value val)) + valid-comps (->> variant-comps + (remove #(= (:id %) component-id)) + (filter #(= (dm/get-in % [:variant-properties pos :value]) val)) + (reverse)) + nearest-comp (apply min-key #(ctv/distance target-props (:variant-properties %)) valid-comps) + shape-parents (cfh/get-parents-with-self current-page-objects (:parent-id shape)) + nearest-comp-children (cfh/get-children-with-self component-page-objects (:main-instance-id nearest-comp)) + comps-nesting-loop? (seq? (cfh/components-nesting-loop? nearest-comp-children shape-parents)) + + {:keys [on-error] + :or {on-error rx/throw}} (meta params)] + + ;; If there is no nearest-comp, do nothing + (when nearest-comp + (if comps-nesting-loop? + (do + (on-error) + (rx/empty)) + (rx/of (dwl/component-swap shape (:component-file shape) (:id nearest-comp) true)))))))))) + +(defn variants-switch + "Switch each shape (that must be a variant copy head) for the closest one with the property value passed as parameter" + [{:keys [shapes] :as params}] + (ptk/reify ::variants-switch + ptk/WatchEvent + (watch [_ _ _] + (let [ids (into (d/ordered-set) d/xf:map-id shapes) + undo-id (js/Symbol)] + (rx/concat + (rx/of (dwu/start-undo-transaction undo-id)) + (->> (rx/from shapes) + (rx/map #(variant-switch % params))) + (rx/of (dwu/commit-undo-transaction undo-id) + (dws/select-shapes ids))))))) + diff --git a/frontend/src/app/main/ui/ds/controls/select.cljs b/frontend/src/app/main/ui/ds/controls/select.cljs index cb1e9ec135..20dcabc546 100644 --- a/frontend/src/app/main/ui/ds/controls/select.cljs +++ b/frontend/src/app/main/ui/ds/controls/select.cljs @@ -183,7 +183,10 @@ (get selected-option :icon) has-icon? - (some? icon)] + (some? icon) + + dimmed? + (:dimmed selected-option)] (mf/with-effect [options] (mf/set-ref-val! options-ref options)) @@ -201,7 +204,7 @@ :size "s" :aria-hidden true}]) [:span {:class (stl/css-case :header-label true - :header-label-dimmed empty-selected-id?)} + :header-label-dimmed (or empty-selected-id? dimmed?))} (if ^boolean empty-selected-id? "--" label)]] [:> icon* {:icon-id i/arrow-down diff --git a/frontend/src/app/main/ui/ds/controls/select.mdx b/frontend/src/app/main/ui/ds/controls/select.mdx index 0ecdf1e7af..d51bc592a2 100644 --- a/frontend/src/app/main/ui/ds/controls/select.mdx +++ b/frontend/src/app/main/ui/ds/controls/select.mdx @@ -34,6 +34,9 @@ If we consider that empty options have a special meaning, we can move them to th Each option of `select*` may accept an `icon`, which must contain an [icon ID](../foundations/assets/icon.mdx). These are available in the `app.main.ds.foundations.assets.icon` namespace. +### Dimmed +Each option can have an optional parameter `dimmed` with value `true` to show the option dimmed + ```clj (ns app.main.ui.foo @@ -42,7 +45,7 @@ These are available in the `app.main.ds.foundations.assets.icon` namespace. ``` ```clj -[:> select* +[:> select* {:options [{ :label "Code" :id "option-code" :icon i/fill-content } @@ -50,7 +53,8 @@ These are available in the `app.main.ds.foundations.assets.icon` namespace. :id "option-design" :icon i/pentool } { :label "Menu" - :id "option-menu" } + :id "option-menu" + :dimmed true } ]}] ``` @@ -58,8 +62,8 @@ These are available in the `app.main.ds.foundations.assets.icon` namespace. ### Where to use -Used in a wide range of applications in the app, -to select among available text-based options, +Used in a wide range of applications in the app, +to select among available text-based options, sometimes with icons that offers additional context. ### When to use @@ -68,5 +72,5 @@ Consider using select when you have 5 or more options to choose from. ### Interaction / Behavior -When the user clicks on the clickable area, a list of +When the user clicks on the clickable area, a list of options appears. When an option is chosen, the list is closed. \ No newline at end of file diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs index 3a7bef9cac..d604b60319 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs @@ -95,7 +95,7 @@ :icon (get option :icon) :ref ref :focused (= id focused) - :dimmed false + :dimmed (true? (:dimmed option)) :on-click on-click}])))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 830b364c69..0065e29507 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -397,15 +397,16 @@ (str duplicated-msg)]]))])) (mf/defc component-variant-copy* - [{:keys [component shape data current-file-id]}] - (let [page-objects (mf/deref refs/workspace-page-objects) - component-id (:id component) - properties (:variant-properties component) + [{:keys [components shapes component-file-data current-file-id]}] + (let [component (first components) + shape (first shapes) + properties (map :variant-properties components) + props-first (:variant-properties component) variant-id (:variant-id component) - objects (-> (dsh/get-page data (:main-instance-page component)) - (get :objects)) - variant-comps (mf/with-memo [data objects variant-id] - (cfv/find-variant-components data objects variant-id)) + component-page-objects (-> (dsh/get-page component-file-data (:main-instance-page component)) + (get :objects)) + variant-comps (mf/with-memo [component-file-data component-page-objects variant-id] + (cfv/find-variant-components component-file-data component-page-objects variant-id)) duplicated-comps (mf/with-memo [variant-comps] (->> variant-comps @@ -414,11 +415,11 @@ malformed-comps (mf/with-memo [variant-comps] (->> variant-comps (filter #(->> (:main-instance-id %) - (get objects) + (get component-page-objects) :variant-error)))) - prop-vals (mf/with-memo [data objects variant-id] - (cfv/extract-properties-values data objects variant-id)) + prop-vals (mf/with-memo [component-file-data component-page-objects variant-id] + (cfv/extract-properties-values component-file-data component-page-objects variant-id)) get-options (mf/use-fn @@ -449,47 +450,45 @@ ;; Used to force a remount after an error key* (mf/use-state (uuid/next)) key (deref key*) + mixed-label (tr "settings.multiple") switch-component (mf/use-fn - (mf/deps shape component component-id variant-comps) + (mf/deps shapes) (fn [pos val] - (when (not= val (dm/get-in component [:variant-properties pos :value])) - (let [target-props (-> (:variant-properties component) - (update pos assoc :value val)) - valid-comps (->> variant-comps - (remove #(= (:id %) component-id)) - (filter #(= (dm/get-in % [:variant-properties pos :value]) val)) - (reverse)) - nearest-comp (apply min-key #(ctv/distance target-props (:variant-properties %)) valid-comps) - parents (cfh/get-parents-with-self page-objects (:parent-id shape)) - children (cfh/get-children-with-self objects (:main-instance-id nearest-comp)) - comps-nesting-loop? (seq? (cfh/components-nesting-loop? children parents))] + (if (= val mixed-label) + (reset! key* (uuid/next)) + (let [error-msg (if (> (count shapes) 1) + (tr "workspace.component.switch.loop-error-multi") + (tr "workspace.component.swap.loop-error")) - (when nearest-comp - (if comps-nesting-loop? - (do - (st/emit! (ntf/error (tr "workspace.component.swap.loop-error"))) - (reset! key* (uuid/next))) - (st/emit! (dwl/component-swap shape (:component-file shape) (:id nearest-comp) true))))))))] + mdata {:on-error #(do + (reset! key* (uuid/next)) + (st/emit! (ntf/error error-msg)))} + params {:shapes shapes :pos pos :val val}] + (st/emit! (dwv/variants-switch (with-meta params mdata)))))))] [:* [:div {:class (stl/css :variant-property-list)} - (for [[pos prop] (map vector (range) properties)] - [:div {:key (str (:id shape) pos) - :class (stl/css :variant-property-container)} + (for [[pos prop] (map vector (range) props-first)] + (let [mixed-value? (not-every? #(= (:value prop) (:value (nth % pos))) properties) + options (cond-> (get-options (:name prop)) + mixed-value? + (conj {:id mixed-label, :label mixed-label :dimmed true}))] + [:div {:key (str pos mixed-value?) + :class (stl/css :variant-property-container)} - [:div {:class (stl/css :variant-property-name-wrapper) - :title (:name prop)} - [:div {:class (stl/css :variant-property-name)} - (:name prop)]] + [:div {:class (stl/css :variant-property-name-wrapper) + :title (:name prop)} + [:div {:class (stl/css :variant-property-name)} + (:name prop)]] - [:div {:class (stl/css :variant-property-value-wrapper)} - [:> select* {:default-selected (:value prop) - :options (get-options (:name prop)) - :empty-to-end true - :on-change (partial switch-component pos) - :key (str (:value prop) "-" key)}]]])] + [:div {:class (stl/css :variant-property-value-wrapper)} + [:> select* {:default-selected (if mixed-value? mixed-label (:value prop)) + :options options + :empty-to-end true + :on-change (partial switch-component pos) + :key (str (:value prop) "-" key)}]]]))] (if (seq malformed-comps) [:div {:class (stl/css :variant-warning-wrapper)} @@ -832,26 +831,23 @@ all-main? (every? ctk/main-instance? shapes) any-variant? (some ctk/is-variant? shapes) - ;; For when it's only one shape - shape (first shapes) - id (:id shape) - shape-name (:name shape) - - component (ctf/resolve-component shape - current-file - libraries - {:include-deleted? true}) - data (dm/get-in libraries [(:component-file shape) :data]) - is-variant? (ctk/is-variant? component) - - main-instance? (ctk/main-instance? shape) - components (mapv #(ctf/resolve-component % current-file libraries {:include-deleted? true}) shapes) same-variant? (ctv/same-variant? components) + ;; For when it's only one shape + shape (first shapes) + id (:id shape) + shape-name (:name shape) + + component (first components) + data (dm/get-in libraries [(:component-file shape) :data]) + is-variant? (ctk/is-variant? component) + + main-instance? (ctk/main-instance? shape) + toggle-content (mf/use-fn #(swap! state* update :show-content not)) @@ -985,7 +981,7 @@ (tr "settings.multiple") (cfh/last-path shape-name))]] - (when (and can-swap? (not multi)) + (when (and can-swap? (or (not multi) same-variant?)) [:div {:class (stl/css :component-parent-name)} (if (:deleted component) (tr "workspace.options.component.unlinked") @@ -1016,11 +1012,11 @@ (not main-instance?) (not (:deleted component)) (not swap-opened?) - (not multi)) + (or (not multi) same-variant?)) [:> component-variant-copy* {:current-file-id current-file-id - :component component - :shape shape - :data data}]) + :components components + :shapes shapes + :component-file-data data}]) (when (and is-variant? main-instance? same-variant? (not swap-opened?)) [:> component-variant-main-instance* {:components components diff --git a/frontend/translations/en.po b/frontend/translations/en.po index aeb26299b5..ce5c593e57 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -5627,7 +5627,10 @@ msgid "workspace.options.component.swap.empty" msgstr "There are no assets in this library yet" msgid "workspace.component.swap.loop-error" -msgstr "Components can't be nested inside themselves" +msgstr "Components can't be nested inside themselves." + +msgid "workspace.component.switch.loop-error-multi" +msgstr "Some copies could not be switched. Components can't be nested inside themselves." #: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:973 msgid "workspace.options.component.unlinked" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 04138d226b..036b2a710a 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -5114,7 +5114,10 @@ msgid "workspace.assets.ungroup" msgstr "Desagrupar" msgid "workspace.component.swap.loop-error" -msgstr "Los componentes no pueden anidarse dentro de sí mismos" +msgstr "Los componentes no pueden anidarse dentro de sí mismos." + +msgid "workspace.component.switch.loop-error-multi" +msgstr "Algunas copias no se han podido intercambiar. Los componentes no pueden anidarse dentro de sí mismos." #: src/app/main/ui/workspace/context_menu.cljs:791 msgid "workspace.context-menu.grid-cells.area"