diff --git a/common/uxbox/common/pages.cljc b/common/uxbox/common/pages.cljc index f400aa084..81c73d028 100644 --- a/common/uxbox/common/pages.cljc +++ b/common/uxbox/common/pages.cljc @@ -61,6 +61,7 @@ (s/def ::stroke-opacity number?) (s/def ::stroke-style #{:solid :dotted :dashed :mixed :none}) (s/def ::stroke-width number?) +(s/def ::stroke-alignment #{:center :inner :outer}) (s/def ::text-align #{"left" "right" "center" "justify"}) (s/def ::type #{:rect :path :circle :image :text :canvas :curve :icon :frame :group}) (s/def ::x number?) @@ -94,6 +95,7 @@ ::stroke-opacity ::stroke-style ::stroke-width + ::stroke-alignment ::text-align ::width ::height])) diff --git a/frontend/resources/locales.json b/frontend/resources/locales.json index ad1028976..3cf1d3728 100644 --- a/frontend/resources/locales.json +++ b/frontend/resources/locales.json @@ -984,21 +984,21 @@ "unused" : true }, "workspace.options.position" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:130", "src/uxbox/main/ui/workspace/sidebar/options/frame.cljs:126" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:144", "src/uxbox/main/ui/workspace/sidebar/options/frame.cljs:126" ], "translations" : { "en" : "Position", "fr" : "Position" } }, "workspace.options.radius" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:174" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:188" ], "translations" : { "en" : "Radius", "fr" : "TODO" } }, "workspace.options.rotation" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:150" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:164" ], "translations" : { "en" : "Rotation", "fr" : "TODO" @@ -1012,7 +1012,7 @@ "unused" : true }, "workspace.options.size" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:78", "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:106", "src/uxbox/main/ui/workspace/sidebar/options/page.cljs:78", "src/uxbox/main/ui/workspace/sidebar/options/frame.cljs:101" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/page.cljs:78", "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:92", "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:120", "src/uxbox/main/ui/workspace/sidebar/options/frame.cljs:101" ], "translations" : { "en" : "Size", "fr" : "Taille" @@ -1025,35 +1025,53 @@ } }, "workspace.options.stroke" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:74", "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:129" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:81", "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:142" ], "translations" : { "en" : "Stroke", "fr" : null } }, + "workspace.options.stroke.center" : { + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:128" ], + "translations" : { + "en" : "Center" + } + }, "workspace.options.stroke.dashed" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:115" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:136" ], "translations" : { "en" : "Dashed", "fr" : "Tiré" } }, "workspace.options.stroke.dotted" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:114" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:135" ], "translations" : { "en" : "Dotted", "fr" : "Pointillé" } }, + "workspace.options.stroke.inner" : { + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:129" ], + "translations" : { + "en" : "Inside" + } + }, "workspace.options.stroke.mixed" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:116" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:137" ], "translations" : { "en" : "Mixed", "fr" : "Mixte" } }, + "workspace.options.stroke.outer" : { + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:130" ], + "translations" : { + "en" : "Outside" + } + }, "workspace.options.stroke.solid" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:113" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:134" ], "translations" : { "en" : "Solid", "fr" : "Solide" @@ -1192,7 +1210,7 @@ } }, "workspace.viewport.click-to-close-path" : { - "used-in" : [ "src/uxbox/main/ui/workspace/drawarea.cljs:329" ], + "used-in" : [ "src/uxbox/main/ui/workspace/drawarea.cljs:335" ], "translations" : { "en" : "Click to close the path" } diff --git a/frontend/src/uxbox/main/ui/shapes/circle.cljs b/frontend/src/uxbox/main/ui/shapes/circle.cljs index 4f29e99bd..a93bdaf39 100644 --- a/frontend/src/uxbox/main/ui/shapes/circle.cljs +++ b/frontend/src/uxbox/main/ui/shapes/circle.cljs @@ -14,7 +14,8 @@ [uxbox.util.interop :as itr] [uxbox.util.geom.matrix :as gmt] [uxbox.util.geom.point :as gpt] - [uxbox.main.ui.shapes.bounding-box :refer [bounding-box]])) + [uxbox.main.ui.shapes.bounding-box :refer [bounding-box]] + [uxbox.main.ui.shapes.custom-stroke :refer [shape-custom-stroke]])) ;; --- Circle Wrapper @@ -54,4 +55,6 @@ :ry ry :transform transform :id (str "shape-" id)}))] - [:> "ellipse" props])) + [:& shape-custom-stroke {:shape shape + :base-props props + :elem-name "ellipse"}])) diff --git a/frontend/src/uxbox/main/ui/shapes/custom_stroke.cljs b/frontend/src/uxbox/main/ui/shapes/custom_stroke.cljs new file mode 100644 index 000000000..f1f91282b --- /dev/null +++ b/frontend/src/uxbox/main/ui/shapes/custom_stroke.cljs @@ -0,0 +1,92 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) 2016-2019 Andrey Antukh + +(ns uxbox.main.ui.shapes.custom-stroke + (:require + [rumext.alpha :as mf] + [uxbox.util.geom.shapes :as geom] + [uxbox.util.interop :as itr])) + +; The SVG standard does not implement yet the 'stroke-alignment' attribute, to define the position +; of the stroke relative to the stroke axis (inner, center, outer). Here we implement a patch +; to be able to draw the stroke in the three cases. See discussion at: +; https://stackoverflow.com/questions/7241393/can-you-control-how-an-svgs-stroke-width-is-drawn +(mf/defc shape-custom-stroke + {::mf/wrap-props false} + [props] + (let [shape (unchecked-get props "shape") + base-props (unchecked-get props "base-props") + elem-name (unchecked-get props "elem-name") + {:keys [id x y width height]} (geom/shape->rect-shape shape) + stroke-style (:stroke-style shape :none) + stroke-position (:stroke-alignment shape :center)] + + (cond + ; Center alignment (or no stroke): the default in SVG + (or (= stroke-style :none) (= stroke-position :center)) + [:> elem-name base-props] + + ; Inner alignment: display the shape with double width stroke, and clip the result + ; with the original shape without stroke. + (= stroke-position :inner) + (let [clip-id (str "clip-" id) + + clip-props (-> (itr/obj-assign! #js {} base-props) + (itr/obj-assign! #js {:stroke nil + :strokeWidth nil + :strokeOpacity nil + :strokeDasharray nil + :fill "white" + :fillOpacity 1})) + + stroke-width (.-strokeWidth base-props) + shape-props (-> (itr/obj-assign! #js {} base-props) + (itr/obj-assign! #js {:strokeWidth (* stroke-width 2) + :clipPath (str "url('#" clip-id "')")}))] + [:* + [:> "clipPath" #js {:id clip-id} + [:> elem-name clip-props]] + [:> elem-name shape-props]]) + + ; Outer alingmnent: display the shape in two layers. One without stroke (only fill), + ; and another one only with stroke at double width (transparent fill) and passed + ; through a mask that shows the whole shape, but hides the original shape without stroke + (= stroke-position :outer) + (let [mask-id (str "mask-" id) + + stroke-width (.-strokeWidth base-props) + mask-props1 (-> (itr/obj-assign! #js {} base-props) + (itr/obj-assign! #js {:stroke "white" + :strokeWidth (* stroke-width 2) + :strokeOpacity 1 + :strokeDasharray nil + :fill "white" + :fillOpacity 1})) + mask-props2 (-> (itr/obj-assign! #js {} base-props) + (itr/obj-assign! #js {:stroke nil + :strokeWidth nil + :strokeOpacity nil + :strokeDasharray nil + :fill "black" + :fillOpacity 1})) + + shape-props1 (-> (itr/obj-assign! #js {} base-props) + (itr/obj-assign! #js {:stroke nil + :strokeWidth nil + :strokeOpacity nil + :strokeDasharray nil})) + shape-props2 (-> (itr/obj-assign! #js {} base-props) + (itr/obj-assign! #js {:strokeWidth (* stroke-width 2) + :fill "none" + :fillOpacity 0 + :mask (str "url('#" mask-id "')")}))] + [:* + [:> "mask" #js {:id mask-id} + [:> elem-name mask-props1] + [:> elem-name mask-props2]] + [:> elem-name shape-props1] + [:> elem-name shape-props2]])))) + diff --git a/frontend/src/uxbox/main/ui/shapes/path.cljs b/frontend/src/uxbox/main/ui/shapes/path.cljs index 506406f4f..20ffba953 100644 --- a/frontend/src/uxbox/main/ui/shapes/path.cljs +++ b/frontend/src/uxbox/main/ui/shapes/path.cljs @@ -16,7 +16,8 @@ [uxbox.main.ui.shapes.common :as common] [uxbox.util.interop :as itr] [uxbox.util.geom.matrix :as gmt] - [uxbox.main.ui.shapes.bounding-box :refer [bounding-box]])) + [uxbox.main.ui.shapes.bounding-box :refer [bounding-box]] + [uxbox.main.ui.shapes.custom-stroke :refer [shape-custom-stroke]])) ;; --- Path Wrapper @@ -83,5 +84,10 @@ :fill "transparent" :stroke-width "20px" :d pdata}] - [:> "path" props]] - [:> "path" props]))) + [:& shape-custom-stroke {:shape shape + :base-props props + :elem-name "path"}]] + [:& shape-custom-stroke {:shape shape + :base-props props + :elem-name "path"}]))) + diff --git a/frontend/src/uxbox/main/ui/shapes/rect.cljs b/frontend/src/uxbox/main/ui/shapes/rect.cljs index e82b28481..9a6db3f01 100644 --- a/frontend/src/uxbox/main/ui/shapes/rect.cljs +++ b/frontend/src/uxbox/main/ui/shapes/rect.cljs @@ -15,7 +15,8 @@ [uxbox.main.ui.shapes.attrs :as attrs] [uxbox.main.ui.shapes.common :as common] [uxbox.util.interop :as itr] - [uxbox.main.ui.shapes.bounding-box :refer [bounding-box]])) + [uxbox.main.ui.shapes.bounding-box :refer [bounding-box]] + [uxbox.main.ui.shapes.custom-stroke :refer [shape-custom-stroke]])) ;; --- Rect Wrapper @@ -53,4 +54,8 @@ :id (str "shape-" id) :width width :height height}))] - [:> "rect" props])) + + [:& shape-custom-stroke {:shape shape + :base-props props + :elem-name "rect"}])) + diff --git a/frontend/src/uxbox/main/ui/workspace/drawarea.cljs b/frontend/src/uxbox/main/ui/workspace/drawarea.cljs index f2f500a5e..492c25d63 100644 --- a/frontend/src/uxbox/main/ui/workspace/drawarea.cljs +++ b/frontend/src/uxbox/main/ui/workspace/drawarea.cljs @@ -38,7 +38,8 @@ (def ^:private minimal-shapes [{:type :rect :name "Rect" - :stroke-color "#000000"} + :stroke-color "#000000" + :stroke-alignment :center} {:type :image} {:type :icon} {:type :circle @@ -48,17 +49,20 @@ :stroke-style :solid :stroke-color "#000000" :stroke-width 2 + :stroke-alignment :center :fill-color "#000000" :fill-opacity 0 :segments []} {:type :frame :stroke-style :none + :stroke-alignment :center :name "Artboard"} {:type :curve :name "Path" :stroke-style :solid :stroke-color "#000000" :stroke-width 2 + :stroke-alignment :center :fill-color "#000000" :fill-opacity 0 :segments []} diff --git a/frontend/src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs b/frontend/src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs index dde427c5c..ec2a2a096 100644 --- a/frontend/src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs +++ b/frontend/src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs @@ -32,6 +32,13 @@ (d/read-string))] (st/emit! (udw/update-shape (:id shape) {:stroke-style value})))) + on-stroke-alignment-change + (fn [event] + (let [value (-> (dom/get-target event) + (dom/get-value) + (d/read-string))] + (st/emit! (udw/update-shape (:id shape) {:stroke-alignment value})))) + on-stroke-width-change (fn [event] (let [value (-> (dom/get-target event) @@ -120,22 +127,28 @@ :step "1" :on-change on-stroke-opacity-change}]] - ;; Stroke Style & Width + ;; Stroke Width, Alignment & Style [:div.row-flex - [:select#style.input-select {:value (pr-str (:stroke-style shape)) - :on-change on-stroke-style-change} - [:option {:value ":solid"} (t locale "workspace.options.stroke.solid")] - [:option {:value ":dotted"} (t locale "workspace.options.stroke.dotted")] - [:option {:value ":dashed"} (t locale "workspace.options.stroke.dashed")] - [:option {:value ":mixed"} (t locale "workspace.options.stroke.mixed")]] - [:div.input-element.pixels [:input.input-text {:type "number" :min "0" :value (str (-> (:stroke-width shape) (d/coalesce 1) (math/round))) - :on-change on-stroke-width-change}]]]]] + :on-change on-stroke-width-change}]] + + [:select#style.input-select {:value (pr-str (:stroke-alignment shape)) + :on-change on-stroke-alignment-change} + [:option {:value ":center"} (t locale "workspace.options.stroke.center")] + [:option {:value ":inner"} (t locale "workspace.options.stroke.inner")] + [:option {:value ":outer"} (t locale "workspace.options.stroke.outer")]] + + [:select#style.input-select {:value (pr-str (:stroke-style shape)) + :on-change on-stroke-style-change} + [:option {:value ":solid"} (t locale "workspace.options.stroke.solid")] + [:option {:value ":dotted"} (t locale "workspace.options.stroke.dotted")] + [:option {:value ":dashed"} (t locale "workspace.options.stroke.dashed")] + [:option {:value ":mixed"} (t locale "workspace.options.stroke.mixed")]]]]] ;; NO STROKE [:div.element-set