From fad083314dc417ef490ba39aa88461def5285452 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Thu, 8 Oct 2026 18:09:06 +0200 Subject: [PATCH] :tada: Expose per-side stroke width in the plugin API (#11994) * :sparkles: Add stroke side-width helpers to common types Add set-width-to-all-sides, set-width-to-single-side and side-width to app.common.types.stroke, mirroring the border-radius helpers in app.common.types.shape.radius. They back the per-side stroke width API and are covered by unit tests. AI-assisted-by: deepseek-v4.1-flash * :sparkles: Round-trip per-side stroke widths through the plugin API Add strokeWidthTop/Right/Bottom/Left to format-stroke and parse-stroke so reading shape.strokes exposes the per-side widths and writing a stroke back preserves them instead of collapsing to the uniform width. parse-stroke prefers a live stroke proxy's hidden raw state when present, so a commit only rewrites the attributes the plugin touched. AI-assisted-by: deepseek-v4.1-flash * :sparkles: Add per-side stroke widths to the plugin API types Add the four per-side members to the Stroke interface and to the borderWidth and dimension token property lists, and refresh the generated API surface used by the plugin test suite. AI-assisted-by: deepseek-v4.1-flash * :sparkles: Expose per-side stroke width on the stroke proxy Add strokeWidthTop/Right/Bottom/Left to StrokeProxy. Reads fall back to the uniform strokeWidth; strokeWidth now writes all four sides, like borderRadius writes all four corners; each side setter writes only its own key and the top side also updates strokeWidth. Reject per-side writes on shapes other than boards and rectangles with a validation error naming the property, and expose the raw stroke map through a hidden proxy member so a commit preserves untouched attributes. AI-assisted-by: deepseek-v4.1-flash * :sparkles: Support stroke width tokens per side in the plugin API Token attribute expansion now accepts the uniform strokeWidth property and expands it to the four per-side attributes, mirroring the design tab. shape.tokens exposes the per-side keys and reports strokeWidth when all four sides share one token, and applyToken routes through the shared expander. AI-assisted-by: deepseek-v4.1-flash * :books: Document per-side stroke width in the plugins changelog Note the new Stroke members, the restored strokeWidth token property and the shape-type validation in plugins/CHANGELOG.md. AI-assisted-by: deepseek-v4.1-flash * :bug: Fix some minor issues --------- Co-authored-by: alonso.torres --- common/src/app/common/types/stroke.cljc | 33 +++- .../test/common_tests/types/stroke_test.cljc | 98 ++++++++++++ .../src/app/main/data/workspace/colors.cljs | 7 +- frontend/src/app/plugins/format.cljs | 11 +- frontend/src/app/plugins/parser.cljs | 41 +++-- frontend/src/app/plugins/shape.cljs | 44 ++++-- frontend/src/app/plugins/strokes.cljs | 147 +++++++++++------- frontend/src/app/plugins/tokens.cljs | 33 +++- .../plugins/context_shapes_test.cljs | 48 +++++- .../frontend_tests/plugins/format_test.cljs | 13 ++ .../plugins/page_active_validation_test.cljs | 4 + .../frontend_tests/plugins/parser_test.cljs | 69 ++++++++ .../frontend_tests/plugins/tokens_test.cljs | 91 ++++++++++- plugins/CHANGELOG.md | 1 + .../src/generated/api-surface.json | 37 ++++- .../src/tests/fills-strokes.test.ts | 46 ++++++ .../src/tests/shapes-geometry.test.ts | 22 +++ .../src/tests/tokens.test.ts | 129 +++++++++++++++ .../src/tests/value-objects.test.ts | 108 +++++++++++++ plugins/libs/plugin-types/index.d.ts | 43 ++++- 20 files changed, 918 insertions(+), 107 deletions(-) diff --git a/common/src/app/common/types/stroke.cljc b/common/src/app/common/types/stroke.cljc index 2a95b57713..ca168457db 100644 --- a/common/src/app/common/types/stroke.cljc +++ b/common/src/app/common/types/stroke.cljc @@ -25,11 +25,8 @@ :stroke-width 1}) (defn materialize-stroke-side-widths - "Given a stroke (or nil) and the set of edited side keys, returns the stroke - with the four per-side width keys concretized: edited sides take `value`, - the rest keep their current per-side width, falling back to `:stroke-width` - (0 when `stroke` is nil). `:stroke-width` mirrors the top side so legacy - consumers that only read it see the top value." + "Sets edited widths and fills missing sides from `:stroke-width` (default 0). + Keeps `:stroke-width` equal to the top width." [stroke edited-keys value] (let [base-width (or (:stroke-width stroke) 0) current (or stroke default-stroke) @@ -42,3 +39,29 @@ {} ctt/per-side-stroke-width-keys)] (merge current side-attrs {:stroke-width (get side-attrs :stroke-width-top)}))) + +(defn set-width-to-all-sides + "Sets every side and `:stroke-width` to `value`." + [stroke value] + (materialize-stroke-side-widths stroke ctt/per-side-stroke-width-keys value)) + +(defn set-width-to-single-side + "Sets one side and preserves the other widths. + Keeps `:stroke-width` equal to the top width." + [stroke attr value] + (materialize-stroke-side-widths (or stroke default-stroke) #{attr} value)) + +(defn side-width + "Returns the side width, falling back to `:stroke-width` when unset." + [stroke attr] + (d/nilv (get stroke attr) (:stroke-width stroke))) + +(defn width-type + "Returns simple when all effective side widths match, multiple otherwise." + [stroke] + (if (= (side-width stroke :stroke-width-top) + (side-width stroke :stroke-width-right) + (side-width stroke :stroke-width-bottom) + (side-width stroke :stroke-width-left)) + :simple + :multiple)) diff --git a/common/test/common_tests/types/stroke_test.cljc b/common/test/common_tests/types/stroke_test.cljc index 0e5726fcdb..3d79a34f45 100644 --- a/common/test/common_tests/types/stroke_test.cljc +++ b/common/test/common_tests/types/stroke_test.cljc @@ -72,3 +72,101 @@ (t/is (= (:stroke-width-bottom stroke) 5)) (t/is (= (:stroke-width-left stroke) 5)) (t/is (= (:stroke-width stroke) 10))))) + +;; --- set-width-to-all-sides + +(t/deftest set-width-to-all-sides-uniform + (t/testing "all four sides take the value and the alias mirrors the top" + (let [stroke (cts/set-width-to-all-sides {:stroke-width 5 :stroke-color "#000000"} 8)] + (t/is (= 8 (:stroke-width-top stroke))) + (t/is (= 8 (:stroke-width-right stroke))) + (t/is (= 8 (:stroke-width-bottom stroke))) + (t/is (= 8 (:stroke-width-left stroke))) + (t/is (= 8 (:stroke-width stroke))) + (t/is (= "#000000" (:stroke-color stroke))))) + + (t/testing "a nil stroke is materialized from the default stroke" + (let [stroke (cts/set-width-to-all-sides nil 3)] + (t/is (= 3 (:stroke-width stroke))) + (t/is (= 3 (:stroke-width-top stroke))) + (t/is (= 3 (:stroke-width-right stroke))) + (t/is (= :solid (:stroke-style stroke)))))) + +;; --- set-width-to-single-side + +(t/deftest set-width-to-single-side-non-top + (t/testing "a non-top side only changes that side, keeping the alias on top" + (let [stroke (cts/set-width-to-single-side + {:stroke-width 5 + :stroke-width-top 5 + :stroke-width-right 5 + :stroke-width-bottom 6 + :stroke-width-left 7} + :stroke-width-right + 9)] + (t/is (= 5 (:stroke-width-top stroke))) + (t/is (= 9 (:stroke-width-right stroke))) + (t/is (= 6 (:stroke-width-bottom stroke))) + (t/is (= 7 (:stroke-width-left stroke))) + (t/is (= 5 (:stroke-width stroke)))))) + +(t/deftest set-width-to-single-side-top + (t/testing "the top side also updates the :stroke-width alias" + (let [stroke (cts/set-width-to-single-side {:stroke-width 5} :stroke-width-top 9)] + (t/is (= 9 (:stroke-width-top stroke))) + (t/is (= 9 (:stroke-width stroke))))) + + (t/testing "a nil stroke is materialized from the default stroke" + (let [stroke (cts/set-width-to-single-side nil :stroke-width-bottom 4)] + (t/is (= 4 (:stroke-width-bottom stroke))) + (t/is (= 1 (:stroke-width stroke))) + (t/is (= :solid (:stroke-style stroke)))))) + +;; --- side-width + +(t/deftest side-width-falls-back-to-global + (t/testing "a side without its own value reads the uniform width" + (t/is (= 5 (cts/side-width {:stroke-width 5} :stroke-width-right))) + (t/is (= 5 (cts/side-width {:stroke-width 5 :stroke-width-left 4} :stroke-width-right)))) + + (t/testing "a side with its own value wins over the global width" + (t/is (= 9 (cts/side-width {:stroke-width 5 :stroke-width-right 9} :stroke-width-right)))) + + (t/testing "a nil stroke reads nil" + (t/is (nil? (cts/side-width nil :stroke-width-top))))) + +(t/deftest width-setters-select-mode + (let [uniform (cts/set-width-to-all-sides {:stroke-color "#000000"} 1) + top (cts/set-width-to-single-side uniform :stroke-width-top 8) + equal (-> top + (cts/set-width-to-single-side :stroke-width-right 8) + (cts/set-width-to-single-side :stroke-width-bottom 8) + (cts/set-width-to-single-side :stroke-width-left 8))] + (t/is (= :simple (cts/width-type uniform))) + (t/is (= :multiple (cts/width-type top))) + (t/is (= [8 1 1 1] + (mapv #(cts/side-width top %) + [:stroke-width-top :stroke-width-right :stroke-width-bottom :stroke-width-left]))) + (t/is (= :simple (cts/width-type equal))) + (t/is (= 8 (:stroke-width equal))) + (t/is (not (contains? equal :stroke-width-type))) + (t/is (= :simple (cts/width-type (cts/set-width-to-all-sides equal 3)))))) + +(t/deftest top-width-preserves-unset-sides + (let [stroke (cts/set-width-to-single-side {:stroke-width 5} :stroke-width-top 9)] + (t/is (= [9 5 5 5] + (mapv #(cts/side-width stroke %) + [:stroke-width-top :stroke-width-right :stroke-width-bottom :stroke-width-left]))))) + +(t/deftest width-type-follows-effective-side-widths + (let [stroke {:stroke-width 2 :stroke-width-top 2 :stroke-width-right 4 + :stroke-width-bottom 6 :stroke-width-left 8} + simple (cts/set-width-to-all-sides stroke 2)] + (t/is (= :multiple (cts/width-type stroke))) + (t/is (= :simple (cts/width-type {:stroke-width 2}))) + (t/is (= :simple (cts/width-type {:stroke-width 2 :stroke-width-top 2}))) + (t/is (= :multiple (cts/width-type {:stroke-width 2 :stroke-width-right 4}))) + (t/is (= [2 2 2 2] + (mapv simple [:stroke-width-top :stroke-width-right :stroke-width-bottom :stroke-width-left]))) + (t/is (= :simple (cts/width-type simple))) + (t/is (not (contains? simple :stroke-width-type))))) diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs index c99f98239b..3bbc099711 100644 --- a/frontend/src/app/main/data/workspace/colors.cljs +++ b/frontend/src/app/main/data/workspace/colors.cljs @@ -467,13 +467,10 @@ (assoc-in [:strokes index] stroke)))) options)))))))) -;; --- Per-side controls expansion (Design tab UI state) +;; --- Per-side controls expansion (defn toggle-stroke-per-side - "Toggle the per-side width controls of the stroke at `index` for the shapes - in `ids`. Purely ephemeral UI state: it lives in `:workspace-local` keyed by - `[ids index]`, so it survives selecting another shape and coming back, but - resets on reload." + "Toggles per-side controls for stroke `index` on shapes `ids` in UI state." [ids index] (ptk/reify ::toggle-stroke-per-side ptk/UpdateEvent diff --git a/frontend/src/app/plugins/format.cljs b/frontend/src/app/plugins/format.cljs index f6f171705e..f84e52d078 100644 --- a/frontend/src/app/plugins/format.cljs +++ b/frontend/src/app/plugins/format.cljs @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.types.stroke :as cts] [app.plugins.image-data :refer [create-image-data]] [app.util.object :as obj])) @@ -211,8 +212,9 @@ (defn format-stroke [{:keys [stroke-color stroke-color-ref-file stroke-color-ref-id stroke-opacity stroke-style stroke-width stroke-alignment - stroke-cap-start stroke-cap-end stroke-color-gradient - stroke-image] :as stroke}] + stroke-width-top stroke-width-right stroke-width-bottom + stroke-width-left stroke-cap-start stroke-cap-end + stroke-color-gradient stroke-image] :as stroke}] (when (some? stroke) (obj/without-empty @@ -222,6 +224,11 @@ :strokeOpacity stroke-opacity :strokeStyle (format-key stroke-style) :strokeWidth stroke-width + :strokeWidthType (format-key (cts/width-type stroke)) + :strokeWidthTop stroke-width-top + :strokeWidthRight stroke-width-right + :strokeWidthBottom stroke-width-bottom + :strokeWidthLeft stroke-width-left :strokeAlignment (format-key stroke-alignment) :strokeCapStart (format-key stroke-cap-start) :strokeCapEnd (format-key stroke-cap-end) diff --git a/frontend/src/app/plugins/parser.cljs b/frontend/src/app/plugins/parser.cljs index 70d0f4d5a8..399859247e 100644 --- a/frontend/src/app/plugins/parser.cljs +++ b/frontend/src/app/plugins/parser.cljs @@ -11,6 +11,7 @@ [app.common.json :as json] [app.common.types.path :as path] [app.common.types.shape.interactions :as ctsi] + [app.common.types.stroke :as cts] [app.common.uuid :as uuid] [app.util.object :as obj] [cuerdas.core :as str])) @@ -223,18 +224,34 @@ (defn parse-stroke [^js stroke] (when (some? stroke) - (d/without-nils - {:stroke-color (-> (obj/get stroke "strokeColor") parse-hex) - :stroke-color-ref-file (-> (obj/get stroke "strokeColorRefFile") parse-id) - :stroke-color-ref-id (-> (obj/get stroke "strokeColorRefId") parse-id) - :stroke-opacity (obj/get stroke "strokeOpacity") - :stroke-style (-> (obj/get stroke "strokeStyle") parse-keyword) - :stroke-width (obj/get stroke "strokeWidth") - :stroke-alignment (-> (obj/get stroke "strokeAlignment") parse-keyword) - :stroke-cap-start (-> (obj/get stroke "strokeCapStart") parse-keyword) - :stroke-cap-end (-> (obj/get stroke "strokeCapEnd") parse-keyword) - :stroke-color-gradient (-> (obj/get stroke "strokeColorGradient") parse-gradient) - :stroke-image (-> (obj/get stroke "strokeImage") parse-image-data)}))) + (let [raw (obj/get stroke "$state") + parsed (d/without-nils + {:stroke-color (-> (obj/get stroke "strokeColor") parse-hex) + :stroke-color-ref-file (-> (obj/get stroke "strokeColorRefFile") parse-id) + :stroke-color-ref-id (-> (obj/get stroke "strokeColorRefId") parse-id) + :stroke-opacity (obj/get stroke "strokeOpacity") + :stroke-style (-> (obj/get stroke "strokeStyle") parse-keyword) + :stroke-alignment (-> (obj/get stroke "strokeAlignment") parse-keyword) + :stroke-cap-start (-> (obj/get stroke "strokeCapStart") parse-keyword) + :stroke-cap-end (-> (obj/get stroke "strokeCapEnd") parse-keyword) + :stroke-color-gradient (-> (obj/get stroke "strokeColorGradient") parse-gradient) + :stroke-image (-> (obj/get stroke "strokeImage") parse-image-data)})] + (if (map? raw) + ;; Reads the proxy's stored widths without replaying its public getters. + (merge parsed (select-keys raw [:stroke-width + :stroke-width-top :stroke-width-right + :stroke-width-bottom :stroke-width-left])) + (let [width (obj/get stroke "strokeWidth") + top (obj/get stroke "strokeWidthTop") + right (obj/get stroke "strokeWidthRight") + bottom (obj/get stroke "strokeWidthBottom") + left (obj/get stroke "strokeWidthLeft")] + (cond-> parsed + (some? width) (cts/set-width-to-all-sides width) + (some? top) (cts/set-width-to-single-side :stroke-width-top top) + (some? right) (cts/set-width-to-single-side :stroke-width-right right) + (some? bottom) (cts/set-width-to-single-side :stroke-width-bottom bottom) + (some? left) (cts/set-width-to-single-side :stroke-width-left left))))))) (defn parse-strokes [^js strokes] diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 4952a89c54..6be18bd4fa 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -31,7 +31,9 @@ [app.common.types.shape.layout :as ctl] [app.common.types.shape.radius :as ctsr] [app.common.types.shape.shadow :as ctss] + [app.common.types.stroke :as types.stroke] [app.common.types.text :as txt] + [app.common.types.token :as ctt] [app.common.uuid :as uuid] [app.main.data.exports.assets :as de] [app.main.data.exports.wasm :as wasm.exports] @@ -64,7 +66,7 @@ [app.plugins.strokes :as strokes] [app.plugins.system-events :as se] [app.plugins.text :as text] - [app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens token-attr-plugin->token-attr token-attr? valid-token-resolution?]] + [app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens expand-token-attrs token-attr-plugin->token-attr token-attr? valid-token-resolution?]] [app.plugins.utils :as u] [app.util.http :as http] [app.util.object :as obj] @@ -273,21 +275,27 @@ (st/emit! (dwsh/update-shapes [id] #(assoc % :fills value)))))) (defn commit-strokes! - [plugin-id ^js self value] - (let [id (obj/get self "$id") - value (parser/parse-strokes value)] - (cond - (not (sm/validate [:vector cts/schema:stroke] value)) - (u/not-valid plugin-id :strokes value) + ([plugin-id self value] + (commit-strokes! plugin-id self value nil)) + ([plugin-id ^js self value options] + (let [id (obj/get self "$id") + value (parser/parse-strokes value)] + (cond + (not (sm/validate [:vector cts/schema:stroke] value)) + (u/not-valid plugin-id :strokes value) - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :strokes "Plugin doesn't have 'content:write' permission") + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :strokes "Plugin doesn't have 'content:write' permission") - (not (u/page-active? (obj/get self "$page"))) - (u/not-valid plugin-id :strokes "Cannot modify a page that is not currently active") + (not (u/page-active? (obj/get self "$page"))) + (u/not-valid plugin-id :strokes "Cannot modify a page that is not currently active") - :else - (st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value)))))) + (and (not (ctt/per-side-stroke-shape? (:type (u/proxy->shape self)))) + (some #(= :multiple (types.stroke/width-type %)) value)) + (u/not-valid plugin-id :strokes "Per-side stroke widths require a board or rectangle") + + :else + (st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value) options)))))) (defn commit-shadows! [plugin-id ^js self value] @@ -992,8 +1000,12 @@ :strokes {:this true :get (fn [^js self] - (strokes/format-strokes (-> self u/proxy->shape :strokes) - #(commit-strokes! plugin-id self %))) + (let [shape (u/proxy->shape self)] + (strokes/format-strokes + (:strokes shape) + #(commit-strokes! plugin-id self %1 %2) + {:per-side-allowed? (ctt/per-side-stroke-shape? (:type shape)) + :not-valid (fn [code value] (u/not-valid plugin-id code value))}))) :set (fn [self value] (commit-strokes! plugin-id self value))} :layoutChild @@ -1782,7 +1794,7 @@ (let [set-id (obj/get token "$set-id") token-id (obj/get token "$id") token (u/locate-token file-id set-id token-id) - kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))] + kw-attrs (expand-token-attrs attrs)] (cond (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission") diff --git a/frontend/src/app/plugins/strokes.cljs b/frontend/src/app/plugins/strokes.cljs index 7ccfe1bc11..f07868b32b 100644 --- a/frontend/src/app/plugins/strokes.cljs +++ b/frontend/src/app/plugins/strokes.cljs @@ -6,78 +6,121 @@ (ns app.plugins.strokes (:require + [app.common.types.stroke :as cts] [app.plugins.format :as format] [app.plugins.gradients :as gradients] [app.plugins.parser :as parser] [app.util.object :as obj])) +(defn- set-side-width! + "Sets one side width and reports the edited attribute to the commit callback. + Rejects shape types that do not support per-side widths." + [state attr value on-change! per-side-allowed? not-valid] + (if (and (false? per-side-allowed?) (fn? not-valid)) + (not-valid attr value) + (do + (swap! state #(cts/set-width-to-single-side % attr value)) + (on-change! [attr])))) + (defn stroke-proxy - [stroke-data on-change!] - (let [state (atom stroke-data)] - (obj/reify {:name "StrokeProxy"} - :strokeColor - {:get (fn [] (:stroke-color @state)) - :set (fn [v] - (swap! state #(-> % (assoc :stroke-color v) (dissoc :stroke-color-gradient :stroke-image))) - (on-change!))} + ([stroke-data on-change!] + (stroke-proxy stroke-data on-change! nil)) + ([stroke-data on-change! {:keys [per-side-allowed? not-valid]}] + (let [state (atom stroke-data)] + (obj/reify {:name "StrokeProxy"} + ;; The parser reads stored widths without invoking fallback getters. + :$state {:enumerable false :get (fn [] @state)} - :strokeColorRefFile - {:get (fn [] (format/format-id (:stroke-color-ref-file @state))) - :set (fn [v] (swap! state assoc :stroke-color-ref-file (parser/parse-id v)) (on-change!))} + :strokeColor + {:get (fn [] (:stroke-color @state)) + :set (fn [v] + (swap! state #(-> % (assoc :stroke-color v) (dissoc :stroke-color-gradient :stroke-image))) + (on-change! [:stroke-color]))} - :strokeColorRefId - {:get (fn [] (format/format-id (:stroke-color-ref-id @state))) - :set (fn [v] (swap! state assoc :stroke-color-ref-id (parser/parse-id v)) (on-change!))} + :strokeColorRefFile + {:get (fn [] (format/format-id (:stroke-color-ref-file @state))) + :set (fn [v] (swap! state assoc :stroke-color-ref-file (parser/parse-id v)) (on-change! [:stroke-color]))} - :strokeOpacity - {:get (fn [] (:stroke-opacity @state)) - :set (fn [v] (swap! state assoc :stroke-opacity v) (on-change!))} + :strokeColorRefId + {:get (fn [] (format/format-id (:stroke-color-ref-id @state))) + :set (fn [v] (swap! state assoc :stroke-color-ref-id (parser/parse-id v)) (on-change! [:stroke-color]))} - :strokeStyle - {:get (fn [] (format/format-key (:stroke-style @state))) - :set (fn [v] (swap! state assoc :stroke-style (parser/parse-keyword v)) (on-change!))} + :strokeOpacity + {:get (fn [] (:stroke-opacity @state)) + :set (fn [v] (swap! state assoc :stroke-opacity v) (on-change! [:stroke-color]))} - :strokeWidth - {:get (fn [] (:stroke-width @state)) - :set (fn [v] (swap! state assoc :stroke-width v) (on-change!))} + :strokeStyle + {:get (fn [] (format/format-key (:stroke-style @state))) + :set (fn [v] (swap! state assoc :stroke-style (parser/parse-keyword v)) (on-change! [:stroke-style]))} - :strokeAlignment - {:get (fn [] (format/format-key (:stroke-alignment @state))) - :set (fn [v] (swap! state assoc :stroke-alignment (parser/parse-keyword v)) (on-change!))} + :strokeWidth + {:get (fn [] (:stroke-width @state)) + :set (fn [v] + (swap! state #(cts/set-width-to-all-sides % v)) + (on-change! [:stroke-width]))} - :strokeCapStart - {:get (fn [] (format/format-key (:stroke-cap-start @state))) - :set (fn [v] (swap! state assoc :stroke-cap-start (parser/parse-keyword v)) (on-change!))} + :strokeWidthType + {:get (fn [] (format/format-key (cts/width-type @state)))} - :strokeCapEnd - {:get (fn [] (format/format-key (:stroke-cap-end @state))) - :set (fn [v] (swap! state assoc :stroke-cap-end (parser/parse-keyword v)) (on-change!))} + :strokeWidthTop + {:get (fn [] (cts/side-width @state :stroke-width-top)) + :set (fn [v] (set-side-width! state :stroke-width-top v on-change! per-side-allowed? not-valid))} - :strokeColorGradient - {:get (fn [] - (when-let [gradient (:stroke-color-gradient @state)] - (let [gradient-state (atom gradient) - gradient-change! (fn [] - (swap! state assoc :stroke-color-gradient @gradient-state) - (on-change!))] - (gradients/gradient-proxy gradient-state gradient-change!)))) - :set (fn [v] - (swap! state #(-> % (assoc :stroke-color-gradient (parser/parse-gradient v)) (dissoc :stroke-color :stroke-image))) - (on-change!))} + :strokeWidthRight + {:get (fn [] (cts/side-width @state :stroke-width-right)) + :set (fn [v] (set-side-width! state :stroke-width-right v on-change! per-side-allowed? not-valid))} - :strokeImage - {:get (fn [] (format/format-image (:stroke-image @state))) - :set (fn [v] - (swap! state #(-> % (assoc :stroke-image (parser/parse-image-data v)) (dissoc :stroke-color :stroke-color-gradient))) - (on-change!))}))) + :strokeWidthBottom + {:get (fn [] (cts/side-width @state :stroke-width-bottom)) + :set (fn [v] (set-side-width! state :stroke-width-bottom v on-change! per-side-allowed? not-valid))} + + :strokeWidthLeft + {:get (fn [] (cts/side-width @state :stroke-width-left)) + :set (fn [v] (set-side-width! state :stroke-width-left v on-change! per-side-allowed? not-valid))} + + :strokeAlignment + {:get (fn [] (format/format-key (:stroke-alignment @state))) + :set (fn [v] (swap! state assoc :stroke-alignment (parser/parse-keyword v)) (on-change! [:stroke-alignment]))} + + :strokeCapStart + {:get (fn [] (format/format-key (:stroke-cap-start @state))) + :set (fn [v] (swap! state assoc :stroke-cap-start (parser/parse-keyword v)) (on-change! [:stroke-cap-start]))} + + :strokeCapEnd + {:get (fn [] (format/format-key (:stroke-cap-end @state))) + :set (fn [v] (swap! state assoc :stroke-cap-end (parser/parse-keyword v)) (on-change! [:stroke-cap-end]))} + + :strokeColorGradient + {:get (fn [] + (when-let [gradient (:stroke-color-gradient @state)] + (let [gradient-state (atom gradient) + gradient-change! (fn [] + (swap! state assoc :stroke-color-gradient @gradient-state) + (on-change! [:stroke-color]))] + (gradients/gradient-proxy gradient-state gradient-change!)))) + :set (fn [v] + (swap! state #(-> % (assoc :stroke-color-gradient (parser/parse-gradient v)) (dissoc :stroke-color :stroke-image))) + (on-change! [:stroke-color]))} + + :strokeImage + {:get (fn [] (format/format-image (:stroke-image @state))) + :set (fn [v] + (swap! state #(-> % (assoc :stroke-image (parser/parse-image-data v)) (dissoc :stroke-color :stroke-color-gradient))) + (on-change! [:stroke-color]))})))) (defn format-strokes - ([strokes] (format-strokes strokes nil)) - ([strokes commit-fn] + ([strokes] (format-strokes strokes nil nil)) + ([strokes commit-fn] (format-strokes strokes commit-fn nil)) + ([strokes commit-fn options] (if (and (some? strokes) (fn? commit-fn)) (let [arr-ref (atom nil) - on-change! (fn [] (commit-fn @arr-ref)) - arr (apply array (mapv #(stroke-proxy % on-change!) strokes))] + on-change! (fn [index attrs] + (commit-fn @arr-ref {:changed-sub-attr attrs :changed-item-index index})) + arr (apply array + (map-indexed + (fn [index stroke] + (stroke-proxy stroke (partial on-change! index) options)) + strokes))] (reset! arr-ref arr) arr) (format/format-array format/format-stroke strokes)))) diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index ba1fd09fee..ae0f6bf171 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -79,15 +79,38 @@ (let [k (cond-> k (string? k) keyword)] (get map:token-attr-plugin->token-attr k k))) +(def ^:private stroke-width-alias + "Uniform stroke width alias that expands to all four side token attributes." + :stroke-width) + +(defn expand-token-attrs + "Resolves token attributes to internal keywords and expands `:stroke-width` + to all four side attributes." + [attrs] + (let [attrs (into #{} (map token-attr-plugin->token-attr) attrs)] + (if (contains? attrs stroke-width-alias) + (into (disj attrs stroke-width-alias) cto/per-side-stroke-width-keys) + attrs))) + (defn applied-tokens-plugin->applied-tokens [value] - (into {} - (map (fn [[k v]] [(token-attr->token-attr-plugin k) v])) - value)) + (let [value (into {} + (map (fn [[k v]] [(token-attr->token-attr-plugin k) v])) + value) + per-side (map #(get value %) + [:stroke-width-top :stroke-width-right + :stroke-width-bottom :stroke-width-left])] + (cond-> value + ;; Exposes `strokeWidth` when all four sides share the same token. + (and (every? some? per-side) + (apply = per-side)) + (assoc :stroke-width (first per-side))))) (defn token-attr? [attr] - (cto/token-attr? (token-attr-plugin->token-attr attr))) + (let [attr (token-attr-plugin->token-attr attr)] + (or (= attr stroke-width-alias) + (cto/token-attr? attr)))) (defn- token-name-schema [file-id set-id token] @@ -180,7 +203,7 @@ :else (st/emit! (-> (dwta/toggle-token {:token token - :attrs (into #{} (map token-attr-plugin->token-attr) attrs) + :attrs (expand-token-attrs attrs) :shape-ids shape-ids :expand-with-children false}) (se/add-event plugin-id))))))) diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index a2c86cb346..5c7da3694f 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -9,6 +9,7 @@ [app.common.math :as m] [app.common.test-helpers.files :as cthf] [app.common.uuid :as uuid] + [app.main.data.workspace.colors :as dwc] [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwtxt] @@ -254,11 +255,49 @@ (t/testing " - strokes" (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 5}]) - (t/is (= (get-in @store (get-shape-path :strokes)) [{:stroke-color "#fabada" :stroke-opacity 1 :stroke-width 5}])) + (t/is (= (get-in @store (get-shape-path :strokes)) [{:stroke-color "#fabada" :stroke-opacity 1 :stroke-width 5 + :stroke-width-top 5 :stroke-width-right 5 :stroke-width-bottom 5 :stroke-width-left 5}])) (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeColor")) "#fabada")) (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeOpacity")) 1)) (t/is (= (-> (. ^js shape -strokes) (aget 0) (aget "strokeWidth")) 5))) + (t/testing " - strokes per-side widths" + (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeWidth 5}]) + (t/is (= 5 (-> (. ^js shape -strokes) (aget 0) (aget "strokeWidthTop"))) + "a uniform stroke reads the global width on every side") + (obj/set! (aget (.-strokes shape) 0) "strokeWidthRight" 9) + (t/is (= 9 (-> (. ^js shape -strokes) (aget 0) (aget "strokeWidthRight")))) + (t/is (= 5 (-> (. ^js shape -strokes) (aget 0) (aget "strokeWidthTop")))) + (t/is (= 9 (get-in @store (conj (get-shape-path :strokes) 0 :stroke-width-right))) + "the edited side is stored") + (t/is (= 5 (get-in @store (conj (get-shape-path :strokes) 0 :stroke-width-top))) + "the untouched sides keep their uniform width")) + + (t/testing " - UI toggles preserve equal side widths and their computed mode" + (set! (.-strokes shape) + #js [#js {:strokeColor "#fabada" :strokeWidth 1 + :strokeWidthTop 8 :strokeWidthRight 8 + :strokeWidthBottom 8 :strokeWidthLeft 8}]) + (t/is (not (contains? (get-in @store (conj (get-shape-path :strokes) 0)) :stroke-width-type))) + (t/is (= 8 (get-in @store (conj (get-shape-path :strokes) 0 :stroke-width)))) + (ptk/emit! store (dwc/toggle-stroke-per-side [(aget shape "$id")] 0)) + (t/is (true? (get-in @store [:workspace-local :stroke-per-side [[(aget shape "$id")] 0]]))) + (t/is (= "simple" (aget (aget (.-strokes shape) 0) "strokeWidthType"))) + (ptk/emit! store (dwc/toggle-stroke-per-side [(aget shape "$id")] 0)) + (t/is (= "simple" (aget (aget (.-strokes shape) 0) "strokeWidthType"))) + (t/is (false? (get-in @store [:workspace-local :stroke-per-side [[(aget shape "$id")] 0]]))) + (t/is (= [8 8 8 8] + (mapv (get-in @store (conj (get-shape-path :strokes) 0)) + [:stroke-width-top :stroke-width-right :stroke-width-bottom :stroke-width-left])))) + + (t/testing " - per-side widths rejected on unsupported shapes" + (let [^js ellipse (.createEllipse context)] + (set! (.-strokes ellipse) #js [#js {:strokeColor "#fabada" :strokeWidth 1}]) + (t/is (thrown? js/Error + (obj/set! (aget (.-strokes ellipse) 0) "strokeWidthTop" 4))) + (t/is (thrown? js/Error + (set! (.-strokes ellipse) #js [#js {:strokeColor "#fabada" :strokeWidthTop 4}]))))) + (t/testing " - fills per-element property mutation (bug #8357)" (set! (.-fills shape) #js [#js {:fillColor "#fabada" :fillOpacity 1}]) (obj/set! (aget (.-fills shape) 0) "fillColor" "#ff0000") @@ -287,13 +326,15 @@ (t/testing " - strokes per-element property mutation (bug #8357)" (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 5}]) (obj/set! (aget (.-strokes shape) 0) "strokeColor" "#0000ff") - (t/is (= (get-in @store (get-shape-path :strokes)) [{:stroke-color "#0000ff" :stroke-opacity 1 :stroke-width 5}]))) + (t/is (= (get-in @store (get-shape-path :strokes)) [{:stroke-color "#0000ff" :stroke-opacity 1 :stroke-width 5 + :stroke-width-top 5 :stroke-width-right 5 :stroke-width-bottom 5 :stroke-width-left 5}]))) (t/testing " - strokes gradient assignment replaces solid color (bug #8357)" (set! (.-strokes shape) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 5}]) (obj/set! (aget (.-strokes shape) 0) "strokeColorGradient" (gradient)) (t/is (= (get-in @store (get-shape-path :strokes)) - [{:stroke-opacity 1 :stroke-width 5 :stroke-color-gradient parsed-gradient}]))) + [{:stroke-opacity 1 :stroke-width 5 + :stroke-width-top 5 :stroke-width-right 5 :stroke-width-bottom 5 :stroke-width-left 5 :stroke-color-gradient parsed-gradient}]))) (t/testing " - strokes nested gradient mutation (bug #8357)" (set! (.-strokes shape) #js [#js {:strokeColorGradient (gradient) :strokeOpacity 1 :strokeWidth 5}]) @@ -304,6 +345,7 @@ (t/is (= (get-in @store (get-shape-path :strokes)) [{:stroke-opacity 1 :stroke-width 5 + :stroke-width-top 5 :stroke-width-right 5 :stroke-width-bottom 5 :stroke-width-left 5 :stroke-color-gradient (-> parsed-gradient (assoc :end-y 0.75) (assoc-in [:stops 1 :opacity] 0.25))}]))))) diff --git a/frontend/test/frontend_tests/plugins/format_test.cljs b/frontend/test/frontend_tests/plugins/format_test.cljs index f658c1e64d..d277ebc075 100644 --- a/frontend/test/frontend_tests/plugins/format_test.cljs +++ b/frontend/test/frontend_tests/plugins/format_test.cljs @@ -55,3 +55,16 @@ (t/deftest test-shape-type-reports-boolean (t/is (= "boolean" (format/shape-type :bool)))) + +(t/deftest test-format-stroke-exposes-per-side-widths + (let [stroke (format/format-stroke {:stroke-color "#000000" + :stroke-width 1 + :stroke-width-top 2 + :stroke-width-right 3 + :stroke-width-bottom 4 + :stroke-width-left 5})] + (t/is (= 1 (aget stroke "strokeWidth"))) + (t/is (= 2 (aget stroke "strokeWidthTop"))) + (t/is (= 3 (aget stroke "strokeWidthRight"))) + (t/is (= 4 (aget stroke "strokeWidthBottom"))) + (t/is (= 5 (aget stroke "strokeWidthLeft"))))) diff --git a/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs b/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs index 9de486f877..46d83f8891 100644 --- a/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs +++ b/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs @@ -116,6 +116,10 @@ ["ShapeProxy.rotation" #(set! (.-rotation rect) 45)] ["ShapeProxy.fills" #(set! (.-fills rect) #js [#js {:fillColor "#fabada" :fillOpacity 1}])] ["ShapeProxy.strokes" #(set! (.-strokes rect) #js [#js {:strokeColor "#fabada" :strokeOpacity 1 :strokeWidth 2}])] + ["ShapeProxy.strokeWidthTop" #(do (set! (.-strokes rect) #js [#js {:strokeColor "#fabada" :strokeWidth 2}]) + (set! (.-strokeWidthTop (aget (.-strokes rect) 0)) 5))] + ["ShapeProxy.strokeWidthRight" #(do (set! (.-strokes rect) #js [#js {:strokeColor "#fabada" :strokeWidth 2}]) + (set! (.-strokeWidthRight (aget (.-strokes rect) 0)) 5))] ;; relative geometry (shape inside a board) ["ShapeProxy.boardX" #(set! (.-boardX child) 10)] ["ShapeProxy.boardY" #(set! (.-boardY child) 10)] diff --git a/frontend/test/frontend_tests/plugins/parser_test.cljs b/frontend/test/frontend_tests/plugins/parser_test.cljs index d39c37851c..4a4fd3088c 100644 --- a/frontend/test/frontend_tests/plugins/parser_test.cljs +++ b/frontend/test/frontend_tests/plugins/parser_test.cljs @@ -11,7 +11,9 @@ [app.common.types.grid :as ctg] [app.common.types.shape.interactions :as ctsi] [app.common.uuid :as uuid] + [app.plugins.format :as format] [app.plugins.parser :as parser] + [app.plugins.strokes :as strokes] [cljs.test :as t :include-macros true])) (defn- overlay-action @@ -193,3 +195,70 @@ (t/testing "a malformed id raises, so plugin bugs stay visible" (t/is (thrown? js/Error (parser/parse-id "not-a-uuid"))))) + +(t/deftest test-parse-stroke-maps-per-side-widths + (let [stroke (parser/parse-stroke + #js {:strokeColor "#000000" + :strokeWidth 1 + :strokeWidthTop 2 + :strokeWidthRight 3 + :strokeWidthBottom 4 + :strokeWidthLeft 5})] + (t/is (= 2 (:stroke-width-top stroke))) + (t/is (= 3 (:stroke-width-right stroke))) + (t/is (= 4 (:stroke-width-bottom stroke))) + (t/is (= 5 (:stroke-width-left stroke)))) + + (t/testing "a uniform literal initializes all sides in simple mode" + (let [stroke (parser/parse-stroke #js {:strokeColor "#000000" :strokeWidth 1})] + (t/is (= "simple" (aget (format/format-stroke stroke) "strokeWidthType"))) + (t/is (= 1 (:stroke-width-top stroke))) + (t/is (= 1 (:stroke-width-left stroke)))))) + +(t/deftest test-parse-stroke-applies-uniform-width-before-sides + (let [stroke (parser/parse-stroke + #js {:strokeColor "#000000" + :strokeWidthLeft 8 + :strokeWidthBottom 8 + :strokeWidthRight 8 + :strokeWidthTop 8 + :strokeWidth 1})] + (t/is (= "simple" (aget (format/format-stroke stroke) "strokeWidthType"))) + (t/is (= 8 (:stroke-width stroke))) + (t/is (= [8 8 8 8] + (mapv stroke [:stroke-width-top :stroke-width-right :stroke-width-bottom :stroke-width-left]))))) + +(t/deftest test-parse-stroke-top-preserves-other-widths + (let [stroke (parser/parse-stroke #js {:strokeColor "#000000" :strokeWidth 5 :strokeWidthTop 9})] + (t/is (= [9 5 5 5] + (mapv stroke [:stroke-width-top :stroke-width-right :stroke-width-bottom :stroke-width-left]))))) + +(t/deftest test-stroke-proxy-mode-transitions + (let [^js stroke (strokes/stroke-proxy {:stroke-color "#000000" :stroke-width 1} (fn [_] nil))] + (t/is (= "simple" (.-strokeWidthType stroke))) + (set! (.-strokeWidthTop stroke) 8) + (t/is (= "multiple" (.-strokeWidthType stroke))) + (t/is (= 1 (.-strokeWidthRight stroke))) + (set! (.-strokeWidthRight stroke) 8) + (set! (.-strokeWidthBottom stroke) 8) + (set! (.-strokeWidthLeft stroke) 8) + (t/is (= "simple" (.-strokeWidthType stroke))) + (t/is (= 8 (.-strokeWidth stroke))) + (set! (.-strokeColor stroke) "#ff0000") + (t/is (not (contains? (parser/parse-stroke stroke) :stroke-width-type))) + (set! (.-strokeWidth stroke) 3) + (t/is (= "simple" (.-strokeWidthType stroke))) + (t/is (= [3 3 3 3] + (mapv #(aget stroke %) ["strokeWidthTop" "strokeWidthRight" "strokeWidthBottom" "strokeWidthLeft"]))) + (set! (.-strokeWidthRight stroke) 9) + (t/is (= "multiple" (.-strokeWidthType stroke))) + (set! (.-strokeWidth stroke) 3) + (t/is (= [3 3 3 3] + (mapv #(aget stroke %) ["strokeWidthTop" "strokeWidthRight" "strokeWidthBottom" "strokeWidthLeft"]))) + (t/is (= "simple" (.-strokeWidthType stroke))))) + +(t/deftest test-stroke-mode-round-trips-through-format + (doseq [right [8 12]] + (let [stroke (parser/parse-stroke + #js {:strokeColor "#000000" :strokeWidth 8 :strokeWidthRight right})] + (t/is (= stroke (parser/parse-stroke (format/format-stroke stroke))))))) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index 374b7e2cc9..56a3a2529d 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -11,17 +11,21 @@ [app.common.test-helpers.files :as cthf] [app.common.test-helpers.ids-map :as cthi] [app.common.test-helpers.tokens :as ctht] + [app.common.types.token :as ctt] [app.common.types.tokens-lib :as ctob] [app.common.types.tokens-status :as ctos] [app.common.uuid :as uuid] [app.main.data.tokenscript :as ts] + [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] [app.plugins.api :as api] [app.plugins.register :as r] [app.plugins.tokens :as ptok] [app.plugins.utils :as u] + [app.util.object :as obj] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as tha] [frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.state :as ths] [frontend-tests.helpers.wasm :as thw] @@ -35,6 +39,73 @@ (def ^:private get-resolved-value @#'ptok/get-resolved-value) +(t/deftest ^:async stroke-property-edits-only-remove-related-token-bindings + (let [tokens {:stroke-color "border.color" + :stroke-width-top "border.top" + :stroke-width-right "border.right" + :stroke-width-bottom "border.bottom" + :stroke-width-left "border.left"} + cases [[0 "strokeWidthTop" 8 #{:stroke-width-top}] + [0 "strokeWidthRight" 8 #{:stroke-width-right}] + [0 "strokeWidthBottom" 8 #{:stroke-width-bottom}] + [0 "strokeWidthLeft" 8 #{:stroke-width-left}] + [0 "strokeWidth" 8 ctt/per-side-stroke-width-keys] + [0 "strokeColor" "#ff0000" #{:stroke-color}] + [0 "strokeOpacity" 0.5 #{:stroke-color}] + [0 "strokeStyle" "dashed" #{}] + [0 "strokeAlignment" "center" #{}] + [1 "strokeWidthTop" 8 #{}] + [1 "strokeWidth" 8 #{}] + [1 "strokeColor" "#ff0000" #{}]]] + (doseq [[index property value removed] cases] + (t/testing (str "stroke " index " property " property) + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + _ (set! st/state store) + ^js ctx (api/create-context "00000000-0000-0000-0000-000000000000") + ^js rect (.createRectangle ctx) + id (obj/get rect "$id") + path [:files (:current-file-id @store) :data :pages-index + (:current-page-id @store) :objects id :applied-tokens]] + (set! (.-strokes rect) + #js [#js {:strokeColor "#000000" :strokeWidth 1 :strokeWidthRight 2} + #js {:strokeColor "#000000" :strokeWidth 1}]) + (ptk/emit! store (dwsh/update-shapes [id] #(assoc % :applied-tokens tokens))) + (obj/set! (aget (.-strokes rect) index) property value) + (await (tha/settle)) + (t/is (= (apply dissoc tokens removed) (get-in @store path))) + (t/is (= value (obj/get (aget (.-strokes rect) index) property)))))))) + +(t/deftest ^:async stroke-gradient-edits-preserve-width-token-bindings + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + _ (set! st/state store) + ^js ctx (api/create-context "00000000-0000-0000-0000-000000000000") + ^js rect (.createRectangle ctx) + id (obj/get rect "$id") + path [:files (:current-file-id @store) :data :pages-index + (:current-page-id @store) :objects id :applied-tokens] + tokens {:stroke-color "border.color" + :stroke-width-top "border.width" + :stroke-width-right "border.width" + :stroke-width-bottom "border.width" + :stroke-width-left "border.width"}] + (set! (.-strokes rect) + #js [#js {:strokeWidth 1 + :strokeColorGradient + #js {:type "linear" :startX 0 :startY 0 :endX 1 :endY 1 :width 1 + :stops #js [#js {:color "#000000" :opacity 1 :offset 0} + #js {:color "#ffffff" :opacity 1 :offset 1}]}}]) + (ptk/emit! store (dwsh/update-shapes [id] #(assoc % :applied-tokens tokens))) + (let [^js gradient (obj/get (aget (.-strokes rect) 0) "strokeColorGradient")] + (set! (.-startX gradient) 0.5) + (await (tha/settle)) + (t/is (= (dissoc tokens :stroke-color) (get-in @store path))) + (t/is (= 0.5 (obj/get (obj/get (aget (.-strokes rect) 0) "strokeColorGradient") "startX"))) + (ptk/emit! store (dwsh/update-shapes [id] #(assoc % :applied-tokens tokens))) + (obj/set! (aget (.-stops gradient) 0) "color" "#ff0000") + (await (tha/settle)) + (t/is (= (dissoc tokens :stroke-color) (get-in @store path))) + (t/is (= "#ff0000" (obj/get (aget (obj/get (obj/get (aget (.-strokes rect) 0) "strokeColorGradient") "stops") 0) "color")))))) + ;; Regression coverage for issue #9162. ;; ;; Plugin code calling `shape.applyToken(token, ["fill"])` or @@ -122,6 +193,25 @@ (t/is (true? (boolean (ptok/token-attr? "r1")))) (t/is (true? (boolean (ptok/token-attr? "m3"))))) +(t/deftest token-attr?-accepts-per-side-stroke-width + ;; Accepts the uniform width alias and side attributes as keywords or strings. + (t/is (true? (boolean (ptok/token-attr? :stroke-width)))) + (t/is (true? (boolean (ptok/token-attr? "stroke-width")))) + (t/is (true? (boolean (ptok/token-attr? :stroke-width-top)))) + (t/is (true? (boolean (ptok/token-attr? "stroke-width-left"))))) + +(t/deftest expand-token-attrs-expands-uniform-stroke-width + (t/testing "the uniform alias becomes the four per-side attributes" + (t/is (= ctt/per-side-stroke-width-keys + (ptok/expand-token-attrs #{:stroke-width}))) + (t/is (= ctt/per-side-stroke-width-keys + (ptok/expand-token-attrs #{"stroke-width"})))) + + (t/testing "per-side and unrelated attributes pass through unchanged" + (t/is (= #{:stroke-width-top} (ptok/expand-token-attrs #{:stroke-width-top}))) + (t/is (= #{:fill} (ptok/expand-token-attrs #{:fill}))) + (t/is (= #{} (ptok/expand-token-attrs nil))))) + (t/deftest shape-apply-token-accepts-padding-top (t/async done @@ -807,4 +897,3 @@ (.addSet catalog #js {"name" "NewSet"}) (t/is (= 1 (count @errors))) (t/is (= [plugin-id :addSet "Plugin doesn't have 'content:write' permission"] (first @errors))))))) - diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 4b45df68b0..4321ec2fc9 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -5,6 +5,7 @@ - **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. - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. - **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished +- **plugin-types:** `Stroke` exposes per-side widths for boards and rectangles, also supported by `borderWidth` and `dimension` tokens. Read-only `strokeWidthType` reports `simple` when all four effective widths match, otherwise `multiple`. ### 🩹 Fixes 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 5116a2b1ec..b32168ec1b 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 @@ -472,7 +472,12 @@ "strokeImage", "strokeOpacity", "strokeStyle", - "strokeWidth" + "strokeWidth", + "strokeWidthBottom", + "strokeWidthLeft", + "strokeWidthRight", + "strokeWidthTop", + "strokeWidthType" ], "SvgRaw": ["type"], "Text": [ @@ -7831,6 +7836,36 @@ "type": null, "array": false }, + "strokeWidthType": { + "decl": "Stroke", + "kind": "get", + "type": null, + "array": false + }, + "strokeWidthTop": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeWidthRight": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeWidthBottom": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, + "strokeWidthLeft": { + "decl": "Stroke", + "kind": "getset", + "type": null, + "array": false + }, "strokeAlignment": { "decl": "Stroke", "kind": "getset", diff --git a/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts index 685d253665..ceb863943e 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/fills-strokes.test.ts @@ -226,6 +226,52 @@ describe('Fills & strokes', () => { expect(stroke.strokeCapEnd).toBe('triangle-arrow'); }); + test('per-side widths survive an unrelated write', (ctx) => { + const r = rect(ctx); + r.strokes = [ + { + strokeColor: '#000000', + strokeWidth: 1, + strokeWidthTop: 2, + strokeWidthRight: 3, + strokeWidthBottom: 4, + strokeWidthLeft: 5, + }, + ]; + const stroke = r.strokes[0]; + stroke.strokeColor = '#ff0000'; + + const updated = r.strokes[0]; + expect(updated.strokeColor).toBe('#ff0000'); + expect(updated.strokeWidthTop).toBeCloseTo(2, 0); + expect(updated.strokeWidthRight).toBeCloseTo(3, 0); + expect(updated.strokeWidthBottom).toBeCloseTo(4, 0); + expect(updated.strokeWidthLeft).toBeCloseTo(5, 0); + }); + + test('a literal without per-side keys reads uniform', (ctx) => { + const r = rect(ctx); + r.strokes = [{ strokeColor: '#000000', strokeWidth: 6 }]; + expect(r.strokes[0].strokeWidth).toBeCloseTo(6, 0); + expect(r.strokes[0].strokeWidthTop).toBeCloseTo(6, 0); + expect(r.strokes[0].strokeWidthLeft).toBeCloseTo(6, 0); + }); + + test('a literal side width preserves the other uniform widths', (ctx) => { + const r = rect(ctx); + r.strokes = [ + { strokeColor: '#000000', strokeWidthTop: 9, strokeWidth: 5 }, + ]; + + const stroke = r.strokes[0]; + expect(stroke.strokeWidthType).toBe('multiple'); + expect(stroke.strokeWidth).toBe(9); + expect(stroke.strokeWidthTop).toBe(9); + expect(stroke.strokeWidthRight).toBe(5); + expect(stroke.strokeWidthBottom).toBe(5); + expect(stroke.strokeWidthLeft).toBe(5); + }); + test('dashed stroke style is preserved', (ctx) => { const r = rect(ctx); r.strokes = [ diff --git a/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts index f337796938..2943c62cf2 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/shapes-geometry.test.ts @@ -273,6 +273,28 @@ describe('Shapes', () => { }); }); + describe('Per-side stroke width', () => { + test('per-side widths are readable and writable on a rectangle', (ctx) => { + const r = rect(ctx); + r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }]; + const stroke = r.strokes[0]; + stroke.strokeWidthTop = 2; + stroke.strokeWidthRight = 3; + expect(stroke.strokeWidthTop).toBeCloseTo(2, 0); + expect(stroke.strokeWidthRight).toBeCloseTo(3, 0); + }); + + test('per-side widths are rejected on shapes that do not support them', (ctx) => { + const path = ctx.penpot.createPath(); + ctx.board.appendChild(path); + path.d = 'M0 0 L40 0'; + path.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }]; + expect(() => { + path.strokes[0].strokeWidthTop = 4; + }).toThrow(); + }); + }); + describe('Ordering', () => { test('setParentIndex moves the shape to the given index', (ctx) => { const a = rect(ctx); diff --git a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts index 9a3f65d0d0..095a86ac17 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/tokens.test.ts @@ -33,6 +33,39 @@ function unique(prefix: string): string { return `${prefix}-${runTag}-${counter}`; } +async function rectangleWithStrokeTokens(ctx: TestContext) { + const set = activeSet(ctx, unique('stroke-set')); + const width = set.addToken({ + type: 'borderWidth', + name: unique('border.width'), + value: '4', + }); + const color = set.addToken({ + type: 'color', + name: unique('border.color'), + value: '#112233', + }); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + rect.strokes = [ + { strokeColor: '#000000', strokeWidth: 1 }, + { strokeColor: '#ffffff', strokeWidth: 2 }, + ]; + rect.applyToken(width, ['strokeWidth']); + await waitFor(() => rect.tokens.strokeWidth === width.name); + rect.applyToken(color, ['strokeColor']); + await waitFor(() => rect.tokens.strokeColor === color.name); + expect(rect.tokens).toEqual({ + strokeColor: color.name, + strokeWidth: width.name, + strokeWidthTop: width.name, + strokeWidthRight: width.name, + strokeWidthBottom: width.name, + strokeWidthLeft: width.name, + }); + return { rect, width, color }; +} + /** Token application and theme/set wiring update the store asynchronously. */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -385,6 +418,102 @@ describe('Tokens', () => { expect(Object.keys(b.tokens)).toContain('paddingLeft'); }); + test('applyToken accepts the uniform strokeWidth property', async (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'borderWidth', + name: unique('width.'), + value: '4', + }); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + + rect.applyToken(token, ['strokeWidth']); + + await waitFor(() => Object.keys(rect.tokens).length > 0); + const keys = Object.keys(rect.tokens); + expect(keys).toContain('strokeWidthTop'); + expect(keys).toContain('strokeWidthRight'); + expect(keys).toContain('strokeWidthBottom'); + expect(keys).toContain('strokeWidthLeft'); + }); + + test('applyToken binds a single per-side stroke width property', async (ctx) => { + const set = activeSet(ctx, unique('set')); + const token = set.addToken({ + type: 'borderWidth', + name: unique('width.'), + value: '4', + }); + const rect = ctx.penpot.createRectangle(); + ctx.board.appendChild(rect); + + rect.applyToken(token, ['strokeWidthTop']); + + await waitFor(() => Object.keys(rect.tokens).includes('strokeWidthTop')); + const keys = Object.keys(rect.tokens); + expect(keys).toContain('strokeWidthTop'); + expect(keys).not.toContain('strokeWidthRight'); + }); + + for (const side of [ + 'strokeWidthTop', + 'strokeWidthRight', + 'strokeWidthBottom', + 'strokeWidthLeft', + ] as const) { + test(`${side} edits preserve unrelated stroke tokens`, async (ctx) => { + const { rect } = await rectangleWithStrokeTokens(ctx); + const expected: Partial = { ...rect.tokens }; + delete expected[side]; + delete expected.strokeWidth; + + rect.strokes[0][side] = 8; + await ctx.penpot.waitForLayoutUpdate(); + + expect(rect.strokes[0][side]).toBe(8); + expect(rect.tokens).toEqual(expected); + }); + } + + test('uniform stroke width edits preserve the color token', async (ctx) => { + const { rect, color } = await rectangleWithStrokeTokens(ctx); + rect.strokes[0].strokeWidth = 8; + await ctx.penpot.waitForLayoutUpdate(); + + expect(rect.tokens).toEqual({ strokeColor: color.name }); + expect(rect.strokes[0].strokeWidthType).toBe('simple'); + expect(rect.strokes[0].strokeWidthTop).toBe(8); + expect(rect.strokes[0].strokeWidthRight).toBe(8); + expect(rect.strokes[0].strokeWidthBottom).toBe(8); + expect(rect.strokes[0].strokeWidthLeft).toBe(8); + }); + + test('stroke color edits preserve the width tokens', async (ctx) => { + const { rect } = await rectangleWithStrokeTokens(ctx); + const expected: Partial = { ...rect.tokens }; + delete expected.strokeColor; + rect.strokes[0].strokeColor = '#ff0000'; + await ctx.penpot.waitForLayoutUpdate(); + + expect(rect.strokes[0].strokeColor).toBe('#ff0000'); + expect(rect.tokens).toEqual(expected); + }); + + test('later stroke edits preserve the first stroke tokens', async (ctx) => { + const { rect } = await rectangleWithStrokeTokens(ctx); + const expected = { ...rect.tokens }; + rect.strokes[1].strokeWidthTop = 8; + rect.strokes[1].strokeColor = '#ff0000'; + await ctx.penpot.waitForLayoutUpdate(); + + expect(rect.strokes[1].strokeWidthTop).toBe(8); + expect(rect.strokes[1].strokeColor).toBe('#ff0000'); + expect(rect.strokes[0].strokeWidthTop).toBe(4); + expect(rect.strokes[0].strokeColor).toBe('#112233'); + expect(rect.tokens).toEqual(expected); + }); + test('duplicate and remove a token', (ctx) => { const set = activeSet(ctx, unique('set')); const token = set.addToken({ diff --git a/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts index f552b2e0fc..919bd9f669 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/value-objects.test.ts @@ -129,6 +129,114 @@ describe('Value objects', () => { expect(stroke.strokeCapEnd).toBe('square'); }); + test('per-side width members round-trip', (ctx) => { + const r = rect(ctx); + r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }]; + const stroke = r.strokes[0]; + stroke.strokeWidthTop = 2; + stroke.strokeWidthRight = 3; + stroke.strokeWidthBottom = 4; + stroke.strokeWidthLeft = 5; + expect(stroke.strokeWidthTop).toBeCloseTo(2, 0); + expect(stroke.strokeWidthRight).toBeCloseTo(3, 0); + expect(stroke.strokeWidthBottom).toBeCloseTo(4, 0); + expect(stroke.strokeWidthLeft).toBeCloseTo(5, 0); + }); + + test('stroke literals apply uniform width before side widths', (ctx) => { + const r = rect(ctx); + r.strokes = [ + { + strokeColor: '#000000', + strokeWidthLeft: 8, + strokeWidthBottom: 8, + strokeWidthRight: 8, + strokeWidthTop: 8, + strokeWidth: 1, + }, + ]; + const stroke = r.strokes[0]; + expect(stroke.strokeWidthType).toBe('simple'); + expect(stroke.strokeWidth).toBe(8); + expect(stroke.strokeWidthTop).toBe(8); + expect(stroke.strokeWidthRight).toBe(8); + expect(stroke.strokeWidthBottom).toBe(8); + expect(stroke.strokeWidthLeft).toBe(8); + + stroke.strokeWidthRight = 12; + expect(stroke.strokeWidthType).toBe('multiple'); + expect(stroke.strokeWidthTop).toBe(8); + stroke.strokeWidth = 3; + expect(stroke.strokeWidthType).toBe('simple'); + expect(stroke.strokeWidthRight).toBe(3); + }); + + test('a top width change preserves the other uniform sides', (ctx) => { + const r = rect(ctx); + r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }]; + r.strokes[0].strokeWidthTop = 8; + + const stroke = r.strokes[0]; + expect(stroke.strokeWidthType).toBe('multiple'); + expect(stroke.strokeWidth).toBe(8); + expect(stroke.strokeWidthTop).toBe(8); + expect(stroke.strokeWidthRight).toBe(1); + expect(stroke.strokeWidthBottom).toBe(1); + expect(stroke.strokeWidthLeft).toBe(1); + }); + + test('side setters return to simple mode when all widths match', (ctx) => { + const r = rect(ctx); + r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }]; + + r.strokes[0].strokeWidthTop = 8; + expect(r.strokes[0].strokeWidthType).toBe('multiple'); + r.strokes[0].strokeWidthRight = 8; + expect(r.strokes[0].strokeWidthType).toBe('multiple'); + r.strokes[0].strokeWidthBottom = 8; + expect(r.strokes[0].strokeWidthType).toBe('multiple'); + r.strokes[0].strokeWidthLeft = 8; + + const stroke = r.strokes[0]; + expect(stroke.strokeWidthType).toBe('simple'); + expect(stroke.strokeWidth).toBe(8); + expect(stroke.strokeWidthTop).toBe(8); + expect(stroke.strokeWidthRight).toBe(8); + expect(stroke.strokeWidthBottom).toBe(8); + expect(stroke.strokeWidthLeft).toBe(8); + }); + + test('a uniform stroke reads the same width on every side', (ctx) => { + const r = rect(ctx); + r.strokes = [{ strokeColor: '#000000', strokeWidth: 6 }]; + const stroke = r.strokes[0]; + expect(stroke.strokeWidthTop).toBeCloseTo(6, 0); + expect(stroke.strokeWidthRight).toBeCloseTo(6, 0); + expect(stroke.strokeWidthBottom).toBeCloseTo(6, 0); + expect(stroke.strokeWidthLeft).toBeCloseTo(6, 0); + }); + + test('setting strokeWidth flattens every side', (ctx) => { + const r = rect(ctx); + r.strokes = [ + { + strokeColor: '#000000', + strokeWidth: 1, + strokeWidthTop: 2, + strokeWidthRight: 3, + strokeWidthBottom: 4, + strokeWidthLeft: 5, + }, + ]; + const stroke = r.strokes[0]; + stroke.strokeWidth = 7; + expect(stroke.strokeWidth).toBeCloseTo(7, 0); + expect(stroke.strokeWidthTop).toBeCloseTo(7, 0); + expect(stroke.strokeWidthRight).toBeCloseTo(7, 0); + expect(stroke.strokeWidthBottom).toBeCloseTo(7, 0); + expect(stroke.strokeWidthLeft).toBeCloseTo(7, 0); + }); + test('stroke reference and gradient members round-trip', (ctx) => { const r = rect(ctx); r.strokes = [{ strokeColor: '#000000', strokeWidth: 1 }]; diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index b7dbcc02fc..884f5002e5 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -4164,8 +4164,8 @@ export interface Slide { } /** - * Represents stroke properties in Penpot. You can add a stroke to any shape except for groups. - * This interface includes properties for defining the color, style, width, alignment, and caps of a stroke. + * Stroke properties for any shape except groups. + * Literals apply strokeWidth before top/right/bottom/left widths. */ export interface Stroke { /** @@ -4190,9 +4190,33 @@ export interface Stroke { */ strokeStyle?: 'solid' | 'dotted' | 'dashed' | 'mixed' | 'none' | 'svg'; /** - * The optional width of the stroke. + * Reads the top width. Setting it selects simple mode and sets all four sides. */ strokeWidth?: number; + /** + * Computed width mode: simple when all four effective widths match, multiple otherwise. + */ + readonly strokeWidthType?: 'simple' | 'multiple'; + /** + * Top side width; setting it preserves the other side widths. + * Boards and rectangles only; other shape types raise a validation error. + */ + strokeWidthTop?: number; + /** + * Right side width; setting it preserves the other side widths. + * Boards and rectangles only; other shape types raise a validation error. + */ + strokeWidthRight?: number; + /** + * Bottom side width; setting it preserves the other side widths. + * Boards and rectangles only; other shape types raise a validation error. + */ + strokeWidthBottom?: number; + /** + * Left side width; setting it preserves the other side widths. + * Boards and rectangles only; other shape types raise a validation error. + */ + strokeWidthLeft?: number; /** * The optional alignment of the stroke relative to the shape's boundary. */ @@ -5465,7 +5489,11 @@ type TokenDimensionProps = | 'y' // Stroke width - | 'strokeWidth'; + | 'strokeWidth' + | 'strokeWidthTop' + | 'strokeWidthRight' + | 'strokeWidthBottom' + | 'strokeWidthLeft'; /** * The properties that a FontFamilies token can be applied to. @@ -5534,7 +5562,12 @@ type TokenSpacingProps = /** * The properties that a BorderWidth token can be applied to. */ -type TokenBorderWidthProps = 'strokeWidth'; +type TokenBorderWidthProps = + | 'strokeWidth' + | 'strokeWidthTop' + | 'strokeWidthRight' + | 'strokeWidthBottom' + | 'strokeWidthLeft'; /** * The properties that a TextCase token can be applied to.