Add basic individual strokes implementation (#10648)

This commit is contained in:
Elena Torró 2026-07-22 16:13:38 +02:00 committed by GitHub
parent 396d799c71
commit ef3511e519
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 567 additions and 15 deletions

View File

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

View File

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

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-linecap="round">
<rect x="3.69" y="1.34" width="9" height="9" rx=".9"/>
<path d="M1.09 10.94C1.09 12.34 2.09 13.34 3.49 13.34H12.69C14.09 13.34 15.09 12.34 15.09 10.94"/>
</svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-linecap="round">
<rect x="3.59" y="3.84" width="9" height="9" rx=".9"/>
<rect x="1.09" y="1.34" width="14" height="14" rx="2.4"/>
</svg>

After

Width:  |  Height:  |  Size: 242 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-linecap="round">
<rect x="5.09" y="3.5" width="9" height="9" rx=".9"/>
<path d="M4.49 1C3.09 1 2.09 2 2.09 3.4V12.6C2.09 14 3.09 15 4.49 15"/>
</svg>

After

Width:  |  Height:  |  Size: 255 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-linecap="round">
<rect x="2.09" y="3.5" width="9" height="9" rx=".9"/>
<path d="M11.69 15C13.09 15 14.09 14 14.09 12.6V3.4C14.09 2 13.09 1 11.69 1"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-linecap="round">
<rect x="3.5" y="4.34" width="9" height="9" rx=".9"/>
<path d="M15.09 3.74C15.09 2.34 14.09 1.34 12.69 1.34H3.49C2.09 1.34 1.09 2.34 1.09 3.74"/>
</svg>

After

Width:  |  Height:  |  Size: 275 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -679,13 +679,13 @@ impl Shape {
pub fn visible_strokes(&self) -> impl DoubleEndedIterator<Item = &Stroke> {
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;

View File

@ -45,6 +45,9 @@ pub struct Stroke {
// default `width + 10` pattern to keep existing designs visually identical.
pub dash: Option<f32>,
pub gap: Option<f32>,
// 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<StrokeCap>, 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);
}
}

View File

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