🎉 Switch several variant copies at the same time

This commit is contained in:
Pablo Alba
2025-09-23 11:31:57 +02:00
committed by GitHub
parent 974b76d7bd
commit c9b61745a0
9 changed files with 137 additions and 80 deletions

View File

@@ -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

View File

@@ -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]

View File

@@ -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)))))))

View File

@@ -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

View File

@@ -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.

View File

@@ -95,7 +95,7 @@
:icon (get option :icon)
:ref ref
:focused (= id focused)
:dimmed false
:dimmed (true? (:dimmed option))
:on-click on-click}]))))

View File

@@ -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

View File

@@ -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"

View File

@@ -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"