diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 5f6ac22ad3..93e4db19e3 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -137,6 +137,8 @@ [:stroke-style {:optional true} [::sm/one-of #{:solid :dotted :dashed :mixed}]] [:stroke-width {:optional true} ::sm/safe-number] + [:stroke-dash {:optional true} ::sm/safe-number] + [:stroke-gap {:optional true} ::sm/safe-number] [:stroke-alignment {:optional true} [::sm/one-of #{:center :inner :outer}]] [:stroke-cap-start {:optional true} diff --git a/common/src/app/common/types/shape/attrs.cljc b/common/src/app/common/types/shape/attrs.cljc index 7bdc3c17a3..ce4ef75f38 100644 --- a/common/src/app/common/types/shape/attrs.cljc +++ b/common/src/app/common/types/shape/attrs.cljc @@ -45,6 +45,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -151,6 +153,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -206,6 +210,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -261,6 +267,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -315,6 +323,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -433,6 +443,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -488,6 +500,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file diff --git a/frontend/src/app/main/ui/shapes/attrs.cljs b/frontend/src/app/main/ui/shapes/attrs.cljs index 4fcc386e06..61cd5da71e 100644 --- a/frontend/src/app/main/ui/shapes/attrs.cljs +++ b/frontend/src/app/main/ui/shapes/attrs.cljs @@ -19,14 +19,14 @@ [cuerdas.core :as str])) (defn- calculate-dasharray - [style width] + [style width dash gap] (let [w+5 (+ 5 width) w+1 (+ 1 width) w+10 (+ 10 width)] (case style :mixed (str/concat "" w+5 "," w+5 "," w+1 "," w+5) :dotted (str/concat "0," w+5) - :dashed (str/concat "" w+10 "," w+10) + :dashed (str/concat "" (or dash w+10) "," (or gap w+10)) ""))) (defn get-border-props @@ -97,6 +97,8 @@ (let [style (:stroke-style data :solid)] (when-not (= style :none) (let [width (:stroke-width data 1) + dash (:stroke-dash data) + gap (:stroke-gap data) gradient (:stroke-color-gradient data) color (:stroke-color data) opacity (:stroke-opacity data)] @@ -114,7 +116,7 @@ (obj/set! attrs "strokeOpacity" opacity)) (when (not= style :svg) - (obj/set! attrs "strokeDasharray" (calculate-dasharray style width))) + (obj/set! attrs "strokeDasharray" (calculate-dasharray style width dash gap))) ;; For simple line caps we use svg stroke-line-cap attribute. This ;; only works if all caps are the same and we are not using the tricks diff --git a/frontend/src/app/main/ui/shapes/export.cljs b/frontend/src/app/main/ui/shapes/export.cljs index 1ff0f8e6bf..6d13619b93 100644 --- a/frontend/src/app/main/ui/shapes/export.cljs +++ b/frontend/src/app/main/ui/shapes/export.cljs @@ -333,6 +333,8 @@ :penpot:stroke-opacity (d/name (:stroke-opacity stroke)) :penpot:stroke-style (d/name (:stroke-style stroke)) :penpot:stroke-width (d/name (:stroke-width stroke)) + :penpot:stroke-dash ((d/nilf str) (:stroke-dash stroke)) + :penpot:stroke-gap ((d/nilf str) (:stroke-gap stroke)) :penpot:stroke-alignment (d/name (:stroke-alignment stroke)) :penpot:stroke-cap-start (d/name (:stroke-cap-start stroke)) :penpot:stroke-cap-end (d/name (:stroke-cap-end stroke))}]))])))) 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 306963bcd4..5748251fd6 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 @@ -30,6 +30,8 @@ :stroke-style :stroke-alignment :stroke-width + :stroke-dash + :stroke-gap :stroke-color :stroke-color-ref-id :stroke-color-ref-file @@ -113,6 +115,18 @@ (st/emit! (udw/trigger-bounding-box-cloaking ids)) (st/emit! (dc/change-stroke-attrs ids {:stroke-width value} index)))) + on-stroke-dash-change + (fn [index value] + (when-not (str/empty? value) + (st/emit! (udw/trigger-bounding-box-cloaking ids)) + (st/emit! (dc/change-stroke-attrs ids {:stroke-dash value} index)))) + + on-stroke-gap-change + (fn [index value] + (when-not (str/empty? value) + (st/emit! (udw/trigger-bounding-box-cloaking ids)) + (st/emit! (dc/change-stroke-attrs ids {:stroke-gap value} index)))) + open-caps-select (fn [caps-state] (fn [event] @@ -226,6 +240,8 @@ :on-color-change on-color-change :on-color-detach on-color-detach :on-stroke-width-change on-stroke-width-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 :on-stroke-alignment-change on-stroke-alignment-change :open-caps-select open-caps-select 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 723a658466..b535c9c821 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,8 @@ on-color-detach on-remove on-stroke-width-change + on-stroke-dash-change + on-stroke-gap-change on-stroke-style-change on-stroke-alignment-change on-stroke-cap-start-change @@ -106,6 +108,23 @@ ids #{:stroke-width}))) + ;; The SVG renderer defaults dash and gap to `stroke-width + 10` when + ;; unset. Showing that value as placeholder makes the override obvious. + default-dash-gap (when (number? stroke-width) (+ 10 stroke-width)) + + stroke-gap (or (:stroke-gap stroke) default-dash-gap) + stroke-dash (or (:stroke-dash stroke) default-dash-gap) + + on-dash-change + (mf/use-fn + (mf/deps index on-stroke-dash-change) + #(on-stroke-dash-change index %)) + + on-gap-change + (mf/use-fn + (mf/deps index on-stroke-gap-change) + #(on-stroke-gap-change index %)) + stroke-alignment (or (:stroke-alignment stroke) :center) stroke-alignment-options @@ -294,6 +313,30 @@ :disabled hidden? :on-change on-style-change}]])]) + ;; Stroke Dash / Gap (only visible for dashed style) + (when (= stroke-style :dashed) + [:div {:class (stl/css :stroke-dash-options) + :data-testid "stroke.dash-options"} + [:> numeric-input-wrapper* {:on-change on-dash-change + :text-icon "DASH" + :min 0 + :on-focus on-focus + :on-blur on-blur + :attr :stroke-dash + :class (stl/css :numeric-input-wrapper) + :property (tr "workspace.options.stroke-dash") + :value stroke-dash}] + [:> numeric-input-wrapper* {:on-change on-gap-change + :text-icon "GAP" + :min 0 + :on-focus on-focus + :on-blur on-blur + :attr :stroke-gap + :tooltip-placement "top-left" + :class (stl/css :numeric-input-wrapper) + :property (tr "workspace.options.stroke-gap") + :value stroke-gap}]]) + ;; Stroke Caps (when show-caps [:div {:class (stl/css :stroke-caps-options)} 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 c764e60f3f..2c4a9d2dfb 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 @@ -77,6 +77,12 @@ column-gap: var(--sp-xs); } +.stroke-dash-options { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: var(--sp-xs); +} + .stroke-options-tokens { @include sidebar.option-grid-structure; diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 6924456411..6b20f1c9d3 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -724,13 +724,18 @@ style (-> stroke :stroke-style sr/translate-stroke-style) cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap) cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap) + ;; Sentinel -1 means "unset" on the Rust side — keeps the + ;; FFI signature flat while letting the renderer fall back + ;; to its default dash pattern when no override is stored. + dash (or (:stroke-dash stroke) -1) + gap (or (:stroke-gap stroke) -1) offset (mem/alloc types.fills.impl/FILL-U8-SIZE) heap (mem/get-heap-u8) dview (js/DataView. (.-buffer heap))] (case align - :inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end) - :outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end) - (h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end)) + :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)) (cond (some? gradient) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b06f29166c..a43cfbd339 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -7560,6 +7560,14 @@ msgstr "Center" msgid "workspace.options.stroke.dashed" msgstr "Dashed" +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +msgid "workspace.options.stroke-dash" +msgstr "Dash" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +msgid "workspace.options.stroke-gap" +msgstr "Gap" + #: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:138 msgid "workspace.options.stroke.dotted" msgstr "Dotted" diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs index 6b3f9443ed..48f43d975b 100644 --- a/render-wasm/src/shapes/strokes.rs +++ b/render-wasm/src/shapes/strokes.rs @@ -41,6 +41,10 @@ pub struct Stroke { pub cap_end: Option, pub cap_start: Option, pub kind: StrokeKind, + // Dash and gap overrides for the `Dashed` style. `None` falls back to the + // default `width + 10` pattern to keep existing designs visually identical. + pub dash: Option, + pub gap: Option, } impl Stroke { @@ -72,6 +76,8 @@ impl Stroke { style: StrokeStyle, cap_start: Option, cap_end: Option, + dash: Option, + gap: Option, ) -> Self { Stroke { fill: Fill::Solid(SolidColor(skia::Color::TRANSPARENT)), @@ -80,6 +86,8 @@ impl Stroke { cap_end, cap_start, kind: StrokeKind::Center, + dash, + gap, } } @@ -88,6 +96,8 @@ impl Stroke { style: StrokeStyle, cap_start: Option, cap_end: Option, + dash: Option, + gap: Option, ) -> Self { Stroke { fill: Fill::Solid(SolidColor(skia::Color::TRANSPARENT)), @@ -96,6 +106,8 @@ impl Stroke { cap_end, cap_start, kind: StrokeKind::Inner, + dash, + gap, } } @@ -104,6 +116,8 @@ impl Stroke { style: StrokeStyle, cap_start: Option, cap_end: Option, + dash: Option, + gap: Option, ) -> Self { Stroke { fill: Fill::Solid(SolidColor(skia::Color::TRANSPARENT)), @@ -112,11 +126,19 @@ impl Stroke { cap_end, cap_start, kind: StrokeKind::Outer, + dash, + gap, } } pub fn scale_content(&mut self, value: f32) { self.width *= value; + if let Some(dash) = self.dash { + self.dash = Some(dash * value); + } + if let Some(gap) = self.gap { + self.gap = Some(gap * value); + } } /// Returns the clip operation for dotted inner/outer strokes. @@ -256,7 +278,9 @@ impl Stroke { ) } StrokeStyle::Dashed => { - skia::PathEffect::dash(&[self.width + 10., self.width + 10.], 0.) + let dash = self.dash.unwrap_or(self.width + 10.); + let gap = self.gap.unwrap_or(self.width + 10.); + skia::PathEffect::dash(&[dash, gap], 0.) } StrokeStyle::Mixed => skia::PathEffect::dash( &[ diff --git a/render-wasm/src/wasm/strokes.rs b/render-wasm/src/wasm/strokes.rs index 452eb4ee2a..1c14ac1077 100644 --- a/render-wasm/src/wasm/strokes.rs +++ b/render-wasm/src/wasm/strokes.rs @@ -68,8 +68,26 @@ impl TryFrom for StrokeCap { } } +// A negative value means "unset" — the renderer falls back to its default +// dash pattern. We use a sentinel instead of passing a bool because adding +// two f32 params keeps the FFI signature flat and allocation-free. +fn decode_optional(value: f32) -> Option { + if value.is_finite() && value >= 0.0 { + Some(value) + } else { + None + } +} + #[no_mangle] -pub extern "C" fn add_shape_center_stroke(width: f32, style: u8, cap_start: u8, cap_end: u8) { +pub extern "C" fn add_shape_center_stroke( + width: f32, + style: u8, + cap_start: u8, + cap_end: u8, + dash: f32, + gap: f32, +) { let stroke_style = RawStrokeStyle::from(style); let cap_start = RawStrokeCap::from(cap_start); let cap_end = RawStrokeCap::from(cap_end); @@ -80,12 +98,21 @@ pub extern "C" fn add_shape_center_stroke(width: f32, style: u8, cap_start: u8, stroke_style.into(), cap_start.try_into().ok(), cap_end.try_into().ok(), + decode_optional(dash), + decode_optional(gap), )); }); } #[no_mangle] -pub extern "C" fn add_shape_inner_stroke(width: f32, style: u8, cap_start: u8, cap_end: u8) { +pub extern "C" fn add_shape_inner_stroke( + width: f32, + style: u8, + cap_start: u8, + cap_end: u8, + dash: f32, + gap: f32, +) { let stroke_style = RawStrokeStyle::from(style); let cap_start = RawStrokeCap::from(cap_start); let cap_end = RawStrokeCap::from(cap_end); @@ -96,12 +123,21 @@ pub extern "C" fn add_shape_inner_stroke(width: f32, style: u8, cap_start: u8, c stroke_style.into(), cap_start.try_into().ok(), cap_end.try_into().ok(), + decode_optional(dash), + decode_optional(gap), )); }); } #[no_mangle] -pub extern "C" fn add_shape_outer_stroke(width: f32, style: u8, cap_start: u8, cap_end: u8) { +pub extern "C" fn add_shape_outer_stroke( + width: f32, + style: u8, + cap_start: u8, + cap_end: u8, + dash: f32, + gap: f32, +) { let stroke_style = RawStrokeStyle::from(style); let cap_start = RawStrokeCap::from(cap_start); let cap_end = RawStrokeCap::from(cap_end); @@ -112,6 +148,8 @@ pub extern "C" fn add_shape_outer_stroke(width: f32, style: u8, cap_start: u8, c stroke_style.into(), cap_start.try_into().ok(), cap_end.try_into().ok(), + decode_optional(dash), + decode_optional(gap), )); }); }