diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc
index 62ce48aa9a..61edd184c5 100644
--- a/common/src/app/common/flags.cljc
+++ b/common/src/app/common/flags.cljc
@@ -170,7 +170,8 @@
:mcp
:background-blur
:available-viewer-wasm
- :stroke-path})
+ :stroke-path
+ :stroke-per-side})
(def all-flags
(set/union email login varia))
diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc
index c7334d1717..cec7844fab 100644
--- a/common/src/app/common/types/shape.cljc
+++ b/common/src/app/common/types/shape.cljc
@@ -139,6 +139,12 @@
[:stroke-style {:optional true}
[::sm/one-of #{:solid :dotted :dashed :mixed}]]
[:stroke-width {:optional true} ::sm/safe-number]
+ ;; wasm-render only, backwards compatible
+ [:stroke-per-side {:optional true} :boolean]
+ [:stroke-width-top {:optional true} ::sm/safe-number]
+ [:stroke-width-right {:optional true} ::sm/safe-number]
+ [:stroke-width-bottom {:optional true} ::sm/safe-number]
+ [:stroke-width-left {:optional true} ::sm/safe-number]
[:stroke-dash {:optional true} ::sm/safe-number]
[:stroke-gap {:optional true} ::sm/safe-number]
[:stroke-alignment {:optional true}
diff --git a/frontend/resources/images/icons/stroke-bottom.svg b/frontend/resources/images/icons/stroke-bottom.svg
new file mode 100644
index 0000000000..3a6e044748
--- /dev/null
+++ b/frontend/resources/images/icons/stroke-bottom.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/resources/images/icons/stroke-extended.svg b/frontend/resources/images/icons/stroke-extended.svg
new file mode 100644
index 0000000000..1326c5718c
--- /dev/null
+++ b/frontend/resources/images/icons/stroke-extended.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/resources/images/icons/stroke-left.svg b/frontend/resources/images/icons/stroke-left.svg
new file mode 100644
index 0000000000..24c05ddf03
--- /dev/null
+++ b/frontend/resources/images/icons/stroke-left.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/resources/images/icons/stroke-right.svg b/frontend/resources/images/icons/stroke-right.svg
new file mode 100644
index 0000000000..248417c737
--- /dev/null
+++ b/frontend/resources/images/icons/stroke-right.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/resources/images/icons/stroke-top.svg b/frontend/resources/images/icons/stroke-top.svg
new file mode 100644
index 0000000000..170ac1e2ab
--- /dev/null
+++ b/frontend/resources/images/icons/stroke-top.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs
index c145a203b7..f8c916f881 100644
--- a/frontend/src/app/main/data/workspace/colors.cljs
+++ b/frontend/src/app/main/data/workspace/colors.cljs
@@ -332,6 +332,11 @@
[:stroke-style
:stroke-alignment
:stroke-width
+ :stroke-per-side
+ :stroke-width-top
+ :stroke-width-right
+ :stroke-width-bottom
+ :stroke-width-left
:stroke-cap-start
:stroke-cap-end])
diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
index 44e44e075f..d0634e21b1 100644
--- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
+++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
@@ -245,19 +245,24 @@
(def ^:icon-id status-update "status-update")
(def ^:icon-id status-wrong "status-wrong")
(def ^:icon-id stroke-arrow "stroke-arrow")
+(def ^:icon-id stroke-bottom "stroke-bottom")
(def ^:icon-id stroke-center "stroke-center")
(def ^:icon-id stroke-circle "stroke-circle")
(def ^:icon-id stroke-dashed "stroke-dashed")
(def ^:icon-id stroke-diamond "stroke-diamond")
(def ^:icon-id stroke-dotted "stroke-dotted")
+(def ^:icon-id stroke-extended "stroke-extended")
(def ^:icon-id stroke-inside "stroke-inside")
+(def ^:icon-id stroke-left "stroke-left")
(def ^:icon-id stroke-mixed "stroke-mixed")
(def ^:icon-id stroke-outside "stroke-outside")
(def ^:icon-id stroke-rectangle "stroke-rectangle")
+(def ^:icon-id stroke-right "stroke-right")
(def ^:icon-id stroke-rounded "stroke-rounded")
(def ^:icon-id stroke-size "stroke-size")
(def ^:icon-id stroke-squared "stroke-squared")
(def ^:icon-id stroke-solid "stroke-solid")
+(def ^:icon-id stroke-top "stroke-top")
(def ^:icon-id stroke-triangle "stroke-triangle")
(def ^:icon-id svg "svg")
(def ^:icon-id swatches "swatches")
diff --git a/frontend/src/app/main/ui/inspect/attributes/geometry.cljs b/frontend/src/app/main/ui/inspect/attributes/geometry.cljs
index dbfa79a52c..8a6c255d50 100644
--- a/frontend/src/app/main/ui/inspect/attributes/geometry.cljs
+++ b/frontend/src/app/main/ui/inspect/attributes/geometry.cljs
@@ -22,6 +22,10 @@
:left
:top
:border-radius
+ :border-block-start-width ; top
+ :border-inline-end-width ; right
+ :border-block-end-width ; bottom
+ :border-inline-start-width ; left
:transform])
(mf/defc geometry-block
diff --git a/frontend/src/app/main/ui/inspect/attributes/stroke.cljs b/frontend/src/app/main/ui/inspect/attributes/stroke.cljs
index bee02622cc..da2d1acb6a 100644
--- a/frontend/src/app/main/ui/inspect/attributes/stroke.cljs
+++ b/frontend/src/app/main/ui/inspect/attributes/stroke.cljs
@@ -13,11 +13,27 @@
[app.main.ui.components.title-bar :refer [inspect-title-bar*]]
[app.main.ui.inspect.attributes.common :as cmm]
[app.util.code-gen.style-css :as css]
+ [app.util.code-gen.style-css-values :as cssv]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
(def ^:private properties [:border-style :border-width])
+(def ^:private per-side-properties
+ [:border-style
+ :border-block-start-width
+ :border-inline-end-width
+ :border-block-end-width
+ :border-inline-start-width])
+
+(defn- stroke-properties
+ "Logical per-side width properties when the stroke uses differing
+ per-side widths; the regular border-style/width pair otherwise."
+ [stroke]
+ (if (some? (cssv/stroke-per-side-widths stroke))
+ per-side-properties
+ properties))
+
(defn- stroke->color [shape]
{:color (:stroke-color shape)
:opacity (:stroke-opacity shape)
@@ -41,7 +57,7 @@
(fn [format]
(reset! format* format)))]
[:div {:class (stl/css :attributes-fill-block)}
- (for [property properties]
+ (for [property (stroke-properties stroke)]
(let [property-name (cmm/get-css-rule-humanized property)
property-value (css/get-css-value objects stroke property)]
[:div {:class (stl/css :stroke-row) :key (str "stroke-" (:id shape) "-" property)}
@@ -56,7 +72,7 @@
:property "Border color"
:format format
:on-change-format on-change
- :copy-data (css/get-shape-properties-css objects {:strokes [shape]} properties)}]]))
+ :copy-data (css/get-shape-properties-css objects {:strokes [shape]} (stroke-properties stroke))}]]))
;; New implementation of stroke-block using the new color format selector
;; This component is used when the flag :inspect-styles is enabled. Update when flag no longer needed.
@@ -64,7 +80,7 @@
[{:keys [objects shape stroke color-space]}]
(let [color (stroke->color stroke)]
[:div {:class (stl/css :attributes-fill-block)}
- (for [property properties]
+ (for [property (stroke-properties stroke)]
(let [property-name (cmm/get-css-rule-humanized property)
property-value (css/get-css-value objects stroke property)]
[:div {:class (stl/css :stroke-row) :key (str "stroke-" (:id shape) "-" property)}
@@ -78,7 +94,7 @@
{:color color
:property "Border color"
:format (d/nilv (keyword color-space) :hex)
- :copy-data (css/get-shape-properties-css objects {:strokes [shape]} properties {:format (keyword color-space)})}]]))
+ :copy-data (css/get-shape-properties-css objects {:strokes [shape]} (stroke-properties stroke) {:format (keyword color-space)})}]]))
(mf/defc stroke-panel*
[{:keys [shapes color-space]}]
diff --git a/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs b/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs
index 6888f99921..d008bf6f84 100644
--- a/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs
+++ b/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs
@@ -22,6 +22,11 @@
:border-start-end-radius
:border-end-start-radius
:border-end-end-radius
+ ;; side strokes
+ :border-block-start-width
+ :border-inline-end-width
+ :border-block-end-width
+ :border-inline-start-width
:transform])
(def ^:private shape-prop->border-radius-prop
diff --git a/frontend/src/app/main/ui/inspect/styles/panels/stroke.cljs b/frontend/src/app/main/ui/inspect/styles/panels/stroke.cljs
index 200a9d8138..2398eaede9 100644
--- a/frontend/src/app/main/ui/inspect/styles/panels/stroke.cljs
+++ b/frontend/src/app/main/ui/inspect/styles/panels/stroke.cljs
@@ -15,11 +15,28 @@
[app.main.ui.inspect.styles.rows.color-properties-row :refer [color-properties-row*]]
[app.main.ui.inspect.styles.rows.properties-row :refer [properties-row*]]
[app.util.code-gen.style-css :as css]
+ [app.util.code-gen.style-css-values :as cssv]
[app.util.color :as uc]
[rumext.v2 :as mf]))
(def ^:private properties [:border-color :border-style :border-width])
+(def ^:private per-side-properties
+ [:border-color
+ :border-style
+ :border-block-start-width
+ :border-inline-end-width
+ :border-block-end-width
+ :border-inline-start-width])
+
+(defn- stroke-properties
+ "Logical per-side width properties when the stroke uses differing
+ per-side widths; the regular property set otherwise."
+ [stroke]
+ (if (some? (cssv/stroke-per-side-widths stroke))
+ per-side-properties
+ properties))
+
(def ^:private shape-prop->stroke-prop
{:border-style :stroke-style
:border-width :stroke-width
@@ -71,7 +88,14 @@
color-value (dm/str "border: " stroke-width "px " (d/name stroke-style) " " formatted-color-value ";")
color-gradient (dm/str "border-image: " (uc/gradient->css gradient-data) " 100 / " stroke-width "px;")
color-image (dm/str "border-image: url(" (cfg/resolve-file-media color-image) ") 100 / " stroke-width "px;")
- :else "")]
+ :else "")
+ value (if-let [[top right bottom left] (cssv/stroke-per-side-widths stroke)]
+ (dm/str value
+ " border-block-start-width: " top "px;"
+ " border-inline-end-width: " right "px;"
+ " border-block-end-width: " bottom "px;"
+ " border-inline-start-width: " left "px;")
+ value)]
(if (empty? acc)
value
(str acc " " value))))
@@ -92,7 +116,7 @@
(for [shape shapes]
[:div {:key (:id shape) :class (stl/css :stroke-shape)}
(for [[idx stroke] (map-indexed vector (:strokes shape))]
- (for [property properties]
+ (for [property (stroke-properties stroke)]
(let [value (css/get-css-value objects stroke property)
stroke-type (ctc/stroke->color stroke)
property-name (cmm/get-css-rule-humanized property)
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs
index 29e4151880..d671e7294c 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs
@@ -10,10 +10,12 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.stroke :as cts]
+ [app.config :as cf]
[app.main.data.workspace :as udw]
[app.main.data.workspace.colors :as dc]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.tokens.application :as dwta]
+ [app.main.features :as features]
[app.main.store :as st]
[app.main.ui.components.title-bar :refer [title-bar*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
@@ -136,6 +138,53 @@
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(st/emit! (dc/change-stroke-attrs ids {:stroke-width value} index))))
+ wasm-render?
+ (features/use-feature "render-wasm/v1")
+
+ per-side-available?
+ (and (contains? cf/flags :stroke-per-side)
+ (or (= type :rect) (= type :frame)))
+
+ per-side-disabled?
+ (not wasm-render?)
+
+ on-stroke-per-side-toggle
+ (fn [index]
+ (let [stroke (get-in values [:strokes index])
+ active? (:stroke-per-side stroke)
+ width (:stroke-width stroke)
+ width (if (number? width) width 1)]
+ (st/emit! (udw/trigger-bounding-box-cloaking ids))
+ (if active?
+ (st/emit! (dc/change-stroke-attrs ids {:stroke-per-side false} index))
+ ;; Entering per-side mode seeds any missing side from the
+ ;; uniform width, so previous per-side edits are preserved.
+ ;; The top value doubles as the global :stroke-width.
+ (let [top (d/nilv (:stroke-width-top stroke) width)]
+ (st/emit! (dc/change-stroke-attrs
+ ids
+ {:stroke-per-side true
+ :stroke-width top
+ :stroke-width-top top
+ :stroke-width-right (d/nilv (:stroke-width-right stroke) width)
+ :stroke-width-bottom (d/nilv (:stroke-width-bottom stroke) width)
+ :stroke-width-left (d/nilv (:stroke-width-left stroke) width)}
+ index))))))
+
+ on-stroke-width-side-change
+ (fn [index attr value]
+ (when (number? value)
+ (st/emit! (udw/trigger-bounding-box-cloaking ids))
+ ;; Code paths that don't know about per-side data (old render,
+ ;; global width input, exports) keep reading :stroke-width, and
+ ;; per spec they must see the TOP side there. So editing the top
+ ;; side also writes :stroke-width; the other sides only write
+ ;; their own attr.
+ (let [attrs (cond-> {attr value}
+ (= attr :stroke-width-top)
+ (assoc :stroke-width value))]
+ (st/emit! (dc/change-stroke-attrs ids attrs index)))))
+
on-stroke-dash-change
(fn [index value]
(when-not (str/empty? value)
@@ -237,6 +286,10 @@
:on-color-detach on-color-detach
:on-remove on-remove
:on-stroke-width-change on-stroke-width-change
+ :per-side-available per-side-available?
+ :per-side-disabled per-side-disabled?
+ :on-stroke-per-side-toggle on-stroke-per-side-toggle
+ :on-stroke-width-side-change on-stroke-width-side-change
:on-stroke-dash-change on-stroke-dash-change
:on-stroke-gap-change on-stroke-gap-change
:on-stroke-style-change on-stroke-style-change
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
index 8e21f18232..1f2ede3f39 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
@@ -35,6 +35,10 @@
on-color-detach
on-remove
on-stroke-width-change
+ per-side-available
+ per-side-disabled
+ on-stroke-per-side-toggle
+ on-stroke-width-side-change
on-stroke-dash-change
on-stroke-gap-change
on-stroke-style-change
@@ -98,6 +102,42 @@
stroke-width (:stroke-width stroke)
+ per-side? (and per-side-available
+ (not per-side-disabled)
+ (true? (:stroke-per-side stroke)))
+
+ per-side-toggle-label
+ (if per-side-disabled
+ (tr "workspace.options.stroke.per-side-disabled")
+ (tr "workspace.options.stroke.per-side"))
+
+ on-per-side-toggle
+ (mf/use-fn
+ (mf/deps index on-stroke-per-side-toggle)
+ (fn []
+ (when on-stroke-per-side-toggle
+ (on-stroke-per-side-toggle index))))
+
+ on-width-top-change
+ (mf/use-fn
+ (mf/deps index on-stroke-width-side-change)
+ #(on-stroke-width-side-change index :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 %))
+
+ on-width-bottom-change
+ (mf/use-fn
+ (mf/deps index on-stroke-width-side-change)
+ #(on-stroke-width-side-change index :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 %))
+
on-width-change
(mf/use-fn
(mf/deps index on-stroke-width-change ids)
@@ -264,7 +304,8 @@
;; Stroke Width, Alignment & Style
(if token-numeric-inputs
- [:div {:class (stl/css :stroke-options-tokens)}
+ [:div {:class (stl/css-case :stroke-options-tokens true
+ :stroke-options-tokens-with-toggle per-side-available)}
[:> numeric-input-wrapper* {:on-change on-width-change
:on-detach on-detach-token-width
:icon i/stroke-size
@@ -275,6 +316,7 @@
:class (stl/css :numeric-input-wrapper)
:property (tr "workspace.options.stroke-width")
:applied-token (get applied-tokens :stroke-width)
+ :disabled per-side?
:value stroke-width}]
[:> select* {:default-selected (d/name stroke-alignment)
:options stroke-alignment-options
@@ -292,7 +334,15 @@
:variant "icon-only"
:disabled (if (= :multiple hidden?) true hidden?)
:dropdown-alignment :right
- :on-change on-style-change}])]
+ :on-change on-style-change}])
+ (when per-side-available
+ [:> icon-button* {:variant "ghost"
+ :class (stl/css-case :per-side-toggle-selected per-side?)
+ :aria-label per-side-toggle-label
+ :disabled per-side-disabled
+ :on-click on-per-side-toggle
+ :icon i/stroke-extended
+ :data-testid "stroke.per-side-toggle"}])]
[:div {:class (stl/css :stroke-options)}
[:div {:class (stl/css :stroke-width-input)
@@ -305,6 +355,7 @@
:on-change on-width-change
:on-focus on-focus
:select-on-focus select-on-focus
+ :is-disabled per-side?
:on-blur on-blur}]]
[:div {:class (stl/css :stroke-alignment-select)
:data-testid "stroke.alignment"}
@@ -314,12 +365,65 @@
:on-change on-alignment-change}]]
(when-not disable-stroke-style
- [:div {:class (stl/css :stroke-style-select)
+ [:div {:class (stl/css-case :stroke-style-select true
+ :stroke-style-select-compact per-side-available)
:data-testid "stroke.style"}
[:& select {:default-value stroke-style
:options stroke-style-options
:disabled hidden?
- :on-change on-style-change}]])])
+ :on-change on-style-change}]])
+ (when per-side-available
+ [:> icon-button* {:variant "ghost"
+ :class (stl/css-case :per-side-toggle-selected per-side?)
+ :aria-label per-side-toggle-label
+ :disabled per-side-disabled
+ :on-click on-per-side-toggle
+ :icon i/stroke-extended
+ :data-testid "stroke.per-side-toggle"}])])
+
+ (when per-side?
+ [: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}]]])
;; Stroke Dash / Gap (only visible for dashed style)
(when (= stroke-style :dashed)
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss
index bc452ce0ad..9e34244454 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss
@@ -98,3 +98,32 @@
.stroke-style-icon-select {
--dropdown-width: var(--four-columns-width);
}
+
+.per-side-toggle-selected {
+ border-color: var(--button-icon-border-color-selected);
+ background-color: var(--button-icon-background-color-selected);
+ color: var(--color-accent-primary);
+}
+
+.stroke-options-tokens-with-toggle {
+ grid-template-columns:
+ 1fr var(--grid-exception-input-width-small)
+ var(--grid-exception-input-width-small) auto;
+}
+
+.stroke-style-select-compact {
+ grid-column: span 2;
+}
+
+.stroke-sides-options {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--sp-xs) var(--sp-xs);
+}
+
+.stroke-side-input {
+ @extend %input-element;
+ @include t.use-typography("body-small");
+
+ padding-inline-start: var(--sp-xs);
+}
diff --git a/frontend/src/app/render_wasm/api/props.cljs b/frontend/src/app/render_wasm/api/props.cljs
index add4496c66..26bbdadc3a 100644
--- a/frontend/src/app/render_wasm/api/props.cljs
+++ b/frontend/src/app/render_wasm/api/props.cljs
@@ -98,6 +98,18 @@
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end dash gap)
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end dash gap)
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end dash gap))
+
+ ;; Per-side widths (rects/frames). Sides falling back to the
+ ;; uniform width; skipped when all sides end up equal, so the
+ ;; renderer keeps the uniform path (with dash/dot support).
+ (when (:stroke-per-side stroke)
+ (let [top (or (:stroke-width-top stroke) width)
+ right (or (:stroke-width-right stroke) width)
+ bottom (or (:stroke-width-bottom stroke) width)
+ left (or (:stroke-width-left stroke) width)]
+ (when-not (= top right bottom left)
+ (h/call wasm/internal-module "_set_shape_stroke_sides" top right bottom left))))
+
(cond
(some? gradient)
(do (types.fills.impl/write-gradient-fill offset dview opacity gradient)
diff --git a/frontend/src/app/util/code_gen/style_css.cljs b/frontend/src/app/util/code_gen/style_css.cljs
index f2b8e76418..85cf87ee0b 100644
--- a/frontend/src/app/util/code_gen/style_css.cljs
+++ b/frontend/src/app/util/code_gen/style_css.cljs
@@ -76,6 +76,10 @@ body {
:transform
:background
:border
+ :border-block-start-width
+ :border-inline-end-width
+ :border-block-end-width
+ :border-inline-start-width
:border-start-start-radius
:border-start-end-radius
:border-end-start-radius
diff --git a/frontend/src/app/util/code_gen/style_css_formats.cljs b/frontend/src/app/util/code_gen/style_css_formats.cljs
index 85e305fa4d..5f3741a638 100644
--- a/frontend/src/app/util/code_gen/style_css_formats.cljs
+++ b/frontend/src/app/util/code_gen/style_css_formats.cljs
@@ -34,6 +34,10 @@
:border-end-start-radius :string-or-size-array
:border-end-end-radius :string-or-size-array
:border-width :border-width
+ :border-block-start-width :border-width
+ :border-inline-end-width :border-width
+ :border-block-end-width :border-width
+ :border-inline-start-width :border-width
:border-style :border-style
:border-color :border-color
:box-shadow :shadows
diff --git a/frontend/src/app/util/code_gen/style_css_values.cljs b/frontend/src/app/util/code_gen/style_css_values.cljs
index 9f53bea5ed..7bfda75b53 100644
--- a/frontend/src/app/util/code_gen/style_css_values.cljs
+++ b/frontend/src/app/util/code_gen/style_css_values.cljs
@@ -213,11 +213,32 @@
(when (and (not (cgc/svg-markup? shape)) (not (cfh/group-shape? shape)) single-fill?)
(fill->color (first fills)))))
+(defn stroke-per-side-widths
+ "Returns [top right bottom left] when the stroke has per-side widths
+ enabled and the sides actually differ; nil otherwise."
+ [stroke]
+ (when (:stroke-per-side stroke)
+ (let [width (:stroke-width stroke)
+ top (d/nilv (:stroke-width-top stroke) width)
+ right (d/nilv (:stroke-width-right stroke) width)
+ bottom (d/nilv (:stroke-width-bottom stroke) width)
+ left (d/nilv (:stroke-width-left stroke) width)]
+ (when-not (= top right bottom left)
+ [top right bottom left]))))
+
(defn- get-border
[shape]
(when-not (cgc/svg-markup? shape)
(get-stroke-data (first (:strokes shape)))))
+(defn- get-border-side-width
+ [shape-or-stroke index]
+ (let [stroke (if (contains? shape-or-stroke :strokes)
+ (first (:strokes shape-or-stroke))
+ shape-or-stroke)]
+ (when-let [widths (stroke-per-side-widths stroke)]
+ {:width (nth widths index)})))
+
(defn- get-border-radius
[{:keys [rx r1 r2 r3 r4] :as shape}]
(cond
@@ -565,6 +586,10 @@
:border-style (get-border-style shape)
:border-width (get-border-width shape)
:border-color (get-border-color shape)
+ :border-block-start-width (get-border-side-width shape 0)
+ :border-inline-end-width (get-border-side-width shape 1)
+ :border-block-end-width (get-border-side-width shape 2)
+ :border-inline-start-width (get-border-side-width shape 3)
:border-radius (get-border-radius shape)
:border-start-start-radius (get-border-start-start-radius shape)
:border-start-end-radius (get-border-start-end-radius shape)
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index fd5440a167..82e3f9a11d 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -8410,6 +8410,30 @@ msgstr "Gap"
msgid "workspace.options.stroke-width"
msgstr "Stroke width"
+#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
+msgid "workspace.options.stroke-width-top"
+msgstr "Top (block start)"
+
+#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
+msgid "workspace.options.stroke-width-right"
+msgstr "Right (inline end)"
+
+#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
+msgid "workspace.options.stroke-width-bottom"
+msgstr "Bottom (block end)"
+
+#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
+msgid "workspace.options.stroke-width-left"
+msgstr "Left (inline start)"
+
+#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
+msgid "workspace.options.stroke.per-side"
+msgstr "Stroke per side"
+
+#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
+msgid "workspace.options.stroke.per-side-disabled"
+msgstr "Strokes per side feature is only available in the new render. Switch render to use it."
+
#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:209
msgid "workspace.options.stroke.add-stroke"
msgstr "Add stroke color"
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index c075237bdd..ca27370228 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -8183,6 +8183,30 @@ msgstr "Color del trazo"
msgid "workspace.options.stroke-width"
msgstr "Ancho del trazo"
+#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
+msgid "workspace.options.stroke-width-top"
+msgstr "Superior (block start)"
+
+#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
+msgid "workspace.options.stroke-width-right"
+msgstr "Derecha (inline end)"
+
+#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
+msgid "workspace.options.stroke-width-bottom"
+msgstr "Inferior (block end)"
+
+#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
+msgid "workspace.options.stroke-width-left"
+msgstr "Izquierda (inline start)"
+
+#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
+msgid "workspace.options.stroke.per-side"
+msgstr "Trazo Por Lado"
+
+#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
+msgid "workspace.options.stroke.per-side-disabled"
+msgstr "Strokes per side feature is only available in the new render. Switch render to use it."
+
#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:209
msgid "workspace.options.stroke.add-stroke"
msgstr "AƱadir color de borde"
diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs
index b5a5a93518..5e5901d53a 100644
--- a/render-wasm/src/render/strokes.rs
+++ b/render-wasm/src/render/strokes.rs
@@ -31,6 +31,12 @@ pub(super) fn draw_stroke_on_rect(
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
+ // Per-side widths render as a band between an outer and an inner rect.
+ if let Some(widths) = stroke.per_side_widths() {
+ draw_per_side_stroke_on_rect(canvas, stroke, rect, corners, &paint, widths, antialias);
+ return;
+ }
+
// By default just draw the rect. Only dotted inner/outer strokes need
// clipping to prevent the dotted pattern from appearing in wrong areas.
let draw_stroke = || match corners {
@@ -97,6 +103,93 @@ pub(super) fn draw_stroke_on_rect(
}
}
+/// Draws a rect/frame stroke whose sides have different widths as the area
+/// between an outer and an inner (rounded) rect, mitered like CSS borders.
+/// The band is filled with the stroke fill; dashed/dotted patterns are not
+/// supported per side and render solid.
+#[allow(clippy::too_many_arguments)]
+fn draw_per_side_stroke_on_rect(
+ canvas: &skia::Canvas,
+ stroke: &Stroke,
+ rect: &Rect,
+ corners: &Option,
+ paint: &skia::Paint,
+ widths: [f32; 4],
+ antialias: bool,
+) {
+ let [top, right, bottom, left] = widths;
+
+ // Fraction of each side width growing outward / inward from the shape
+ // boundary, per stroke alignment.
+ let (out_f, in_f) = match stroke.kind {
+ StrokeKind::Inner => (0.0, 1.0),
+ StrokeKind::Center => (0.5, 0.5),
+ StrokeKind::Outer => (1.0, 0.0),
+ };
+
+ let outer = Rect::from_ltrb(
+ rect.left - left * out_f,
+ rect.top - top * out_f,
+ rect.right + right * out_f,
+ rect.bottom + bottom * out_f,
+ );
+ let inner = Rect::from_ltrb(
+ rect.left + left * in_f,
+ rect.top + top * in_f,
+ rect.right - right * in_f,
+ rect.bottom - bottom * in_f,
+ );
+ // When a side width exceeds the shape dimension the inner rect collapses
+ // and the whole outer rect is filled, like CSS collapsing borders.
+ let has_hole = inner.width() > 0.0 && inner.height() > 0.0;
+
+ let mut fill_paint = paint.clone();
+ fill_paint.set_style(skia::PaintStyle::Fill);
+ fill_paint.set_path_effect(None);
+ fill_paint.set_anti_alias(antialias);
+
+ let mut pb = skia::PathBuilder::new();
+ match corners {
+ Some(radii) => {
+ // Straight (zero-radius) corners stay sharp; rounded ones keep
+ // their curvature parallel to the shape edge, like CSS borders.
+ let grow = |radius: f32, delta: f32| {
+ if radius > 0.0 {
+ radius + delta
+ } else {
+ 0.0
+ }
+ };
+ let shrink = |radius: f32, delta: f32| (radius - delta).max(0.0);
+ // Adjacent side widths per corner, matching the Skia radii order:
+ // [top-left, top-right, bottom-right, bottom-left].
+ let side_deltas = [(left, top), (right, top), (right, bottom), (left, bottom)];
+ let mut outer_radii = *radii;
+ let mut inner_radii = *radii;
+ for (i, (dx, dy)) in side_deltas.into_iter().enumerate() {
+ outer_radii[i].x = grow(outer_radii[i].x, dx * out_f);
+ outer_radii[i].y = grow(outer_radii[i].y, dy * out_f);
+ inner_radii[i].x = shrink(inner_radii[i].x, dx * in_f);
+ inner_radii[i].y = shrink(inner_radii[i].y, dy * in_f);
+ }
+ pb.add_rrect(RRect::new_rect_radii(outer, &outer_radii), None, None);
+ if has_hole {
+ pb.add_rrect(RRect::new_rect_radii(inner, &inner_radii), None, None);
+ }
+ }
+ None => {
+ pb.add_rect(outer, None, None);
+ if has_hole {
+ pb.add_rect(inner, None, None);
+ }
+ }
+ }
+
+ let mut path = pb.detach();
+ path.set_fill_type(skia::PathFillType::EvenOdd);
+ canvas.draw_path(&path, &fill_paint);
+}
+
#[allow(clippy::too_many_arguments)]
pub(super) fn draw_stroke_on_circle(
canvas: &skia::Canvas,
@@ -711,6 +804,7 @@ fn strokes_share_geometry(strokes: &[&Stroke]) -> bool {
strokes.windows(2).all(|pair| {
pair[0].kind == pair[1].kind
&& pair[0].width == pair[1].width
+ && pair[0].widths == pair[1].widths
&& pair[0].style == pair[1].style
&& pair[0].cap_start == pair[1].cap_start
&& pair[0].cap_end == pair[1].cap_end
diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs
index 7ae9fd8cef..75bb653e22 100644
--- a/render-wasm/src/shapes.rs
+++ b/render-wasm/src/shapes.rs
@@ -679,13 +679,13 @@ impl Shape {
pub fn visible_strokes(&self) -> impl DoubleEndedIterator- {
self.strokes
.iter()
- .filter(|stroke| stroke.width > MIN_STROKE_WIDTH)
+ .filter(|stroke| stroke.max_width() > MIN_STROKE_WIDTH)
}
pub fn has_visible_strokes(&self) -> bool {
self.strokes
.iter()
- .any(|stroke| stroke.width > MIN_STROKE_WIDTH)
+ .any(|stroke| stroke.max_width() > MIN_STROKE_WIDTH)
}
pub fn add_stroke(&mut self, s: Stroke) {
@@ -693,6 +693,13 @@ impl Shape {
self.strokes.push(s)
}
+ pub fn set_last_stroke_widths(&mut self, widths: [f32; 4]) -> Result<(), String> {
+ let stroke = self.strokes.last_mut().ok_or("Shape has no strokes")?;
+ stroke.widths = Some(widths);
+ self.invalidate_extrect();
+ Ok(())
+ }
+
pub fn set_stroke_fill(&mut self, f: Fill) -> Result<(), String> {
let stroke = self.strokes.last_mut().ok_or("Shape has no strokes")?;
stroke.fill = f;
diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs
index 48f43d975b..f774a70457 100644
--- a/render-wasm/src/shapes/strokes.rs
+++ b/render-wasm/src/shapes/strokes.rs
@@ -45,6 +45,9 @@ pub struct Stroke {
// default `width + 10` pattern to keep existing designs visually identical.
pub dash: Option,
pub gap: Option,
+ // Per-side widths [top, right, bottom, left] for rects and frames.
+ // `None` means the uniform `width` applies to all sides.
+ pub widths: Option<[f32; 4]>,
}
impl Stroke {
@@ -57,11 +60,34 @@ impl Stroke {
}
}
+ /// The widest side of the stroke: the uniform `width` unless per-side
+ /// widths are set, in which case the maximum of the four sides.
+ pub fn max_width(&self) -> f32 {
+ match self.widths {
+ Some(widths) => widths.into_iter().reduce(f32::max).unwrap_or(self.width),
+ None => self.width,
+ }
+ }
+
+ /// Per-side widths [top, right, bottom, left] when they actually differ.
+ /// Returns `None` when unset or when all sides are equal, so the uniform
+ /// render path (which supports dashed/dotted styles) keeps handling that
+ /// case.
+ pub fn per_side_widths(&self) -> Option<[f32; 4]> {
+ let widths = self.widths?;
+ let [top, right, bottom, left] = widths;
+ if top == right && right == bottom && bottom == left {
+ None
+ } else {
+ Some(widths)
+ }
+ }
+
pub fn bounds_width(&self, is_open: bool) -> f32 {
match self.render_kind(is_open) {
StrokeKind::Inner => 0.,
- StrokeKind::Center => self.width / 2.,
- StrokeKind::Outer => self.width,
+ StrokeKind::Center => self.max_width() / 2.,
+ StrokeKind::Outer => self.max_width(),
}
}
@@ -88,6 +114,7 @@ impl Stroke {
kind: StrokeKind::Center,
dash,
gap,
+ widths: None,
}
}
@@ -108,6 +135,7 @@ impl Stroke {
kind: StrokeKind::Inner,
dash,
gap,
+ widths: None,
}
}
@@ -128,11 +156,17 @@ impl Stroke {
kind: StrokeKind::Outer,
dash,
gap,
+ widths: None,
}
}
pub fn scale_content(&mut self, value: f32) {
self.width *= value;
+ if let Some(widths) = &mut self.widths {
+ for width in widths.iter_mut() {
+ *width *= value;
+ }
+ }
if let Some(dash) = self.dash {
self.dash = Some(dash * value);
}
@@ -417,3 +451,46 @@ fn cap_margin_for_cap(cap: Option, width: f32) -> f32 {
_ => 0.0,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn stroke_with_widths(widths: Option<[f32; 4]>) -> Stroke {
+ let mut stroke = Stroke::new_inner_stroke(2.0, StrokeStyle::Solid, None, None, None, None);
+ stroke.widths = widths;
+ stroke
+ }
+
+ #[test]
+ fn max_width_falls_back_to_uniform_width() {
+ let stroke = stroke_with_widths(None);
+ assert_eq!(stroke.max_width(), 2.0);
+ }
+
+ #[test]
+ fn max_width_uses_widest_side() {
+ let stroke = stroke_with_widths(Some([1.0, 8.0, 3.0, 0.0]));
+ assert_eq!(stroke.max_width(), 8.0);
+ }
+
+ #[test]
+ fn per_side_widths_none_when_all_sides_equal() {
+ let stroke = stroke_with_widths(Some([4.0, 4.0, 4.0, 4.0]));
+ assert_eq!(stroke.per_side_widths(), None);
+ }
+
+ #[test]
+ fn per_side_widths_returns_differing_sides() {
+ let stroke = stroke_with_widths(Some([1.0, 2.0, 3.0, 4.0]));
+ assert_eq!(stroke.per_side_widths(), Some([1.0, 2.0, 3.0, 4.0]));
+ }
+
+ #[test]
+ fn scale_content_scales_per_side_widths() {
+ let mut stroke = stroke_with_widths(Some([1.0, 2.0, 3.0, 4.0]));
+ stroke.scale_content(2.0);
+ assert_eq!(stroke.widths, Some([2.0, 4.0, 6.0, 8.0]));
+ assert_eq!(stroke.width, 4.0);
+ }
+}
diff --git a/render-wasm/src/wasm/strokes.rs b/render-wasm/src/wasm/strokes.rs
index 1c14ac1077..647510ff0b 100644
--- a/render-wasm/src/wasm/strokes.rs
+++ b/render-wasm/src/wasm/strokes.rs
@@ -154,6 +154,15 @@ pub extern "C" fn add_shape_outer_stroke(
});
}
+#[no_mangle]
+pub extern "C" fn set_shape_stroke_sides(top: f32, right: f32, bottom: f32, left: f32) {
+ with_current_shape_mut!(state, |shape: &mut Shape| {
+ shape
+ .set_last_stroke_widths([top, right, bottom, left])
+ .expect("could not set stroke side widths");
+ });
+}
+
#[no_mangle]
pub extern "C" fn add_shape_stroke_fill() {
with_current_shape_mut!(state, |shape: &mut Shape| {