mirror of
https://github.com/penpot/penpot.git
synced 2025-12-11 22:14:05 +01:00
🎉 Switch several variant copies at the same time
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)))))))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
]}]
|
||||
```
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
:icon (get option :icon)
|
||||
:ref ref
|
||||
:focused (= id focused)
|
||||
:dimmed false
|
||||
:dimmed (true? (:dimmed option))
|
||||
:on-click on-click}]))))
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
component-page-objects (-> (dsh/get-page component-file-data (:main-instance-page component))
|
||||
(get :objects))
|
||||
variant-comps (mf/with-memo [data objects variant-id]
|
||||
(cfv/find-variant-components data objects variant-id))
|
||||
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,34 +450,32 @@
|
||||
;; 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)
|
||||
(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)
|
||||
@@ -485,11 +484,11 @@
|
||||
(:name prop)]]
|
||||
|
||||
[:div {:class (stl/css :variant-property-value-wrapper)}
|
||||
[:> select* {:default-selected (:value prop)
|
||||
:options (get-options (:name prop))
|
||||
[:> 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)}]]])]
|
||||
: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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user