diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 595f99caa0..8b7831d5ab 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -347,6 +347,22 @@ (+ pad-top pad-top) (+ pad-top pad-bottom)))) +(defn padding-type-for + "`:simple` when top≈bottom and left≈right, `:multiple` otherwise (nil sides = 0)." + [{:keys [p1 p2 p3 p4]}] + (if (and (mth/close? (d/nilv p1 0) (d/nilv p3 0)) + (mth/close? (d/nilv p2 0) (d/nilv p4 0))) + :simple + :multiple)) + +(defn margin-type-for + "`:simple` when top≈bottom and left≈right, `:multiple` otherwise (nil sides = 0)." + [{:keys [m1 m2 m3 m4]}] + (if (and (mth/close? (d/nilv m1 0) (d/nilv m3 0)) + (mth/close? (d/nilv m2 0) (d/nilv m4 0))) + :simple + :multiple)) + (defn child-min-width [child] (if (and (fill-width? child) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index 654eccaf4c..70050dd38e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -1213,6 +1213,10 @@ (and (= type :simple) (or (= prop :p2) (= prop #{:p2 :p4}))) (st/emit! (dwsl/update-layout ids {:layout-padding {:p2 val :p4 val}})) + (and (= type :multiple) (some? prop)) + (st/emit! (dwsl/update-layout ids {:layout-padding-type :multiple + :layout-padding {prop val}})) + (some? prop) (st/emit! (dwsl/update-layout ids {:layout-padding {prop val}})))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs index 565c368611..f20dc82caa 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs @@ -12,6 +12,7 @@ [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] [app.common.geom.shapes :as gsh] + [app.common.math :as mth] [app.common.types.component :as ctk] [app.common.types.path :as path] [app.common.types.shape.attrs :refer [editable-attrs]] @@ -208,6 +209,59 @@ [v] (when v (select-keys v blur-keys))) +(def layout-padding-attrs [:p1 :p2 :p3 :p4]) + +(defn- normalize-layout-padding + [value] + (if (= value :multiple) + (zipmap layout-padding-attrs (repeat :multiple)) + value)) + +(defn- merge-layout-padding + [values shape-values] + (let [current (normalize-layout-padding (get values :layout-padding ::unset)) + next (normalize-layout-padding (get shape-values :layout-padding ::unset))] + (cond + (= current ::unset) next + (= next ::unset) current + + (and (map? current) (map? next)) + (attrs/get-attrs-multi [current next] layout-padding-attrs) + + (= current next) + current + + :else + :multiple))) + +(defn- same-padding-value? + [v1 v2] + (if (and (number? v1) (number? v2)) + (mth/close? v1 v2) + (= v1 v2))) + +(defn- simple-layout-padding? + [{:keys [p1 p2 p3 p4]}] + (and (same-padding-value? p1 p3) + (same-padding-value? p2 p4))) + +(defn- promote-simple-layout-padding-type + [{:keys [layout-padding layout-padding-type] :as values}] + (cond-> values + (and (= layout-padding-type :simple) + (map? layout-padding) + (not (simple-layout-padding? layout-padding))) + (assoc :layout-padding-type :multiple))) + +(defn- merge-layout-container-attrs + [values shape-values attrs] + (let [merged-values (attrs/get-attrs-multi [values shape-values] attrs) + merged-values (cond-> merged-values + (or (contains? values :layout-padding) + (contains? shape-values :layout-padding)) + (assoc :layout-padding (merge-layout-padding values shape-values)))] + (promote-simple-layout-padding-type merged-values))) + (defn get-attrs* "Given a group of attributes that we want to extract and the shapes to extract them from returns a list of tuples [id, values] with the extracted properties for the shapes that @@ -217,9 +271,10 @@ merge-attrs (fn [v1 v2] (cond - (= attr-group :shadow) (attrs/get-attrs-multi [v1 v2] attrs shadow-eq shadow-sel) - (= attr-group :blur) (attrs/get-attrs-multi [v1 v2] attrs blur-eq blur-sel) - :else (attrs/get-attrs-multi [v1 v2] attrs))) + (= attr-group :shadow) (attrs/get-attrs-multi [v1 v2] attrs shadow-eq shadow-sel) + (= attr-group :blur) (attrs/get-attrs-multi [v1 v2] attrs blur-eq blur-sel) + (= attr-group :layout-container) (merge-layout-container-attrs v1 v2 attrs) + :else (attrs/get-attrs-multi [v1 v2] attrs))) merge-attr (fn [acc applied-tokens t-attr] diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs index ff1ec594a7..0967edcbec 100644 --- a/frontend/src/app/plugins/flex.cljs +++ b/frontend/src/app/plugins/flex.cljs @@ -20,6 +20,20 @@ ;; Define in `app.plugins.shape` we do this way to prevent circular dependency (def shape-proxy? nil) +(defn- update-padding + "Patch the layout padding and re-derive its `:simple`/`:multiple` type." + [this id patch] + (let [padding (merge (-> this u/proxy->shape :layout-padding) patch)] + (st/emit! (dwsl/update-layout #{id} {:layout-padding patch + :layout-padding-type (ctl/padding-type-for padding)})))) + +(defn- update-margin + "Patch the item margin and re-derive its `:simple`/`:multiple` type." + [this id patch] + (let [margin (merge (-> this u/proxy->shape :layout-item-margin) patch)] + (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin patch + :layout-item-margin-type (ctl/margin-type-for margin)})))) + (defn flex-layout-proxy? [p] (obj/type-of? p "FlexLayoutProxy")) @@ -185,7 +199,7 @@ {:this true :get #(-> % u/proxy->shape :layout-padding :p1 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :verticalPadding value) @@ -197,13 +211,13 @@ (u/not-valid plugin-id :verticalPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))} + (update-padding this id {:p1 value :p3 value})))} :horizontalPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p2 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :horizontalPadding value) @@ -215,13 +229,13 @@ (u/not-valid plugin-id :horizontalPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))} + (update-padding this id {:p2 value :p4 value})))} :topPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p1 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :topPadding value) @@ -233,13 +247,13 @@ (u/not-valid plugin-id :topPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))} + (update-padding this id {:p1 value})))} :rightPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p2 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :rightPadding value) @@ -251,13 +265,13 @@ (u/not-valid plugin-id :rightPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))} + (update-padding this id {:p2 value})))} :bottomPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p3 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :bottomPadding value) @@ -269,13 +283,13 @@ (u/not-valid plugin-id :bottomPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))} + (update-padding this id {:p3 value})))} :leftPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p4 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :leftPadding value) @@ -287,7 +301,27 @@ (u/not-valid plugin-id :leftPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))} + (update-padding this id {:p4 value})))} + + ;; `:simple` mirrors vertical/horizontal padding; `:multiple` honours each side. + :paddingType + {:this true + :get #(-> % u/proxy->shape :layout-padding-type (d/nilv :simple) d/name) + :set + (fn [_ value] + (let [value (keyword value)] + (cond + (not (contains? ctl/padding-type value)) + (u/not-valid plugin-id :paddingType value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :paddingType "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :paddingType "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwsl/update-layout #{id} {:layout-padding-type value})))))} :remove (fn [] @@ -469,7 +503,7 @@ {:this true :get #(-> % u/proxy->shape :layout-item-margin :m1 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :verticalMargin value) @@ -481,13 +515,13 @@ (u/not-valid plugin-id :verticalMargin "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value :m3 value}}))))} + (update-margin this id {:m1 value :m3 value})))} :horizontalMargin {:this true :get #(-> % u/proxy->shape :layout-item-margin :m2 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :horizontalMargin value) @@ -499,13 +533,13 @@ (u/not-valid plugin-id :horizontalMargin "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value :m4 value}}))))} + (update-margin this id {:m2 value :m4 value})))} :topMargin {:this true :get #(-> % u/proxy->shape :layout-item-margin :m1 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :topMargin value) @@ -517,13 +551,13 @@ (u/not-valid plugin-id :topMargin "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value}}))))} + (update-margin this id {:m1 value})))} :rightMargin {:this true :get #(-> % u/proxy->shape :layout-item-margin :m2 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :rightMargin value) @@ -535,13 +569,13 @@ (u/not-valid plugin-id :rightMargin "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value}}))))} + (update-margin this id {:m2 value})))} :bottomMargin {:this true :get #(-> % u/proxy->shape :layout-item-margin :m3 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :bottomMargin value) @@ -553,13 +587,13 @@ (u/not-valid plugin-id :bottomMargin "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m3 value}}))))} + (update-margin this id {:m3 value})))} :leftMargin {:this true :get #(-> % u/proxy->shape :layout-item-margin :m4 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :leftMargin value) @@ -571,7 +605,27 @@ (u/not-valid plugin-id :leftMargin "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m4 value}}))))} + (update-margin this id {:m4 value})))} + + ;; `:simple` mirrors vertical/horizontal margin; `:multiple` honours each side. + :marginType + {:this true + :get #(-> % u/proxy->shape :layout-item-margin-type (d/nilv :simple) d/name) + :set + (fn [_ value] + (let [value (keyword value)] + (cond + (not (contains? ctl/item-margin-types value)) + (u/not-valid plugin-id :marginType value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :marginType "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :marginType "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin-type value})))))} :maxWidth {:this true diff --git a/frontend/src/app/plugins/grid.cljs b/frontend/src/app/plugins/grid.cljs index e0122062db..96deccb857 100644 --- a/frontend/src/app/plugins/grid.cljs +++ b/frontend/src/app/plugins/grid.cljs @@ -21,6 +21,13 @@ ;; Define in `app.plugins.shape` we do this way to prevent circular dependency (def shape-proxy? nil) +(defn- update-padding + "Patch the layout padding and re-derive its `:simple`/`:multiple` type." + [this id patch] + (let [padding (merge (-> this u/proxy->shape :layout-padding) patch)] + (st/emit! (dwsl/update-layout #{id} {:layout-padding patch + :layout-padding-type (ctl/padding-type-for padding)})))) + (defn grid-layout-proxy? [p] (obj/type-of? p "GridLayoutProxy")) @@ -217,7 +224,7 @@ {:this true :get #(-> % u/proxy->shape :layout-padding :p1 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :verticalPadding value) @@ -229,13 +236,13 @@ (u/not-valid plugin-id :verticalPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))} + (update-padding this id {:p1 value :p3 value})))} :horizontalPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p2 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :horizontalPadding value) @@ -247,13 +254,13 @@ (u/not-valid plugin-id :horizontalPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))} + (update-padding this id {:p2 value :p4 value})))} :topPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p1 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :topPadding value) @@ -265,13 +272,13 @@ (u/not-valid plugin-id :topPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))} + (update-padding this id {:p1 value})))} :rightPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p2 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :rightPadding value) @@ -283,13 +290,13 @@ (u/not-valid plugin-id :rightPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))} + (update-padding this id {:p2 value})))} :bottomPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p3 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :bottomPadding value) @@ -301,13 +308,13 @@ (u/not-valid plugin-id :bottomPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))} + (update-padding this id {:p3 value})))} :leftPadding {:this true :get #(-> % u/proxy->shape :layout-padding :p4 (d/nilv 0)) :set - (fn [_ value] + (fn [this value] (cond (not (sm/valid-safe-number? value)) (u/not-valid plugin-id :leftPadding value) @@ -319,7 +326,27 @@ (u/not-valid plugin-id :leftPadding "Cannot modify a page that is not currently active") :else - (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))} + (update-padding this id {:p4 value})))} + + ;; `:simple` mirrors vertical/horizontal padding; `:multiple` honours each side. + :paddingType + {:this true + :get #(-> % u/proxy->shape :layout-padding-type (d/nilv :simple) d/name) + :set + (fn [_ value] + (let [value (keyword value)] + (cond + (not (contains? ctl/padding-type value)) + (u/not-valid plugin-id :paddingType value) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :paddingType "Plugin doesn't have 'content:write' permission") + + (not (u/page-active? page-id)) + (u/not-valid plugin-id :paddingType "Cannot modify a page that is not currently active") + + :else + (st/emit! (dwsl/update-layout #{id} {:layout-padding-type value})))))} :addRow (fn [type value] diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 6f92bff904..b6f1a32a80 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -56,6 +56,7 @@ [frontend-tests.ui.comments-clustering-test] [frontend-tests.ui.comments-position-modifier-test] [frontend-tests.ui.ds-controls-numeric-input-test] + [frontend-tests.ui.layout-container-multiple-test] [frontend-tests.ui.measures-menu-props-test] [frontend-tests.util-object-test] [frontend-tests.util-range-tree-test] @@ -128,6 +129,7 @@ 'frontend-tests.ui.comments-clustering-test 'frontend-tests.ui.comments-position-modifier-test 'frontend-tests.ui.ds-controls-numeric-input-test + 'frontend-tests.ui.layout-container-multiple-test 'frontend-tests.ui.measures-menu-props-test 'frontend-tests.util-object-test 'frontend-tests.util-range-tree-test diff --git a/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs b/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs new file mode 100644 index 0000000000..19c4278057 --- /dev/null +++ b/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs @@ -0,0 +1,46 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.ui.layout-container-multiple-test + (:require + [app.main.ui.workspace.sidebar.options.shapes.multiple :as multiple] + [cljs.test :as t :include-macros true])) + +(defn- flex-frame + [id padding-type padding] + {:id id + :type :frame + :layout :flex + :layout-flex-dir :row + :layout-gap-type :multiple + :layout-gap {:row-gap 0 :column-gap 0} + :layout-align-items :start + :layout-justify-content :start + :layout-align-content :stretch + :layout-wrap-type :nowrap + :layout-padding-type padding-type + :layout-padding padding}) + +(defn- layout-container-values + [shapes] + (let [[_ids values _tokens] (multiple/get-attrs* shapes {} :layout-container)] + values)) + +(t/deftest multiple-selection-padding-keeps-matching-sides + (let [values (layout-container-values + [(flex-frame :frame-1 :simple {:p1 10 :p2 20 :p3 10 :p4 20}) + (flex-frame :frame-2 :simple {:p1 10 :p2 20 :p3 30 :p4 40})])] + (t/is (= {:p1 10 :p2 20 :p3 :multiple :p4 :multiple} + (:layout-padding values))) + (t/is (= :multiple (:layout-padding-type values))))) + +(t/deftest multiple-selection-padding-type-does-not-demote + (let [values (layout-container-values + [(flex-frame :frame-1 :multiple {:p1 10 :p2 20 :p3 10 :p4 20}) + (flex-frame :frame-2 :multiple {:p1 10 :p2 20 :p3 10 :p4 20})])] + (t/is (= {:p1 10 :p2 20 :p3 10 :p4 20} + (:layout-padding values))) + (t/is (= :multiple (:layout-padding-type values))))) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 80dee3f1c9..323e8319ab 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -1,4 +1,14 @@ -## 1.5.0 (Unreleased) +## 1.6.0 (Unreleased) + +### 🚀 Features + +- **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. + +### 🩹 Fixes + +- **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again. + +## 1.5.0 (2026-07-08) ### 💣 Breaking changes & Deprecations diff --git a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json index 69dc9d5546..f61aac311a 100644 --- a/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json +++ b/plugins/apps/plugin-api-test-suite/src/generated/api-surface.json @@ -66,6 +66,7 @@ "justifyContent", "justifyItems", "leftPadding", + "paddingType", "remove", "rightPadding", "rowGap", @@ -254,6 +255,7 @@ "horizontalMargin", "horizontalSizing", "leftMargin", + "marginType", "maxHeight", "maxWidth", "minHeight", @@ -2032,6 +2034,12 @@ "type": null, "array": false }, + "paddingType": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, "horizontalSizing": { "decl": "CommonLayout", "kind": "getset", @@ -3242,6 +3250,12 @@ "type": null, "array": false }, + "paddingType": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, "horizontalSizing": { "decl": "CommonLayout", "kind": "getset", @@ -3590,6 +3604,12 @@ "type": null, "array": false }, + "paddingType": { + "decl": "CommonLayout", + "kind": "getset", + "type": null, + "array": false + }, "horizontalSizing": { "decl": "CommonLayout", "kind": "getset", @@ -4868,6 +4888,12 @@ "type": null, "array": false }, + "marginType": { + "decl": "LayoutChildProperties", + "kind": "getset", + "type": null, + "array": false + }, "maxWidth": { "decl": "LayoutChildProperties", "kind": "getset", diff --git a/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts index b42cb65fab..867132714e 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/layout.test.ts @@ -82,6 +82,34 @@ describe('Layout', () => { expect(flex.leftPadding).toBeCloseTo(4.5, 2); }); + // paddingType is "simple" (sides mirrored) or "multiple" (each side independent). + test('paddingType round-trips', (ctx) => { + const flex = board(ctx).addFlexLayout(); + expect(flex.paddingType).toBe('simple'); + flex.paddingType = 'multiple'; + expect(flex.paddingType).toBe('multiple'); + flex.paddingType = 'simple'; + expect(flex.paddingType).toBe('simple'); + }); + + // Issue #10278: a single asymmetric side must switch paddingType to "multiple" so it paints. + test('setting an individual padding side switches paddingType to multiple', (ctx) => { + const flex = board(ctx).addFlexLayout(); + expect(flex.paddingType).toBe('simple'); + flex.leftPadding = 40; + expect(flex.leftPadding).toBeCloseTo(40, 0); + expect(flex.paddingType).toBe('multiple'); + }); + + // Re-derived from the sides: once symmetric again, the type collapses to "simple". + test('padding collapsing back to symmetric restores simple type', (ctx) => { + const flex = board(ctx).addFlexLayout(); + flex.leftPadding = 40; + expect(flex.paddingType).toBe('multiple'); + flex.leftPadding = 0; + expect(flex.paddingType).toBe('simple'); + }); + test('sizing round-trips', (ctx) => { const flex = board(ctx).addFlexLayout(); flex.horizontalSizing = 'fix'; @@ -210,6 +238,22 @@ describe('Layout', () => { expect(grid.leftPadding).toBeCloseTo(4.5, 2); }); + // paddingType behaves the same as on flex layouts (see issue #10278). + test('paddingType round-trips', (ctx) => { + const grid = board(ctx).addGridLayout(); + expect(grid.paddingType).toBe('simple'); + grid.paddingType = 'multiple'; + expect(grid.paddingType).toBe('multiple'); + }); + + test('setting an individual padding side switches paddingType to multiple', (ctx) => { + const grid = board(ctx).addGridLayout(); + expect(grid.paddingType).toBe('simple'); + grid.leftPadding = 40; + expect(grid.leftPadding).toBeCloseTo(40, 0); + expect(grid.paddingType).toBe('multiple'); + }); + // Index boundaries — invalid indices must be rejected. test('addRowAtIndex with a negative index throws', (ctx) => { const grid = board(ctx).addGridLayout(); @@ -389,6 +433,55 @@ describe('Layout', () => { } }); + // marginType is the child-margin counterpart of a layout's paddingType. + test('marginType round-trips', (ctx) => { + const b = board(ctx); + const flex = b.addFlexLayout(); + const rect = ctx.penpot.createRectangle(); + flex.appendChild(rect); + const child = rect.layoutChild; + expect(child).toBeDefined(); + if (child) { + expect(child.marginType).toBe('simple'); + child.marginType = 'multiple'; + expect(child.marginType).toBe('multiple'); + child.marginType = 'simple'; + expect(child.marginType).toBe('simple'); + } + }); + + // Issue #10278 (margins): a single asymmetric side must switch marginType to "multiple". + test('setting an individual margin side switches marginType to multiple', (ctx) => { + const b = board(ctx); + const flex = b.addFlexLayout(); + const rect = ctx.penpot.createRectangle(); + flex.appendChild(rect); + const child = rect.layoutChild; + expect(child).toBeDefined(); + if (child) { + expect(child.marginType).toBe('simple'); + child.leftMargin = 12; + expect(child.leftMargin).toBeCloseTo(12, 0); + expect(child.marginType).toBe('multiple'); + } + }); + + // Symmetric margins collapse the child back to "simple", mirroring padding. + test('margin collapsing back to symmetric restores simple type', (ctx) => { + const b = board(ctx); + const flex = b.addFlexLayout(); + const rect = ctx.penpot.createRectangle(); + flex.appendChild(rect); + const child = rect.layoutChild; + expect(child).toBeDefined(); + if (child) { + child.leftMargin = 12; + expect(child.marginType).toBe('multiple'); + child.leftMargin = 0; + expect(child.marginType).toBe('simple'); + } + }); + // Community report (forum #10700, issue #12): layoutChild was said to be // null for children appended to a flex board that is re-found (fresh // proxy) instead of using the creation-time reference. Did not reproduce; diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 6150e6b136..1dbe005a3e 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -747,6 +747,13 @@ export interface CommonLayout { * The `leftPadding` property specifies the padding at the left of the container. */ leftPadding: number; + /** + * The `paddingType` property specifies how the four padding values are applied. + * It can be one of the following values: + * - 'simple': the vertical and horizontal paddings are mirrored across both sides. + * - 'multiple': each of the four sides (top, right, bottom, left) is honoured independently. + */ + paddingType: 'simple' | 'multiple'; /** * The `horizontalSizing` property specifies the horizontal sizing behavior of the container. @@ -2576,6 +2583,14 @@ export interface LayoutChildProperties { */ leftMargin: number; + /** + * The `marginType` property specifies how the four margin values are applied. + * It can be one of the following values: + * - 'simple': the vertical and horizontal margins are mirrored across both sides. + * - 'multiple': each of the four sides (top, right, bottom, left) is honoured independently. + */ + marginType: 'simple' | 'multiple'; + /** * Defines the maximum width of the child element. * If set to null, there is no maximum width constraint.