🎉 Expose per-side stroke width in the plugin API (#11994)

* ✨ 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

* ✨ 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

* ✨ 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

* ✨ 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

* ✨ 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

* 📚 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

* 🐛 Fix some minor issues

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
This commit is contained in:
Luis de Dios 2026-10-08 18:09:06 +02:00 committed by GitHub
parent 9218448b29
commit fad083314d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 918 additions and 107 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<void> {
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<typeof rect.tokens> = { ...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<typeof rect.tokens> = { ...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({

View File

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

View File

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