diff --git a/common/app/common/pages.cljc b/common/app/common/pages.cljc index 8dee8154db..7f83d54a98 100644 --- a/common/app/common/pages.cljc +++ b/common/app/common/pages.cljc @@ -245,7 +245,8 @@ :internal.shape/height :internal.shape/interactions :internal.shape/selrect - :internal.shape/points])) + :internal.shape/points + :internal.shape/masked-group?])) (def component-sync-attrs {:fill-color :fill-group :fill-color-ref-file :fill-group @@ -270,7 +271,8 @@ :height :size-group :proportion :size-group :rx :radius-group - :ry :radius-group}) + :ry :radius-group + :masked-group? :mask-group}) (s/def ::minimal-shape (s/keys :req-un [::type ::name] diff --git a/frontend/resources/images/icons/mask.svg b/frontend/resources/images/icons/mask.svg new file mode 100644 index 0000000000..3c2e315340 --- /dev/null +++ b/frontend/resources/images/icons/mask.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/styles/main/partials/sidebar-layers.scss b/frontend/resources/styles/main/partials/sidebar-layers.scss index 80d5d03651..6a2cd5780d 100644 --- a/frontend/resources/styles/main/partials/sidebar-layers.scss +++ b/frontend/resources/styles/main/partials/sidebar-layers.scss @@ -144,6 +144,47 @@ } } +.element-list li.masked { + .element-children { + li:first-child { + position: relative; + + &::before { + content: "^"; + font-size: $fs18; + font-family: opensans; + font-weight: lighter; + position: absolute; + top: -5px; + left: -5px; + } + } + + li:last-child { + border-left: none; + position: relative; + + &::before { + content: " "; + border-left: 1px solid $color-gray-40; + height: 1rem; + position: absolute; + top: 0; + left: 0; + } + + &::after { + content: " "; + border-bottom: 1px solid $color-gray-40; + width: 0.3rem; + position: absolute; + top: 1rem; + left: 0; + } + } + } +} + .element-icon { svg { fill: $color-gray-30; diff --git a/frontend/resources/styles/main/partials/sidebar.scss b/frontend/resources/styles/main/partials/sidebar.scss index 5f109810cf..2dad4a320b 100644 --- a/frontend/resources/styles/main/partials/sidebar.scss +++ b/frontend/resources/styles/main/partials/sidebar.scss @@ -158,7 +158,7 @@ $width-settings-bar: 16rem; ul { border-left: 9px solid $color-gray-50; - margin: 0; + margin: 0 0 0 0.4rem; li { border-left: 1px solid $color-gray-40; diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 959f8a0592..ba02229f52 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -1369,6 +1369,69 @@ (dws/prepare-remove-group page-id group objects)] (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))))) +(def mask-group + (ptk/reify ::mask-group + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + objects (dwc/lookup-page-objects state page-id) + selected (get-in state [:workspace-local :selected]) + shapes (dws/shapes-for-grouping objects selected)] + (when-not (empty? shapes) + (let [;; If the selected shape is a group, we can use it. If not, + ;; create a new group and set it as masked. + [group rchanges uchanges] + (if (and (= (count shapes) 1) + (= (:type (first shapes)) :group)) + [(first shapes) [] []] + (dws/prepare-create-group page-id shapes "Group-" true)) + + rchanges (conj rchanges + {:type :mod-obj + :page-id page-id + :id (:id group) + :operations [{:type :set + :attr :masked-group? + :val true}]}) + + uchanges (conj rchanges + {:type :mod-obj + :page-id page-id + :id (:id group) + :operations [{:type :set + :attr :masked-group? + :val nil}]})] + + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) + (dws/select-shapes (d/ordered-set (:id group)))))))))) + +(def unmask-group + (ptk/reify ::unmask-group + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + objects (dwc/lookup-page-objects state page-id) + selected (get-in state [:workspace-local :selected])] + (when (= (count selected) 1) + (let [group (get objects (first selected)) + + rchanges [{:type :mod-obj + :page-id page-id + :id (:id group) + :operations [{:type :set + :attr :masked-group? + :val nil}]}] + + uchanges [{:type :mod-obj + :page-id page-id + :id (:id group) + :operations [{:type :set + :attr :masked-group? + :val (:masked-group? group)}]}]] + + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) + (dws/select-shapes (d/ordered-set (:id group)))))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Interactions @@ -1514,6 +1577,7 @@ "+" #(st/emit! (increase-zoom nil)) "-" #(st/emit! (decrease-zoom nil)) "ctrl+g" #(st/emit! group-selected) + "ctrl+shift+m" #(st/emit! mask-group) "ctrl+k" #(st/emit! dwl/add-component) "shift+g" #(st/emit! ungroup-selected) "shift+0" #(st/emit! reset-zoom) diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 4e28fc2cad..415987521f 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -69,6 +69,7 @@ (def logo-icon (icon-xref :uxbox-logo-icon)) (def lowercase (icon-xref :lowercase)) (def mail (icon-xref :mail)) +(def mask (icon-xref :mask)) (def minus (icon-xref :minus)) (def move (icon-xref :move)) (def msg-error (icon-xref :msg-error)) diff --git a/frontend/src/app/main/ui/shapes/custom_stroke.cljs b/frontend/src/app/main/ui/shapes/custom_stroke.cljs index 8d0784f8f6..e509150dd4 100644 --- a/frontend/src/app/main/ui/shapes/custom_stroke.cljs +++ b/frontend/src/app/main/ui/shapes/custom_stroke.cljs @@ -9,6 +9,7 @@ [rumext.alpha :as mf] [app.common.uuid :as uuid] [app.common.geom.shapes :as geom] + [app.main.ui.shapes.group :refer [mask-id-ctx]] [app.util.object :as obj])) ; The SVG standard does not implement yet the 'stroke-alignment' @@ -23,13 +24,16 @@ base-props (unchecked-get props "base-props") elem-name (unchecked-get props "elem-name") {:keys [x y width height]} (geom/shape->rect-shape shape) + mask-id (mf/use-ctx mask-id-ctx) stroke-id (mf/use-var (uuid/next)) 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] + [:> elem-name (cond-> (obj/merge! #js {} base-props) + (some? mask-id) + (obj/merge! #js {:mask mask-id}))] ;; Inner alignment: display the shape with double width stroke, ;; and clip the result with the original shape without stroke. @@ -49,10 +53,15 @@ shape-props (-> (obj/merge! #js {} base-props) (obj/merge! #js {:strokeWidth (* stroke-width 2) :clipPath (str "url('#" clip-id "')")}))] - [:* - [:> "clipPath" #js {:id clip-id} - [:> elem-name clip-props]] - [:> elem-name shape-props]]) + (if (nil? mask-id) + [:* + [:> "clipPath" #js {:id clip-id} + [:> elem-name clip-props]] + [:> elem-name shape-props]] + [:g {:mask mask-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 @@ -61,7 +70,7 @@ ;; without stroke (= stroke-position :outer) - (let [mask-id (str "mask-" @stroke-id) + (let [stroke-mask-id (str "mask-" @stroke-id) stroke-width (.-strokeWidth ^js base-props) mask-props1 (-> (obj/merge! #js {} base-props) (obj/merge! #js {:stroke "white" @@ -89,11 +98,18 @@ (obj/merge! #js {:strokeWidth (* stroke-width 2) :fill "none" :fillOpacity 0 - :mask (str "url('#" mask-id "')")}))] - [:* - [:mask {:id mask-id} - [:> elem-name mask-props1] - [:> elem-name mask-props2]] - [:> elem-name shape-props1] - [:> elem-name shape-props2]])))) + :mask (str "url('#" stroke-mask-id "')")}))] + (if (nil? mask-id) + [:* + [:mask {:id mask-id} + [:> elem-name mask-props1] + [:> elem-name mask-props2]] + [:> elem-name shape-props1] + [:> elem-name shape-props2]] + [:g {:mask mask-id} + [:mask {:id stroke-mask-id} + [:> elem-name mask-props1] + [:> elem-name mask-props2]] + [:> elem-name shape-props1] + [:> elem-name shape-props2]]))))) diff --git a/frontend/src/app/main/ui/shapes/group.cljs b/frontend/src/app/main/ui/shapes/group.cljs index 5835b89d6a..c261899b8c 100644 --- a/frontend/src/app/main/ui/shapes/group.cljs +++ b/frontend/src/app/main/ui/shapes/group.cljs @@ -10,10 +10,13 @@ (ns app.main.ui.shapes.group (:require [rumext.alpha :as mf] + [cuerdas.core :as str] [app.main.ui.shapes.attrs :as attrs] [app.util.debug :refer [debug?]] [app.common.geom.shapes :as geom])) +(def mask-id-ctx (mf/create-context nil)) + (defn group-shape [shape-wrapper] (mf/fnc group-shape @@ -22,14 +25,26 @@ (let [frame (unchecked-get props "frame") shape (unchecked-get props "shape") childs (unchecked-get props "childs") + mask (if (:masked-group? shape) + (first childs) + nil) + childs (if (:masked-group? shape) + (rest childs) + childs) is-child-selected? (unchecked-get props "is-child-selected?") {:keys [id x y width height]} shape transform (geom/transform-matrix shape)] [:g - (for [item childs] - [:& shape-wrapper {:frame frame - :shape item - :key (:id item)}]) + (when mask + [:defs + [:mask {:id (:id mask)} + [:& shape-wrapper {:frame frame + :shape mask}]]]) + [:& (mf/provider mask-id-ctx) {:value (str/fmt "url(#%s)" (:id mask))} + (for [item childs] + [:& shape-wrapper {:frame frame + :shape item + :key (:id item)}])] (when (not is-child-selected?) [:rect {:transform transform :x x diff --git a/frontend/src/app/main/ui/shapes/icon.cljs b/frontend/src/app/main/ui/shapes/icon.cljs index dbf759393b..bba2c92518 100644 --- a/frontend/src/app/main/ui/shapes/icon.cljs +++ b/frontend/src/app/main/ui/shapes/icon.cljs @@ -12,6 +12,7 @@ [rumext.alpha :as mf] [app.common.geom.shapes :as geom] [app.main.ui.shapes.attrs :as attrs] + [app.main.ui.shapes.group :refer [mask-id-ctx]] [app.util.object :as obj])) (mf/defc icon-shape @@ -20,6 +21,7 @@ (let [shape (unchecked-get props "shape") {:keys [id x y width height metadata rotation content]} shape + mask-id (mf/use-ctx mask-id-ctx) transform (geom/transform-matrix shape) vbox (apply str (interpose " " (:view-box metadata))) @@ -33,6 +35,7 @@ :height height :viewBox vbox :preserveAspectRatio "none" + :mask mask-id :dangerouslySetInnerHTML #js {:__html content}}))] [:g {:transform transform} [:> "svg" props]])) @@ -41,7 +44,9 @@ [{:keys [shape] :as props}] (let [{:keys [content id metadata]} shape view-box (apply str (interpose " " (:view-box metadata))) + mask-id (mf/use-ctx mask-id-ctx) props {:viewBox view-box :id (str "shape-" id) + :mask mask-id :dangerouslySetInnerHTML #js {:__html content}}] [:& "svg" props])) diff --git a/frontend/src/app/main/ui/shapes/image.cljs b/frontend/src/app/main/ui/shapes/image.cljs index 4fbe7d6ed3..0380ba3431 100644 --- a/frontend/src/app/main/ui/shapes/image.cljs +++ b/frontend/src/app/main/ui/shapes/image.cljs @@ -13,6 +13,7 @@ [app.config :as cfg] [app.common.geom.shapes :as geom] [app.main.ui.shapes.attrs :as attrs] + [app.main.ui.shapes.group :refer [mask-id-ctx]] [app.util.object :as obj] [app.main.ui.context :as muc] [app.main.data.fetch :as df] @@ -26,6 +27,7 @@ {:keys [id x y width height rotation metadata]} shape uri (cfg/resolve-media-path (:path metadata)) embed-resources? (mf/use-ctx muc/embed-ctx) + mask-id (mf/use-ctx mask-id-ctx) data-uri (mf/use-state (when (not embed-resources?) uri))] (mf/use-effect @@ -44,7 +46,8 @@ :id (str "shape-" id) :width width :height height - :preserveAspectRatio "none"}))] + :preserveAspectRatio "none" + :mask mask-id}))] (if (nil? @data-uri) [:> "rect" (obj/merge! props diff --git a/frontend/src/app/main/ui/shapes/path.cljs b/frontend/src/app/main/ui/shapes/path.cljs index bead937a4d..a591df4871 100644 --- a/frontend/src/app/main/ui/shapes/path.cljs +++ b/frontend/src/app/main/ui/shapes/path.cljs @@ -13,6 +13,7 @@ [rumext.alpha :as mf] [app.main.ui.shapes.attrs :as attrs] [app.main.ui.shapes.custom-stroke :refer [shape-custom-stroke]] + [app.main.ui.shapes.group :refer [mask-id-ctx]] [app.common.geom.shapes :as geom] [app.util.object :as obj])) @@ -45,6 +46,7 @@ (let [shape (unchecked-get props "shape") background? (unchecked-get props "background?") {:keys [id x y width height]} (geom/shape->rect-shape shape) + mask-id (mf/use-ctx mask-id-ctx) transform (geom/transform-matrix shape) pdata (render-path shape) props (-> (attrs/extract-style-attrs shape) @@ -53,7 +55,7 @@ :id (str "shape-" id) :d pdata}))] (if background? - [:g + [:g {:mask mask-id} [:path {:stroke "transparent" :fill "transparent" :stroke-width "20px" @@ -63,5 +65,6 @@ :elem-name "path"}]] [:& shape-custom-stroke {:shape shape :base-props props + :mask mask-id :elem-name "path"}]))) diff --git a/frontend/src/app/main/ui/shapes/text.cljs b/frontend/src/app/main/ui/shapes/text.cljs index ba773fc449..240c0d0adb 100644 --- a/frontend/src/app/main/ui/shapes/text.cljs +++ b/frontend/src/app/main/ui/shapes/text.cljs @@ -13,6 +13,7 @@ [app.main.data.fetch :as df] [app.main.fonts :as fonts] [app.main.ui.context :as muc] + [app.main.ui.shapes.group :refer [mask-id-ctx]] [app.common.data :as d] [app.common.geom.shapes :as geom] [app.common.geom.matrix :as gmt] @@ -224,6 +225,7 @@ [props] (let [shape (unchecked-get props "shape") selected? (unchecked-get props "selected?") + mask-id (mf/use-ctx mask-id-ctx) {:keys [id x y width height rotation content]} shape] [:foreignObject {:x x :y y @@ -231,6 +233,7 @@ :transform (geom/transform-matrix shape) :id (str id) :width width - :height height} + :height height + :mask mask-id} [:& text-content {:content (:content shape)}]])) diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index cc109cc50d..697fc02730 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -62,6 +62,8 @@ do-unlock-shape #(st/emit! (dw/update-shape-flags id {:blocked false})) do-create-group #(st/emit! dw/group-selected) do-remove-group #(st/emit! dw/ungroup-selected) + do-mask-group #(st/emit! dw/mask-group) + do-unmask-group #(st/emit! dw/unmask-group) do-add-component #(st/emit! dwl/add-component) do-detach-component #(st/emit! (dwl/detach-component id)) do-reset-component #(st/emit! (dwl/reset-component id)) @@ -98,14 +100,26 @@ [:& menu-separator] (when (> (count selected) 1) - [:& menu-entry {:title "Group" - :shortcut "Ctrl + g" - :on-click do-create-group}]) + [:* + [:& menu-entry {:title "Group" + :shortcut "Ctrl + g" + :on-click do-create-group}] + [:& menu-entry {:title "Mask" + :shortcut "Ctrl + Shift + M" + :on-click do-mask-group}]]) (when (and (= (count selected) 1) (= (:type shape) :group)) - [:& menu-entry {:title "Ungroup" - :shortcut "Shift + g" - :on-click do-remove-group}]) + [:* + [:& menu-entry {:title "Ungroup" + :shortcut "Shift + g" + :on-click do-remove-group}] + (if (:masked-group? shape) + [:& menu-entry {:title "Unmask" + :shortcut "Ctrl + Shift + M" + :on-click do-unmask-group}] + [:& menu-entry {:title "Mask" + :shortcut "Ctrl + Shift + M" + :on-click do-mask-group}])]) (if (:hidden shape) [:& menu-entry {:title "Show" diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index 5080656f53..19fbad4b9c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -43,9 +43,11 @@ :rect i/box :curve i/curve :text i/text - :group (if (nil? (:component-id shape)) - i/folder - i/component) + :group (if (some? (:component-id shape)) + i/component + (if (:masked-group? shape) + i/mask + i/folder)) nil)) ;; --- Layer Name @@ -196,6 +198,7 @@ :ref dref :class (dom/classnames :component (not (nil? (:component-id item))) + :masked (:masked-group? item) :dnd-over (= (:over dprops) :center) :dnd-over-top (= (:over dprops) :top) :dnd-over-bot (= (:over dprops) :bot) @@ -307,7 +310,8 @@ :component-file :shape-ref :touched - :metadata])] + :metadata + :masked-group?])] (persistent! (reduce-kv (fn [res id obj] (assoc! res id (strip-data obj)))