From ef3511e519155b24fcf24327a28a20d259f072b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Wed, 22 Jul 2026 16:13:38 +0200 Subject: [PATCH] :sparkles: Add basic individual strokes implementation (#10648) --- common/src/app/common/flags.cljc | 3 +- common/src/app/common/types/shape.cljc | 6 + .../resources/images/icons/stroke-bottom.svg | 4 + .../images/icons/stroke-extended.svg | 4 + .../resources/images/icons/stroke-left.svg | 4 + .../resources/images/icons/stroke-right.svg | 4 + .../resources/images/icons/stroke-top.svg | 4 + .../src/app/main/data/workspace/colors.cljs | 5 + .../main/ui/ds/foundations/assets/icon.cljs | 5 + .../main/ui/inspect/attributes/geometry.cljs | 4 + .../main/ui/inspect/attributes/stroke.cljs | 24 +++- .../ui/inspect/styles/panels/geometry.cljs | 5 + .../main/ui/inspect/styles/panels/stroke.cljs | 28 ++++- .../sidebar/options/menus/stroke.cljs | 53 +++++++++ .../sidebar/options/rows/stroke_row.cljs | 112 +++++++++++++++++- .../sidebar/options/rows/stroke_row.scss | 29 +++++ frontend/src/app/render_wasm/api/props.cljs | 12 ++ frontend/src/app/util/code_gen/style_css.cljs | 4 + .../app/util/code_gen/style_css_formats.cljs | 4 + .../app/util/code_gen/style_css_values.cljs | 25 ++++ frontend/translations/en.po | 24 ++++ frontend/translations/es.po | 24 ++++ render-wasm/src/render/strokes.rs | 94 +++++++++++++++ render-wasm/src/shapes.rs | 11 +- render-wasm/src/shapes/strokes.rs | 81 ++++++++++++- render-wasm/src/wasm/strokes.rs | 9 ++ 26 files changed, 567 insertions(+), 15 deletions(-) create mode 100644 frontend/resources/images/icons/stroke-bottom.svg create mode 100644 frontend/resources/images/icons/stroke-extended.svg create mode 100644 frontend/resources/images/icons/stroke-left.svg create mode 100644 frontend/resources/images/icons/stroke-right.svg create mode 100644 frontend/resources/images/icons/stroke-top.svg 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| {