Apply tokens to stroke per side

This commit is contained in:
Luis de Dios 2026-07-29 10:37:19 +02:00
parent fe85a5717e
commit 5329c3eb4c
11 changed files with 256 additions and 101 deletions

View File

@ -269,8 +269,11 @@
(def spacing-keys (schema-keys schema:spacing))
(def ^:private schema:stroke-width
[:map
[:stroke-width {:optional true} schema:token-name]])
[:map {:title "StrokeWidthTokenAttrs"}
[:stroke-width-top {:optional true} schema:token-name]
[:stroke-width-right {:optional true} schema:token-name]
[:stroke-width-bottom {:optional true} schema:token-name]
[:stroke-width-left {:optional true} schema:token-name]])
(def stroke-width-keys (schema-keys schema:stroke-width))
@ -421,6 +424,10 @@
:fill :fills
:stroke-color :strokes
:stroke-width :strokes
:stroke-width-top :strokes
:stroke-width-right :strokes
:stroke-width-bottom :strokes
:stroke-width-left :strokes
token-attr))
(defn shape-attr->token-attrs
@ -434,12 +441,16 @@
#{:fill}
(and (= :strokes shape-attr) (nil? changed-sub-attr))
#{:stroke-width :stroke-color}
(set/union stroke-width-keys #{:stroke-color})
(= :strokes shape-attr)
(cond
(some #{:stroke-color} changed-sub-attr) #{:stroke-color}
(some #{:stroke-width} changed-sub-attr) #{:stroke-width})
(let [sub-attrs (set changed-sub-attr)]
(cond
(sub-attrs :stroke-color) #{:stroke-color}
(sub-attrs :stroke-width) stroke-width-keys
:else
(let [per-side (set/intersection sub-attrs stroke-width-keys)]
(when (seq per-side) per-side))))
(= :layout-padding shape-attr)
(if (seq changed-sub-attr)
@ -460,6 +471,7 @@
(font-weight-keys shape-attr) #{shape-attr :typography}
(border-radius-keys shape-attr) #{shape-attr}
(stroke-width-keys shape-attr) #{shape-attr}
(shadow-keys shape-attr) #{shape-attr}
(sizing-keys shape-attr) #{shape-attr}
(opacity-keys shape-attr) #{shape-attr}
@ -555,6 +567,10 @@
:line-height [:line-height :number]
:opacity [:opacity]
:stroke-width [:stroke-width :dimensions]
:stroke-width-top [:stroke-width :dimensions]
:stroke-width-right [:stroke-width :dimensions]
:stroke-width-bottom [:stroke-width :dimensions]
:stroke-width-left [:stroke-width :dimensions]
:font-size [:font-size]
:font-weight [:font-weight]
:text-decoration [:text-decoration]

View File

@ -102,20 +102,57 @@
:no-wasm? true})))))))
(defn update-stroke-width
([value shape-ids attributes] (update-stroke-width value shape-ids attributes nil))
([value shape-ids _attributes page-id] ; The attributes param is needed to have the same arity that other update functions
([value shape-ids attributes]
(update-stroke-width value shape-ids attributes nil))
([value shape-ids _attributes page-id]
(when (number? value)
(dwsh/update-shapes shape-ids
(fn [shape]
(if (seq (:strokes shape))
(assoc-in shape [:strokes 0 :stroke-width] value)
(let [stroke (assoc cts/default-stroke :stroke-width value)]
(let [stroke (get-in shape [:strokes 0])]
(assoc-in shape [:strokes 0]
(merge stroke
{:stroke-width value
:stroke-width-top value
:stroke-width-right value
:stroke-width-bottom value
:stroke-width-left value})))
(let [stroke (assoc cts/default-stroke
:stroke-width value
:stroke-width-top value
:stroke-width-right value
:stroke-width-bottom value
:stroke-width-left value)]
(assoc shape :strokes [stroke]))))
{:reg-objects? true
:ignore-touched true
:page-id page-id
:attrs [:strokes]}))))
(defn update-stroke-width-side
([value shape-ids attributes]
(update-stroke-width-side value shape-ids attributes nil))
([value shape-ids attributes page-id]
(when (number? value)
(let [attrs (into {} (map (fn [attr] [attr value]) attributes))]
(dwsh/update-shapes shape-ids
(fn [shape]
(if (seq (:strokes shape))
(let [attrs (if (contains? attributes :stroke-width-top)
(assoc attrs :stroke-width value)
attrs)]
(update-in shape [:strokes 0] merge attrs))
(let [default-attrs (reduce
(fn [acc attr]
(assoc acc attr value))
{:stroke-width value}
(disj attributes :stroke-width))]
(assoc shape :strokes [(merge cts/default-stroke default-attrs)]))))
{:reg-objects? true
:ignore-touched true
:page-id page-id
:attrs [:strokes]})))))
(defn update-color [f value shape-ids page-id]
(when-let [tc (tinycolor/valid-color value)]
(let [hex (tinycolor/->hex-string tc)
@ -539,10 +576,10 @@
(set (filter attributes #{:r1 :r2 :r3 :r4}))
page-id)))
(some attributes #{:stroke-width})
(conj #(update-stroke-width
(some attributes ctt/stroke-width-keys)
(conj #(update-stroke-width-side
value shape-ids
#{:stroke-width}
(set/intersection attributes ctt/stroke-width-keys)
page-id))
(some attributes #{:max-width :max-height :layout-item-max-h :layout-item-max-w :layout-item-min-h :layout-item-min-w})
@ -615,9 +652,9 @@
"Maps each attribute-set to the update function that applies it to a shape.
Used both here (to resolve the correct update fn when explicit attrs are
passed to toggle-token) and in propagation.cljs (re-exported from there)."
{ctt/border-radius-keys update-shape-radius-for-corners
ctt/color-keys update-fill-stroke
ctt/stroke-width-keys update-stroke-width
{ctt/border-radius-keys update-shape-radius-for-corners
ctt/color-keys update-fill-stroke
ctt/stroke-width-keys update-stroke-width-side
ctt/sizing-keys apply-dimensions-token
ctt/opacity-keys update-opacity
ctt/rotation-keys update-rotation
@ -1006,6 +1043,7 @@
{:title "Stroke Width"
:attributes ctt/stroke-width-keys
:on-update-shape update-stroke-width
:on-update-shape-side update-stroke-width-side
:modal {:key :tokens/stroke-width
:fields [{:label "Stroke Width"
:key :stroke-width}]}}

View File

@ -145,7 +145,7 @@
[:step {:optional true} [:maybe [:or :int :float]]]
[:is-selected-on-focus {:optional true} :boolean]
[:nillable {:optional true} :boolean]
[:applied-token {:optional true} [:maybe [:or :string [:= :multiple]]]]
[:applied-token-name {:optional true} [:maybe [:or :string [:= :multiple]]]]
[:empty-to-end {:optional true} :boolean]
[:on-change {:optional true} fn?]
[:on-change-start {:optional true} fn?]

View File

@ -276,7 +276,7 @@
(seq strokes)
[:> h/sortable-container* {}
(for [[index value] (d/enumerate (:strokes values []))]
[:> stroke-row* {:key (dm/str "stroke-" index "-" (hash applied-tokens))
[:> stroke-row* {:key (dm/str "stroke-" index)
:index index
:stroke value
:title (tr "workspace.options.stroke-color")

View File

@ -121,6 +121,25 @@
show-multiple-placeholder? (or per-side-expanded? (not all-sides-equal?))
applied-token-width
(mf/with-memo [applied-tokens]
(let [top (get applied-tokens :stroke-width-top)
right (get applied-tokens :stroke-width-right)
bottom (get applied-tokens :stroke-width-bottom)
left (get applied-tokens :stroke-width-left)
per-side [top right bottom left]
per-side-has-token? (some some? per-side)]
(cond
(and (every? some? per-side) (apply = per-side))
(first per-side)
per-side-has-token?
:multiple
:else
nil)))
per-side-toggle-label
(if per-side-disabled
(tr "workspace.options.stroke.per-side-disabled")
@ -139,42 +158,62 @@
on-width-top-change
(mf/use-fn
(mf/deps index on-stroke-width-side-change)
#(on-stroke-width-side-change index :stroke-width-top %))
(mf/deps index on-stroke-width-side-change ids)
#(soc/emit-value-or-token % (fn [v]
(on-stroke-width-side-change index :stroke-width-top v)) ids #{:stroke-width-top}))
on-width-right-change
(mf/use-fn
(mf/deps index on-stroke-width-side-change)
#(on-stroke-width-side-change index :stroke-width-right %))
(mf/deps index on-stroke-width-side-change ids)
#(soc/emit-value-or-token % (fn [v]
(on-stroke-width-side-change index :stroke-width-right v)) ids #{:stroke-width-right}))
on-width-bottom-change
(mf/use-fn
(mf/deps index on-stroke-width-side-change)
#(on-stroke-width-side-change index :stroke-width-bottom %))
(mf/deps index on-stroke-width-side-change ids)
#(soc/emit-value-or-token % (fn [v]
(on-stroke-width-side-change index :stroke-width-bottom v)) ids #{:stroke-width-bottom}))
on-width-left-change
(mf/use-fn
(mf/deps index on-stroke-width-side-change)
#(on-stroke-width-side-change index :stroke-width-left %))
(mf/deps index on-stroke-width-side-change ids)
#(soc/emit-value-or-token % (fn [v]
(on-stroke-width-side-change index :stroke-width-left v)) ids #{:stroke-width-left}))
on-width-change
(mf/use-fn
(mf/deps index on-stroke-width-change ids per-side?)
(fn [value]
(if per-side?
(st/emit! (dc/change-stroke-attrs
ids
{:stroke-width value
:stroke-width-top value
:stroke-width-right value
:stroke-width-bottom value
:stroke-width-left value}
index))
(if (coll? value)
(soc/emit-value-or-token
value
(fn [v]
(st/emit! (dc/change-stroke-attrs
ids
{:stroke-width v
:stroke-width-top v
:stroke-width-right v
:stroke-width-bottom v
:stroke-width-left v}
index)))
ids
#{:stroke-width-top :stroke-width-right
:stroke-width-bottom :stroke-width-left})
(st/emit! (dc/change-stroke-attrs
ids
{:stroke-width value
:stroke-width-top value
:stroke-width-right value
:stroke-width-bottom value
:stroke-width-left value}
index)))
(soc/emit-value-or-token
value
#(on-stroke-width-change index %)
ids
#{:stroke-width}))))
ids
#{:stroke-width-top :stroke-width-right
:stroke-width-bottom :stroke-width-left}))))
;; The SVG renderer defaults dash and gap to `stroke-width + 10` when
;; unset. Showing that value as placeholder makes the override obvious.
@ -264,7 +303,32 @@
(mf/use-fn
(mf/deps on-detach-token)
(fn [token]
(on-detach-token token #{:stroke-width})))
(on-detach-token token #{:stroke-width-top :stroke-width-right
:stroke-width-bottom :stroke-width-left})))
on-detach-token-width-top
(mf/use-fn
(mf/deps on-detach-token)
(fn [token]
(on-detach-token token #{:stroke-width-top})))
on-detach-token-width-right
(mf/use-fn
(mf/deps on-detach-token)
(fn [token]
(on-detach-token token #{:stroke-width-right})))
on-detach-token-width-bottom
(mf/use-fn
(mf/deps on-detach-token)
(fn [token]
(on-detach-token token #{:stroke-width-bottom})))
on-detach-token-width-left
(mf/use-fn
(mf/deps on-detach-token)
(fn [token]
(on-detach-token token #{:stroke-width-left})))
stroke-caps-options
[{:id "none" :value "none" :label (tr "workspace.options.stroke-cap.none")}
@ -343,7 +407,7 @@
:attr :stroke-width
:class (stl/css :numeric-input-wrapper)
:property (tr "workspace.options.stroke-width")
:applied-token (get applied-tokens :stroke-width)
:applied-token applied-token-width
:placeholder (if show-multiple-placeholder?
(tr "settings.multiple")
"--")
@ -420,46 +484,54 @@
(when per-side-expanded?
[:div {:class (stl/css :stroke-sides-options)
:data-testid "stroke.per-side-options"}
[:div {:class (stl/css :stroke-side-input)
:title (tr "workspace.options.stroke-width-top")}
[:> icon* {:icon-id i/stroke-top
:size "s"}]
[:> deprecated-input/numeric-input* {:value (d/nilv (:stroke-width-top stroke) stroke-width)
:min 0
:on-change on-width-top-change
:on-focus on-focus
:select-on-focus select-on-focus
:on-blur on-blur}]]
[:div {:class (stl/css :stroke-side-input)
:title (tr "workspace.options.stroke-width-right")}
[:> icon* {:icon-id i/stroke-right
:size "s"}]
[:> deprecated-input/numeric-input* {:value (d/nilv (:stroke-width-right stroke) stroke-width)
:min 0
:on-change on-width-right-change
:on-focus on-focus
:select-on-focus select-on-focus
:on-blur on-blur}]]
[:div {:class (stl/css :stroke-side-input)
:title (tr "workspace.options.stroke-width-bottom")}
[:> icon* {:icon-id i/stroke-bottom
:size "s"}]
[:> deprecated-input/numeric-input* {:value (d/nilv (:stroke-width-bottom stroke) stroke-width)
:min 0
:on-change on-width-bottom-change
:on-focus on-focus
:select-on-focus select-on-focus
:on-blur on-blur}]]
[:div {:class (stl/css :stroke-side-input)
:title (tr "workspace.options.stroke-width-left")}
[:> icon* {:icon-id i/stroke-left
:size "s"}]
[:> deprecated-input/numeric-input* {:value (d/nilv (:stroke-width-left stroke) stroke-width)
:min 0
:on-change on-width-left-change
:on-focus on-focus
:select-on-focus select-on-focus
:on-blur on-blur}]]])
[:> numeric-input-wrapper* {:on-change on-width-top-change
:on-detach on-detach-token-width-top
:icon i/stroke-top
:min 0
:on-focus on-focus
:on-blur on-blur
:attr :stroke-width-top
:class (stl/css :numeric-input-wrapper)
:property (tr "workspace.options.stroke-width-top")
:applied-token (get applied-tokens :stroke-width-top)
:nillable true
:value (d/nilv (:stroke-width-top stroke) stroke-width)}]
[:> numeric-input-wrapper* {:on-change on-width-right-change
:on-detach on-detach-token-width-right
:icon i/stroke-right
:min 0
:on-focus on-focus
:on-blur on-blur
:attr :stroke-width-right
:class (stl/css :numeric-input-wrapper)
:property (tr "workspace.options.stroke-width-right")
:applied-token (get applied-tokens :stroke-width-right)
:nillable true
:value (d/nilv (:stroke-width-right stroke) stroke-width)}]
[:> numeric-input-wrapper* {:on-change on-width-bottom-change
:on-detach on-detach-token-width-bottom
:icon i/stroke-bottom
:min 0
:on-focus on-focus
:on-blur on-blur
:attr :stroke-width-bottom
:class (stl/css :numeric-input-wrapper)
:property (tr "workspace.options.stroke-width-bottom")
:applied-token (get applied-tokens :stroke-width-bottom)
:nillable true
:value (d/nilv (:stroke-width-bottom stroke) stroke-width)}]
[:> numeric-input-wrapper* {:on-change on-width-left-change
:on-detach on-detach-token-width-left
:icon i/stroke-left
:min 0
:on-focus on-focus
:on-blur on-blur
:attr :stroke-width-left
:class (stl/css :numeric-input-wrapper)
:property (tr "workspace.options.stroke-width-left")
:applied-token (get applied-tokens :stroke-width-left)
:nillable true
:value (d/nilv (:stroke-width-left stroke) stroke-width)}]])
;; Stroke Dash / Gap (only visible for dashed style)
(when (= stroke-style :dashed)

View File

@ -278,7 +278,14 @@
(dwta/update-shape-radius-for-corners value shape-ids attributes)))
(def shape-attribute-actions-map
(let [stroke-width (partial generic-attribute-actions #{:stroke-width} "Stroke Width")
(let [stroke-width (partial all-or-separate-actions {:attribute-labels {:stroke-width "Stroke Width"
:stroke-width-top "Top"
:stroke-width-right "Right"
:stroke-width-bottom "Bottom"
:stroke-width-left "Left"}
:hint (tr "workspace.tokens.stroke-width")
:on-update-shape-all dwta/update-stroke-width
:on-update-shape dwta/update-stroke-width-side})
font-size (partial generic-attribute-actions #{:font-size} "Font Size")
letter-spacing (partial generic-attribute-actions #{:letter-spacing} "Letter Spacing")
font-family (partial generic-attribute-actions #{:font-family} "Font Family")
@ -327,7 +334,7 @@
(when (seq (border-radius context-data))
[{:title "Border Radius" :submenu :border-radius}])
[:separator]
(stroke-width (assoc context-data :on-update-shape dwta/update-stroke-width))
(stroke-width context-data)
[:separator]
(generic-attribute-actions #{:x} "X" (assoc context-data :on-update-shape dwta/update-shape-position :hint (tr "workspace.tokens.axis")))
(generic-attribute-actions #{:y} "Y" (assoc context-data :on-update-shape dwta/update-shape-position)))

View File

@ -55,10 +55,19 @@
;; Color
:fill "Fill"
:stroke-color "Stroke Color"})
:stroke-color "Stroke Color"
;; Stroke Width
:stroke-width-top "Top"
:stroke-width-right "Right"
:stroke-width-bottom "Bottom"
:stroke-width-left "Left"})
(def ^:private dimensions-dictionary
{:stroke-width :stroke-width
{:stroke-width-top :stroke-width
:stroke-width-right :stroke-width
:stroke-width-bottom :stroke-width
:stroke-width-left :stroke-width
:p1 :spacing
:p2 :spacing
:p3 :spacing

View File

@ -365,7 +365,7 @@
(ctht/apply-token-to-shape :frame1 "token-radius" [:r1 :r2 :r3 :r4] [:r1 :r2 :r3 :r4] 10)
(ctht/apply-token-to-shape :frame1 "token-rotation" [:rotation] [:rotation] 30)
(ctht/apply-token-to-shape :frame1 "token-opacity" [:opacity] [:opacity] 0.7)
(ctht/apply-token-to-shape :frame1 "token-stroke-width" [:stroke-width] [:stroke-width] 2)
(ctht/apply-token-to-shape :frame1 "token-stroke-width" [:stroke-width-top :stroke-width-right :stroke-width-bottom :stroke-width-left] [:stroke-width] 2)
(ctht/apply-token-to-shape :frame1 "token-color" [:stroke-color] [:stroke-color] "#00ff00")
(ctht/apply-token-to-shape :frame1 "token-color" [:fill] [:fill] "#00ff00")
(ctht/apply-token-to-shape :frame1 "token-dimensions" [:width :height] [:width :height] 100)
@ -406,14 +406,17 @@
tokens-frame1' (:applied-tokens c-frame1')]
;; ==== Check
(t/is (= (count tokens-frame1') 11))
(t/is (= (get tokens-frame1' :r1) "token-radius"))
(t/is (= (get tokens-frame1' :r2) "token-radius"))
(t/is (= (get tokens-frame1' :r3) "token-radius"))
(t/is (= (get tokens-frame1' :r4) "token-radius"))
(t/is (= (get tokens-frame1' :rotation) "token-rotation"))
(t/is (= (get tokens-frame1' :opacity) "token-opacity"))
(t/is (= (get tokens-frame1' :stroke-width) "token-stroke-width"))
(t/is (= (count tokens-frame1') 14))
(t/is (= (get tokens-frame1' :r1) "token-radius"))
(t/is (= (get tokens-frame1' :r2) "token-radius"))
(t/is (= (get tokens-frame1' :r3) "token-radius"))
(t/is (= (get tokens-frame1' :r4) "token-radius"))
(t/is (= (get tokens-frame1' :rotation) "token-rotation"))
(t/is (= (get tokens-frame1' :opacity) "token-opacity"))
(t/is (= (get tokens-frame1' :stroke-width-top) "token-stroke-width"))
(t/is (= (get tokens-frame1' :stroke-width-right) "token-stroke-width"))
(t/is (= (get tokens-frame1' :stroke-width-bottom) "token-stroke-width"))
(t/is (= (get tokens-frame1' :stroke-width-left) "token-stroke-width"))
(t/is (= (get tokens-frame1' :stroke-color) "token-color"))
(t/is (= (get tokens-frame1' :fill) "token-color"))
(t/is (= (get tokens-frame1' :width) "token-dimensions"))

View File

@ -508,9 +508,9 @@
rect-with-stroke (cths/get-shape file :rect-1)
rect-without-stroke (cths/get-shape file :rect-2)
events [(dwta/apply-token {:shape-ids [(:id rect-with-stroke) (:id rect-without-stroke)]
:attributes #{:stroke-width}
:token (toht/get-token file "stroke-width.sm")
:on-update-shape dwta/update-stroke-width})]]
:attributes #{:stroke-width-top :stroke-width-right :stroke-width-bottom :stroke-width-left}
:token (toht/get-token file "stroke-width.sm")
:on-update-shape dwta/update-stroke-width-side})]]
(tohs/run-store-async
store done events
(fn [new-state]
@ -518,12 +518,18 @@
token-target' (toht/get-token file' "stroke-width.sm")
rect-with-stroke' (cths/get-shape file' :rect-1)
rect-without-stroke' (cths/get-shape file' :rect-2)]
(t/testing "token got applied to rect with stroke and shape stroke got updated"
(t/is (= (:stroke-width (:applied-tokens rect-with-stroke')) (:name token-target')))
(t/is (= (get-in rect-with-stroke' [:strokes 0 :stroke-width]) 10)))
(t/testing "token got applied to rect without stroke and shape stroke got updated"
(t/is (= (:stroke-width (:applied-tokens rect-without-stroke')) (:name token-target')))
(t/is (= (get-in rect-without-stroke' [:strokes 0 :stroke-width]) 10))))))))))
(t/testing "token got applied to rect with stroke and shape stroke got updated"
(t/is (= (:stroke-width-top (:applied-tokens rect-with-stroke')) (:name token-target')))
(t/is (= (:stroke-width-right (:applied-tokens rect-with-stroke')) (:name token-target')))
(t/is (= (:stroke-width-bottom (:applied-tokens rect-with-stroke')) (:name token-target')))
(t/is (= (:stroke-width-left (:applied-tokens rect-with-stroke')) (:name token-target')))
(t/is (= (get-in rect-with-stroke' [:strokes 0 :stroke-width]) 10)))
(t/testing "token got applied to rect without stroke and shape stroke got updated"
(t/is (= (:stroke-width-top (:applied-tokens rect-without-stroke')) (:name token-target')))
(t/is (= (:stroke-width-right (:applied-tokens rect-without-stroke')) (:name token-target')))
(t/is (= (:stroke-width-bottom (:applied-tokens rect-without-stroke')) (:name token-target')))
(t/is (= (:stroke-width-left (:applied-tokens rect-without-stroke')) (:name token-target')))
(t/is (= (get-in rect-without-stroke' [:strokes 0 :stroke-width]) 10))))))))))
(t/deftest test-apply-shadow
(t/testing "applies shadow token and updates the shapes with shadow"

View File

@ -165,7 +165,7 @@
{:case :stroke-width
:token {:name "stroke-width-token" :type :stroke-width :value "2"}
:attribute :stroke-width
:attribute :stroke-width-top
:shape :rect-shape}
{:case :text-case

View File

@ -9661,6 +9661,10 @@ msgstr "Paddings"
msgid "workspace.tokens.radius"
msgstr "Radius"
#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:287
msgid "workspace.tokens.stroke-width"
msgstr "Stroke Width"
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:47, src/app/main/ui/workspace/tokens/management/token_pill.cljs:130
msgid "workspace.tokens.ref-not-valid"
msgstr "Reference in {%s} is not valid or is not in any active set."