mirror of
https://github.com/penpot/penpot.git
synced 2026-07-24 06:58:12 +00:00
✨ Add basic individual strokes implementation (#10648)
This commit is contained in:
parent
396d799c71
commit
ef3511e519
@ -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))
|
||||
|
||||
@ -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}
|
||||
|
||||
4
frontend/resources/images/icons/stroke-bottom.svg
Normal file
4
frontend/resources/images/icons/stroke-bottom.svg
Normal 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 |
4
frontend/resources/images/icons/stroke-extended.svg
Normal file
4
frontend/resources/images/icons/stroke-extended.svg
Normal 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 |
4
frontend/resources/images/icons/stroke-left.svg
Normal file
4
frontend/resources/images/icons/stroke-left.svg
Normal 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 |
4
frontend/resources/images/icons/stroke-right.svg
Normal file
4
frontend/resources/images/icons/stroke-right.svg
Normal 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 |
4
frontend/resources/images/icons/stroke-top.svg
Normal file
4
frontend/resources/images/icons/stroke-top.svg
Normal 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 |
@ -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])
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]}]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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| {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user