From 802f19453de8f2d3801c3247b7eb612f1f86c0a8 Mon Sep 17 00:00:00 2001 From: "alonso.torres" <alonso.torres@kaleidos.net> Date: Thu, 7 Jan 2021 12:26:55 +0100 Subject: [PATCH] :sparkles: Upload SVG as shapes --- backend/src/app/media.clj | 2 +- backend/src/app/rpc/mutations/media.clj | 2 +- backend/src/app/svgparse.clj | 20 ++- common/app/common/geom/shapes.cljc | 4 + common/app/common/geom/shapes/rect.cljc | 9 ++ common/app/common/geom/shapes/transforms.cljc | 20 +-- common/app/common/pages/init.cljc | 9 +- docker/devenv/Dockerfile | 3 +- frontend/resources/locales.json | 6 + frontend/src/app/main/data/workspace.cljs | 126 +++++++++++++++--- .../src/app/main/data/workspace/common.cljs | 54 ++++---- .../app/main/data/workspace/persistence.cljs | 97 +++++++++++--- frontend/src/app/main/exports.cljs | 37 +++-- frontend/src/app/main/ui/context.cljs | 1 + frontend/src/app/main/ui/handoff/render.cljs | 55 +++++--- frontend/src/app/main/ui/shapes/attrs.cljs | 15 ++- frontend/src/app/main/ui/shapes/filters.cljs | 48 +------ frontend/src/app/main/ui/shapes/svg_raw.cljs | 123 +++++++++++++++++ frontend/src/app/main/ui/viewer/shapes.cljs | 81 +++++++---- .../app/main/ui/workspace/left_toolbar.cljs | 20 ++- .../src/app/main/ui/workspace/shapes.cljs | 34 +++-- .../app/main/ui/workspace/shapes/svg_raw.cljs | 93 +++++++++++++ .../app/main/ui/workspace/sidebar/assets.cljs | 8 +- .../app/main/ui/workspace/sidebar/layers.cljs | 1 + .../main/ui/workspace/sidebar/options.cljs | 20 +-- .../workspace/sidebar/options/multiple.cljs | 8 ++ .../ui/workspace/sidebar/options/svg_raw.cljs | 114 ++++++++++++++++ .../src/app/main/ui/workspace/viewport.cljs | 73 ++++++---- 28 files changed, 839 insertions(+), 244 deletions(-) create mode 100644 frontend/src/app/main/ui/shapes/svg_raw.cljs create mode 100644 frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs create mode 100644 frontend/src/app/main/ui/workspace/sidebar/options/svg_raw.cljs diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index 969138b24..9affa541c 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -125,7 +125,7 @@ (ex/raise :type :validation :code :media-type-mismatch :hint (str "Seems like you are uploading a file whose content does not match the extension." - "Expected: " mtype "Got: " mtype'))) + "Expected: " mtype ". Got: " mtype'))) {:width (.getImageWidth instance) :height (.getImageHeight instance) :mtype mtype'}))) diff --git a/backend/src/app/rpc/mutations/media.clj b/backend/src/app/rpc/mutations/media.clj index f48befc59..66c332aa4 100644 --- a/backend/src/app/rpc/mutations/media.clj +++ b/backend/src/app/rpc/mutations/media.clj @@ -84,7 +84,7 @@ :thumbnail-id (:id thumb) :width (:width source-info) :height (:height source-info) - :mtype (:mtype source-info)}))) + :mtype source-mtype}))) ;; --- Create File Media Object (from URL) diff --git a/backend/src/app/svgparse.clj b/backend/src/app/svgparse.clj index 05d05dc9f..6bf81f2c7 100644 --- a/backend/src/app/svgparse.clj +++ b/backend/src/app/svgparse.clj @@ -11,6 +11,7 @@ (:require [app.common.exceptions :as ex] [app.common.spec :as us] + [cuerdas.core :as str] [app.metrics :as mtx] [clojure.java.io :as io] [clojure.java.shell :as shell] @@ -25,10 +26,25 @@ [^String data] (IOUtils/toInputStream data "UTF-8")) +(defn- stream->string + [input] + (with-open [istream (io/input-stream input)] + (-> (IOUtils/toString input "UTF-8")))) + (defn- clean-svg [^InputStream input] - (let [result (shell/sh "svgcleaner" "-c" "-" :in input :out-enc :bytes)] - (when (not= 0 (:exit result)) + (let [result (shell/sh + ;; "svgcleaner" "--allow-bigger-file" "-c" "-" + "svgo" + "--enable=prefixIds,removeDimensions,removeXMLNS,removeScriptElement" + "--disable=removeViewBox,moveElemsAttrsToGroup" + "-i" "-" "-o" "-" + + :in input :out-enc :bytes) + err-str (:err result)] + (when (or (not= 0 (:exit result)) + ;; svgcleaner returns 0 with some errors, we need to check + (and (not= err-str "") (not (nil? err-str)) (str/starts-with? err-str "Error"))) (ex/raise :type :validation :code :unable-to-optimize :hint (:err result))) diff --git a/common/app/common/geom/shapes.cljc b/common/app/common/geom/shapes.cljc index f99994ee7..ccc26db3d 100644 --- a/common/app/common/geom/shapes.cljc +++ b/common/app/common/geom/shapes.cljc @@ -261,9 +261,13 @@ (d/export gco/center-selrect) (d/export gco/center-rect) (d/export gco/center-points) + (d/export gpr/rect->selrect) (d/export gpr/rect->points) (d/export gpr/points->selrect) +(d/export gpr/points->rect) +(d/export gpr/center->rect) + (d/export gtr/transform-shape) (d/export gtr/transform-matrix) (d/export gtr/inverse-transform-matrix) diff --git a/common/app/common/geom/shapes/rect.cljc b/common/app/common/geom/shapes/rect.cljc index b7a56e6fa..80e06ef11 100644 --- a/common/app/common/geom/shapes/rect.cljc +++ b/common/app/common/geom/shapes/rect.cljc @@ -58,3 +58,12 @@ :width (- maxx minx) :height (- maxy miny)})) +(defn center->rect [center width height] + (assert (gpt/point center)) + (assert (and (number? width) (> width 0))) + (assert (and (number? height) (> height 0))) + + {:x (- (:x center) (/ width 2)) + :y (- (:y center) (/ height 2)) + :width width + :height height}) diff --git a/common/app/common/geom/shapes/transforms.cljc b/common/app/common/geom/shapes/transforms.cljc index 698524501..4e0743797 100644 --- a/common/app/common/geom/shapes/transforms.cljc +++ b/common/app/common/geom/shapes/transforms.cljc @@ -26,17 +26,17 @@ "Returns a transformation matrix without changing the shape properties. The result should be used in a `transform` attribute in svg" ([shape] (transform-matrix shape nil)) - ([{:keys [flip-x flip-y] :as shape} {:keys [no-flip]}] - (let [shape-center (or (gco/center-shape shape) - (gpt/point 0 0))] - (-> (gmt/matrix) - (gmt/translate shape-center) + ([shape params] (transform-matrix shape params (or (gco/center-shape shape) + (gpt/point 0 0)))) + ([{:keys [flip-x flip-y] :as shape} {:keys [no-flip]} shape-center] + (-> (gmt/matrix) + (gmt/translate shape-center) - (gmt/multiply (:transform shape (gmt/matrix))) - (cond-> - (and (not no-flip) flip-x) (gmt/scale (gpt/point -1 1)) - (and (not no-flip) flip-y) (gmt/scale (gpt/point 1 -1))) - (gmt/translate (gpt/negate shape-center)))))) + (gmt/multiply (:transform shape (gmt/matrix))) + (cond-> + (and (not no-flip) flip-x) (gmt/scale (gpt/point -1 1)) + (and (not no-flip) flip-y) (gmt/scale (gpt/point 1 -1))) + (gmt/translate (gpt/negate shape-center))))) (defn inverse-transform-matrix ([shape] diff --git a/common/app/common/pages/init.cljc b/common/app/common/pages/init.cljc index 0eab6ae86..38267aaf5 100644 --- a/common/app/common/pages/init.cljc +++ b/common/app/common/pages/init.cljc @@ -31,8 +31,7 @@ :pages-index {}}) (def default-shape-attrs - {:fill-color default-color - :fill-opacity 1}) + {}) (def default-frame-attrs {:frame-id uuid/zero @@ -55,8 +54,6 @@ {:type :image} - {:type :icon} - {:type :circle :name "Circle" :fill-color default-color @@ -89,7 +86,9 @@ {:type :text :name "Text" - :content nil}]) + :content nil} + + {:type :svg-raw}]) (defn make-minimal-shape [type] diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 036abeb9d..75f7b7ab1 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -133,7 +133,8 @@ RUN set -ex; \ mv /tmp/node/node-$NODE_VERSION-linux-x64 /usr/local/nodejs; \ chown -R root /usr/local/nodejs; \ /usr/local/nodejs/bin/npm install -g yarn; \ - rm -rf /tmp/node; + /usr/local/nodejs/bin/npm install -g svgo; \ + rm -rf /tmp/node; RUN set -ex; \ cd /tmp; \ diff --git a/frontend/resources/locales.json b/frontend/resources/locales.json index 1deab1ad9..90883d4eb 100644 --- a/frontend/resources/locales.json +++ b/frontend/resources/locales.json @@ -1282,6 +1282,12 @@ }, "unused" : true }, + "handoff.tabs.code.selected.svg-raw" : { + "translations" : { + "en" : "SVG" + }, + "unused" : true + }, "handoff.tabs.info" : { "used-in" : [ "src/app/main/ui/handoff/right_sidebar.cljs:59" ], "translations" : { diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 198c814a4..1591f3299 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -568,14 +568,11 @@ (let [{:keys [width height]} data [vbc-x vbc-y] (viewport-center state) - x (:x data (- vbc-x (/ width 2))) y (:y data (- vbc-y (/ height 2))) - page-id (:current-page-id state) frame-id (-> (dwc/lookup-page-objects state page-id) (cp/frame-id-by-position {:x frame-x :y frame-y})) - shape (-> (cp/make-minimal-shape type) (merge data) (merge {:x x :y y}) @@ -1417,33 +1414,119 @@ (dwc/add-shape shape) (dwc/commit-undo-transaction)))))) -(defn- image-uploaded - [image] - (let [{:keys [x y]} @ms/mouse-position - {:keys [width height]} image - shape {:name (:name image) - :width width - :height height - :x (- x (/ width 2)) - :y (- y (/ height 2)) - :metadata {:width width - :height height - :id (:id image) - :path (:path image)}}] - (st/emit! (create-and-add-shape :image x y shape)))) +(defn image-upload [image x y] + (ptk/reify ::add-image + ptk/WatchEvent + (watch [_ state stream] + (let [{:keys [name width height id mtype]} image + shape {:name name + :width width + :height height + :x (- x (/ width 2)) + :y (- y (/ height 2)) + :metadata {:width width + :height height + :mtype mtype + :id id}}] + (rx/of (create-and-add-shape :image x y shape)))))) + + +(defn- svg-dimensions [data] + (let [width (get-in data [:attrs :width] 100) + height (get-in data [:attrs :height] 100) + viewbox (get-in data [:attrs :viewBox] (str "0 0 " width " " height)) + [_ _ width-str height-str] (str/split viewbox " ") + width (d/parse-integer width-str) + height (d/parse-integer height-str)] + [width height])) + +(defn svg-upload [data x y] + (ptk/reify ::svg-upload + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + objects (dwc/lookup-page-objects state page-id) + frame-id (cp/frame-id-by-position objects {:x x :y y}) + + [width height] (svg-dimensions data) + x (- x (/ width 2)) + y (- y (/ height 2)) + + create-svg-raw + (fn [{:keys [tag] :as data} unames root-id] + (let [base (cond (string? tag) tag + (keyword? tag) (name tag) + (nil? tag) "node" + :else (str tag))] + (-> {:id (uuid/next) + :type :svg-raw + :name (dwc/generate-unique-name unames (str "svg-" base)) + :frame-id frame-id + ;; For svg children we set its coordinates as the root of the svg + :width width + :height height + :x x + :y y + :content data + :root-id root-id} + (gsh/setup-selrect)))) + + add-svg-child + (fn add-svg-child [parent-id root-id [unames [rchs uchs]] [index {:keys [content] :as data}]] + (let [shape (create-svg-raw data unames root-id) + shape-id (:id shape) + [rch1 uch1] (dwc/add-shape-changes page-id shape) + + ;; Mov-objects won't have undo because we "delete" the object in the undo of the + ;; previous operation + rch2 [{:type :mov-objects + :parent-id parent-id + :frame-id frame-id + :page-id page-id + :index index + :shapes [shape-id]}] + + ;; Careful! the undo changes are concatenated reversed (we undo in reverse order + changes [(d/concat rchs rch1 rch2) (d/concat uch1 uchs)] + unames (conj unames (:name shape))] + (reduce (partial add-svg-child shape-id root-id) [unames changes] (d/enumerate (:content data))))) + + unames (dwc/retrieve-used-names objects) + + svg-name (->> (str/replace (:name data) ".svg" "") + (dwc/generate-unique-name unames)) + + root-shape (create-svg-raw data unames nil) + root-shape (-> root-shape + (assoc :name svg-name)) + root-id (:id root-shape) + + changes (dwc/add-shape-changes page-id root-shape) + + [_ [rchanges uchanges]] (reduce (partial add-svg-child root-id root-id) [unames changes] (d/enumerate (:content data)))] + (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) + (dwc/select-shapes (d/ordered-set root-id))) + )))) (defn- paste-image [image] (ptk/reify ::paste-bin-impl ptk/WatchEvent (watch [_ state stream] - (let [file-id (get-in state [:workspace-file :id]) + (let [response-sb (rx/subject) + file-id (get-in state [:workspace-file :id]) params {:file-id file-id :local? true :data [image]}] - (rx/of (dwp/upload-media-objects - (with-meta params - {:on-success image-uploaded}))))))) + (rx/concat (rx/of (dwp/upload-media-objects + (with-meta params + {:on-image + #(let [{:keys [x y]} @ms/mouse-position] + (rx/push! response-sb (image-upload % x y))) + :on-svg + #(let [{:keys [x y]} @ms/mouse-position] + (rx/push! response-sb (svg-upload % x y)))}))) + (rx/take 1 response-sb)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Interactions @@ -1536,6 +1619,7 @@ :value previus-color}] {:commit-local? true})))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Exports ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/data/workspace/common.cljs b/frontend/src/app/main/data/workspace/common.cljs index 31597600b..2d27d17c8 100644 --- a/frontend/src/app/main/data/workspace/common.cljs +++ b/frontend/src/app/main/data/workspace/common.cljs @@ -522,6 +522,27 @@ (update-in [:workspace-local :hover] disj id) (update :workspace-local dissoc :edition)))))) +(defn add-shape-changes + [page-id attrs] + (let [id (:id attrs) + frame-id (:frame-id attrs) + shape (gpr/setup-proportions attrs) + + default-attrs (if (= :frame (:type shape)) + cp/default-frame-attrs + cp/default-shape-attrs) + shape (merge default-attrs shape) + + redo-changes [{:type :add-obj + :id id + :page-id page-id + :frame-id frame-id + :obj shape}] + undo-changes [{:type :del-obj + :page-id page-id + :id id}]] + + [redo-changes undo-changes])) (defn add-shape [attrs] @@ -532,36 +553,21 @@ (let [page-id (:current-page-id state) objects (lookup-page-objects state page-id) - id (or (:id attrs) (uuid/next)) - shape (gpr/setup-proportions attrs) - - unames (retrieve-used-names objects) - name (generate-unique-name unames (:name shape)) - + id (or (:id attrs) (uuid/next)) + name (-> objects + (retrieve-used-names) + (generate-unique-name (:name attrs))) frame-id (if (= :frame (:type attrs)) uuid/zero (or (:frame-id attrs) (cp/frame-id-by-position objects attrs))) - shape (merge - (if (= :frame (:type shape)) - cp/default-frame-attrs - cp/default-shape-attrs) - (assoc shape - :id id - :name name)) - - rchange {:type :add-obj - :id id - :page-id page-id - :frame-id frame-id - :obj shape} - uchange {:type :del-obj - :page-id page-id - :id id}] - + [rchanges uchanges] (add-shape-changes page-id (assoc attrs + :id id + :frame-id frame-id + :name name))] (rx/concat - (rx/of (commit-changes [rchange] [uchange] {:commit-local? true}) + (rx/of (commit-changes rchanges uchanges {:commit-local? true}) (select-shapes (d/ordered-set id))) (when (= :text (:type attrs)) (->> (rx/of (start-edition-mode id)) diff --git a/frontend/src/app/main/data/workspace/persistence.cljs b/frontend/src/app/main/data/workspace/persistence.cljs index 8bc50dce4..75e489b2c 100644 --- a/frontend/src/app/main/data/workspace/persistence.cljs +++ b/frontend/src/app/main/data/workspace/persistence.cljs @@ -9,6 +9,8 @@ (ns app.main.data.workspace.persistence (:require + [cuerdas.core :as str] + [app.util.http :as http] [app.common.data :as d] [app.common.geom.point :as gpt] [app.common.media :as cm] @@ -345,23 +347,54 @@ (s/def ::name ::us/string) (s/def ::uri ::us/string) (s/def ::uris (s/coll-of ::uri)) +(s/def ::mtype ::us/string) (s/def ::upload-media-objects (s/and (s/keys :req-un [::file-id ::local?] - :opt-in [::name ::data ::uris]) + :opt-in [::name ::data ::uris ::mtype]) (fn [props] (or (contains? props :data) (contains? props :uris))))) +(defn parse-svg [text] + (->> (http/send! {:method :post + :uri "/api/svg" + :headers {"content-type" "image/svg+xml"} + :body text}) + (rx/map (fn [{:keys [status body]}] + (let [result (t/decode body)] + (if (= status 200) + result + (throw result))))))) + +(defn fetch-svg [uri] + (->> (http/send! {:method :get :uri uri}) + (rx/map :body))) + (defn upload-media-objects - [{:keys [file-id local? data name uris] :as params}] + [{:keys [file-id local? data name uris mtype svg-as-images] :as params}] (us/assert ::upload-media-objects params) (ptk/reify ::upload-media-objects ptk/WatchEvent (watch [_ state stream] - (let [{:keys [on-success on-error] - :or {on-success identity}} (meta params) + (let [{:keys [on-image on-svg on-error] + :or {on-image identity + on-svg identity}} (meta params) + + svg? (fn [blob] + (= (.-type blob) "image/svg+xml")) + + svg-url? (fn [url] + (or (and mtype (= mtype "image/svg+xml")) + (str/ends-with? url ".svg"))) + + url-name (fn [url] + (let [query-idx (str/last-index-of url "?") + url (if (> query-idx 0) (subs url 0 query-idx) url) + filename (->> (str/split url "/") (last)) + ext-idx (str/last-index-of filename ".")] + (if (> ext-idx 0) (subs filename 0 ext-idx) filename))) prepare-file (fn [blob] @@ -376,22 +409,54 @@ {:file-id file-id :is-local local? :url uri - :name name})] + :name (or name (url-name uri))}) + + file-stream + (when data + (->> (rx/from data) + (rx/map di/validate-file))) + + image-stream + (cond + (seq uris) + + (rx/merge + (->> (rx/from uris) + (rx/filter (comp not svg-url?)) + (rx/map prepare-uri) + (rx/mapcat #(rp/mutation! :create-file-media-object-from-url %)) + (rx/do on-image)) + + (->> (rx/from uris) + (rx/filter svg-url?) + (rx/merge-map fetch-svg) + (rx/merge-map parse-svg) + (rx/with-latest vector uris) + (rx/map #(assoc (first %) :name (or name (url-name (second %))))) + (rx/do on-svg))) + + :else + (rx/merge + (->> file-stream + (rx/filter #(or svg-as-images (not (svg? %)))) + (rx/map prepare-file) + (rx/mapcat #(rp/mutation! :upload-file-media-object %)) + (rx/do on-image)) + + (->> file-stream + (rx/filter #(and (not svg-as-images) (svg? %))) + (rx/merge-map #(.text %)) + (rx/merge-map parse-svg) + (rx/with-latest vector file-stream) + (rx/map #(assoc (first %) :name (.-name (second %)))) + (rx/do on-svg))))] (rx/concat (rx/of (dm/show {:content (tr "media.loading") :type :info :timeout nil :tag :media-loading})) - (->> (if (seq uris) - (->> (rx/from uris) - (rx/map prepare-uri) - (rx/mapcat #(rp/mutation! :create-file-media-object-from-url %))) - (->> (rx/from data) - (rx/map di/validate-file) - (rx/map prepare-file) - (rx/mapcat #(rp/mutation! :upload-file-media-object %)))) - (rx/do on-success) + (->> image-stream (rx/catch (fn [error] (cond (= (:code error) :media-type-not-allowed) @@ -400,6 +465,9 @@ (= (:code error) :media-type-mismatch) (rx/of (dm/error (tr "errors.media-type-mismatch"))) + (= (:code error) :unable-to-optimize) + (rx/of (dm/error (:hint error))) + (fn? on-error) (do (on-error error) @@ -410,7 +478,6 @@ (rx/finalize (fn [] (st/emit! (dm/hide-tag :media-loading)))))))))) - ;; --- Upload File Media objects (s/def ::object-id ::us/uuid) diff --git a/frontend/src/app/main/exports.cljs b/frontend/src/app/main/exports.cljs index 8166ab334..70fce4c10 100644 --- a/frontend/src/app/main/exports.cljs +++ b/frontend/src/app/main/exports.cljs @@ -27,6 +27,7 @@ [app.main.ui.shapes.rect :as rect] [app.main.ui.shapes.text :as text] [app.main.ui.shapes.group :as group] + [app.main.ui.shapes.svg-raw :as svg-raw] [app.main.ui.shapes.shape :refer [shape-container]])) (def ^:private default-color "#E8E9EA") ;; $color-canvas @@ -77,26 +78,42 @@ :is-child-selected? true :childs childs}])))) +(defn svg-raw-wrapper-factory + [objects] + (let [shape-wrapper (shape-wrapper-factory objects) + svg-raw-shape (svg-raw/svg-raw-shape shape-wrapper)] + (mf/fnc svg-raw-wrapper + [{:keys [shape frame] :as props}] + (let [childs (mapv #(get objects %) (:shapes shape))] + [:& svg-raw-shape {:frame frame + :shape shape + :childs childs}])))) + (defn shape-wrapper-factory [objects] (mf/fnc shape-wrapper [{:keys [frame shape] :as props}] (let [group-wrapper (mf/use-memo (mf/deps objects) #(group-wrapper-factory objects)) + svg-raw-wrapper (mf/use-memo (mf/deps objects) #(svg-raw-wrapper-factory objects)) frame-wrapper (mf/use-memo (mf/deps objects) #(frame-wrapper-factory objects))] (when (and shape (not (:hidden shape))) (let [shape (-> (gsh/transform-shape shape) (gsh/translate-to-frame frame)) opts #js {:shape shape}] - [:> shape-container {:shape shape} - (case (:type shape) - :text [:> text/text-shape opts] - :rect [:> rect/rect-shape opts] - :path [:> path/path-shape opts] - :image [:> image/image-shape opts] - :circle [:> circle/circle-shape opts] - :frame [:> frame-wrapper {:shape shape}] - :group [:> group-wrapper {:shape shape :frame frame}] - nil)]))))) + (if (and (= :svg-raw (:type shape)) + (not= :svg (get-in shape [:content :tag]))) + [:> svg-raw-wrapper {:shape shape :frame frame}] + [:> shape-container {:shape shape} + (case (:type shape) + :text [:> text/text-shape opts] + :rect [:> rect/rect-shape opts] + :path [:> path/path-shape opts] + :image [:> image/image-shape opts] + :circle [:> circle/circle-shape opts] + :frame [:> frame-wrapper {:shape shape}] + :group [:> group-wrapper {:shape shape :frame frame}] + :svg-raw [:> svg-raw-wrapper {:shape shape :frame frame}] + nil)])))))) (defn get-viewbox [{:keys [x y width height] :or {x 0 y 0 width 100 height 100}}] (str/fmt "%s %s %s %s" x y width height)) diff --git a/frontend/src/app/main/ui/context.cljs b/frontend/src/app/main/ui/context.cljs index 7e063282b..fcc077bc7 100644 --- a/frontend/src/app/main/ui/context.cljs +++ b/frontend/src/app/main/ui/context.cljs @@ -13,6 +13,7 @@ (def embed-ctx (mf/create-context false)) (def render-ctx (mf/create-context nil)) +(def def-ctx (mf/create-context false)) (def current-route (mf/create-context nil)) (def current-team-id (mf/create-context nil)) diff --git a/frontend/src/app/main/ui/handoff/render.cljs b/frontend/src/app/main/ui/handoff/render.cljs index 7af944e29..3f2d5dea4 100644 --- a/frontend/src/app/main/ui/handoff/render.cljs +++ b/frontend/src/app/main/ui/handoff/render.cljs @@ -25,6 +25,7 @@ [app.main.ui.shapes.circle :as circle] [app.main.ui.shapes.frame :as frame] [app.main.ui.shapes.group :as group] + [app.main.ui.shapes.svg-raw :as svg-raw] [app.main.ui.shapes.image :as image] [app.main.ui.shapes.path :as path] [app.main.ui.shapes.rect :as rect] @@ -63,14 +64,19 @@ childs (unchecked-get props "childs") frame (unchecked-get props "frame")] - [:> shape-container {:shape shape - :on-mouse-enter (handle-hover-shape shape true) - :on-mouse-leave (handle-hover-shape shape false) - :on-click (select-shape shape)} - [:& component {:shape shape - :frame frame - :childs childs - :is-child-selected? true}]]))) + (if (and (= :svg-raw (:type shape)) + (not= :svg (get-in shape [:content :tag]))) + [:& component {:shape shape + :frame frame + :childs childs}] + [:> shape-container {:shape shape + :on-mouse-enter (handle-hover-shape shape true) + :on-mouse-leave (handle-hover-shape shape false) + :on-click (select-shape shape)} + [:& component {:shape shape + :frame frame + :childs childs + :is-child-selected? true}]])))) (defn frame-container-factory [objects] @@ -105,6 +111,21 @@ (obj/merge! #js {:childs childs}))] [:> group-wrapper props])))) +(defn svg-raw-container-factory + [objects] + (let [shape-container (shape-container-factory objects) + svg-raw-shape (svg-raw/svg-raw-shape shape-container) + svg-raw-wrapper (shape-wrapper-factory svg-raw-shape)] + (mf/fnc group-container + {::mf/wrap-props false} + [props] + (let [shape (unchecked-get props "shape") + childs (mapv #(get objects %) (:shapes shape)) + props (-> (obj/new) + (obj/merge! props) + (obj/merge! #js {:childs childs}))] + [:> svg-raw-wrapper props])))) + (defn shape-container-factory [objects show-interactions?] (let [path-wrapper (shape-wrapper-factory path/path-shape) @@ -119,19 +140,23 @@ frame (unchecked-get props "frame") group-container (mf/use-memo (mf/deps objects) - #(group-container-factory objects))] + #(group-container-factory objects)) + svg-raw-container (mf/use-memo + (mf/deps objects) + #(svg-raw-container-factory objects))] (when (and shape (not (:hidden shape))) (let [shape (-> (geom/transform-shape shape) (geom/translate-to-frame frame)) opts #js {:shape shape :frame frame}] (case (:type shape) - :text [:> text-wrapper opts] - :rect [:> rect-wrapper opts] - :path [:> path-wrapper opts] - :image [:> image-wrapper opts] - :circle [:> circle-wrapper opts] - :group [:> group-container opts]))))))) + :text [:> text-wrapper opts] + :rect [:> rect-wrapper opts] + :path [:> path-wrapper opts] + :image [:> image-wrapper opts] + :circle [:> circle-wrapper opts] + :group [:> group-container opts] + :svg-raw [:> svg-raw-container opts]))))))) (defn adjust-frame-position [frame-id objects] (let [frame (get objects frame-id) diff --git a/frontend/src/app/main/ui/shapes/attrs.cljs b/frontend/src/app/main/ui/shapes/attrs.cljs index 27083b0ab..e536ea6e4 100644 --- a/frontend/src/app/main/ui/shapes/attrs.cljs +++ b/frontend/src/app/main/ui/shapes/attrs.cljs @@ -23,15 +23,22 @@ nil)) (defn add-border-radius [attrs shape] - (obj/merge! attrs #js {:rx (:rx shape) - :ry (:ry shape)})) + (if (or (:rx shape) (:ry shape)) + (obj/merge! attrs #js {:rx (:rx shape) + :ry (:ry shape)}) + attrs)) (defn add-fill [attrs shape render-id] (let [fill-color-gradient-id (str "fill-color-gradient_" render-id)] - (if (:fill-color-gradient shape) + (cond + (:fill-color-gradient shape) (obj/merge! attrs #js {:fill (str/format "url(#%s)" fill-color-gradient-id)}) + + (or (:fill-color shape) (:fill-opacity shape)) (obj/merge! attrs #js {:fill (or (:fill-color shape) "transparent") - :fillOpacity (:fill-opacity shape nil)})))) + :fillOpacity (:fill-opacity shape nil)}) + + :else attrs))) (defn add-stroke [attrs shape render-id] (let [stroke-style (:stroke-style shape :none) diff --git a/frontend/src/app/main/ui/shapes/filters.cljs b/frontend/src/app/main/ui/shapes/filters.cljs index 140b5fb57..9b3457a37 100644 --- a/frontend/src/app/main/ui/shapes/filters.cljs +++ b/frontend/src/app/main/ui/shapes/filters.cljs @@ -9,12 +9,12 @@ (ns app.main.ui.shapes.filters (:require - [rumext.alpha :as mf] - [cuerdas.core :as str] - [app.util.color :as color] [app.common.data :as d] [app.common.math :as mth] - [app.common.uuid :as uuid])) + [app.common.uuid :as uuid] + [app.util.color :as color] + [cuerdas.core :as str] + [rumext.alpha :as mf])) (defn get-filter-id [] (str "filter_" (uuid/next))) @@ -109,37 +109,6 @@ :in2 filter-in :result filter-id}]) -(defn filter-bounds [shape filter-entry] - (let [{:keys [x y width height]} (:selrect shape) - {:keys [offset-x offset-y blur spread] :or {offset-x 0 offset-y 0 blur 0 spread 0}} (:params filter-entry) - filter-x (min x (+ x offset-x (- spread) (- blur) -5)) - filter-y (min y (+ y offset-y (- spread) (- blur) -5)) - filter-width (+ width (mth/abs offset-x) (* spread 2) (* blur 2) 10) - filter-height (+ height (mth/abs offset-x) (* spread 2) (* blur 2) 10)] - {:x1 filter-x - :y1 filter-y - :x2 (+ filter-x filter-width) - :y2 (+ filter-y filter-height)})) - -(defn get-filters-bounds - [shape filters blur-value] - - (let [filter-bounds (->> filters - (filter #(= :drop-shadow (:type %))) - (map (partial filter-bounds shape) )) - ;; We add the selrect so the minimum size will be the selrect - filter-bounds (conj filter-bounds (:selrect shape)) - x1 (apply min (map :x1 filter-bounds)) - y1 (apply min (map :y1 filter-bounds)) - x2 (apply max (map :x2 filter-bounds)) - y2 (apply max (map :y2 filter-bounds)) - - x1 (- x1 (* blur-value 2)) - x2 (+ x2 (* blur-value 2)) - y1 (- y1 (* blur-value 2)) - y2 (+ y2 (* blur-value 2))] - [x1 y1 (- x2 x1) (- y2 y1)])) - (defn blur-filters [type value] (->> [value] (remove :hidden) @@ -185,18 +154,11 @@ (->> shape :blur (blur-filters :layer-blur))) ;; Adds the previous filter as `filter-in` parameter - filters (map #(assoc %1 :filter-in %2) filters (cons nil (map :id filters))) - - [filter-x filter-y filter-width filter-height] (get-filters-bounds shape filters (or (-> shape :blur :value) 0))] + filters (map #(assoc %1 :filter-in %2) filters (cons nil (map :id filters)))] [:* (when (> (count filters) 2) [:filter {:id filter-id - :x filter-x - :y filter-y - :width filter-width - :height filter-height - :filterUnits "userSpaceOnUse" :color-interpolation-filters "sRGB"} (for [entry filters] diff --git a/frontend/src/app/main/ui/shapes/svg_raw.cljs b/frontend/src/app/main/ui/shapes/svg_raw.cljs new file mode 100644 index 000000000..c79d848ed --- /dev/null +++ b/frontend/src/app/main/ui/shapes/svg_raw.cljs @@ -0,0 +1,123 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.shapes.svg-raw + (:require + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.main.ui.shapes.attrs :as usa] + [app.util.data :as d] + [app.util.object :as obj] + [cuerdas.core :as str] + [rumext.alpha :as mf])) + +(defn clean-attrs + "Transforms attributes to their react equivalent" + [attrs] + (letfn [(transform-key [key] + (-> (name key) + (str/replace ":" "-") + (str/camel) + (keyword))) + + (format-styles [style-str] + (->> (str/split style-str ";") + (map str/trim) + (map #(str/split % ":")) + (group-by first) + (map (fn [[key val]] + (vector + (transform-key key) + (second (first val))))) + (into {}))) + + (map-fn [[key val]] + (cond + (= key :style) [key (format-styles val)] + :else (vector (transform-key key) val)))] + + (->> attrs + (map map-fn) + (into {})))) + +(defn vbox->rect + "Converts the viewBox into a rectangle" + [vbox] + (when vbox + (let [[x y width height] (map d/parse-float (str/split vbox " "))] + {:x x :y y :width width :height height}))) + +(defn vbox-center [shape] + (let [vbox-rect (-> (get-in shape [:content :attrs :viewBox] "0 0 100 100") + (vbox->rect))] + (gsh/center-rect vbox-rect))) + +(defn vbox-bounds [shape] + (let [vbox-rect (-> (get-in shape [:content :attrs :viewBox] "0 0 100 100") + (vbox->rect)) + vbox-center (gsh/center-rect vbox-rect) + transform (gsh/transform-matrix shape nil vbox-center)] + (-> (gsh/rect->points vbox-rect) + (gsh/transform-points vbox-center transform) + (gsh/points->rect))) ) + +(defn transform-viewbox [shape] + (let [center (vbox-center shape) + bounds (vbox-bounds shape) + {:keys [x y width height]} (gsh/center->rect center (:width bounds) (:height bounds))] + (str x " " y " " width " " height))) + +(defn svg-raw-shape [shape-wrapper] + (mf/fnc svg-raw-shape + {::mf/wrap-props false} + [props] + (let [frame (unchecked-get props "frame") + shape (unchecked-get props "shape") + childs (unchecked-get props "childs") + + {:keys [tag attrs] :as content} (:content shape) + + attrs (obj/merge! (clj->js (clean-attrs attrs)) + (usa/extract-style-attrs shape))] + + (cond + ;; Root SVG TAG + (and (map? content) (= tag :svg)) + (let [;; {:keys [x y width height]} (-> (:points shape) gsh/points->selrect) + {:keys [x y width height]} shape + attrs (-> attrs + (obj/set! "x" x) + (obj/set! "y" y) + (obj/set! "width" width) + (obj/set! "height" height) + (obj/set! "preserveAspectRatio" "none") + #_(obj/set! "viewBox" (transform-viewbox shape)))] + + [:g.svg-raw {:transform (gsh/transform-matrix shape)} + [:> "svg" attrs + (for [item childs] + [:& shape-wrapper {:frame frame + :shape item + :key (:id item)}])]]) + + ;; Other tags different than root + (map? content) + [:> (name tag) attrs + (for [item childs] + [:& shape-wrapper {:frame frame + :shape item + :key (:id item)}])] + + ;; String content + (string? content) content + + :else nil)))) + + diff --git a/frontend/src/app/main/ui/viewer/shapes.cljs b/frontend/src/app/main/ui/viewer/shapes.cljs index d27dfe525..b6d43c7e6 100644 --- a/frontend/src/app/main/ui/viewer/shapes.cljs +++ b/frontend/src/app/main/ui/viewer/shapes.cljs @@ -20,6 +20,7 @@ [app.main.ui.shapes.circle :as circle] [app.main.ui.shapes.frame :as frame] [app.main.ui.shapes.group :as group] + [app.main.ui.shapes.svg-raw :as svg-raw] [app.main.ui.shapes.image :as image] [app.main.ui.shapes.path :as path] [app.main.ui.shapes.rect :as rect] @@ -56,22 +57,27 @@ (mf/deps shape) #(on-mouse-down % shape))] - [:> shape-container {:shape shape - :on-mouse-down on-mouse-down - :cursor (when (seq (:interactions shape)) "pointer")} - [:& component {:shape shape - :frame frame - :childs childs - :is-child-selected? true}] - (when (and (:interactions shape) show-interactions?) - [:rect {:x (- x 1) - :y (- y 1) - :width (+ width 2) - :height (+ height 2) - :fill "#31EFB8" - :stroke "#31EFB8" - :stroke-width 1 - :fill-opacity 0.2}])]))) + (if (and (= :svg-raw (:type shape)) + (not= :svg (get-in shape [:content :tag]))) + [:& component {:shape shape + :frame frame + :childs childs}] + [:> shape-container {:shape shape + :on-mouse-down on-mouse-down + :cursor (when (seq (:interactions shape)) "pointer")} + [:& component {:shape shape + :frame frame + :childs childs + :is-child-selected? true}] + (when (and (:interactions shape) show-interactions?) + [:rect {:x (- x 1) + :y (- y 1) + :width (+ width 2) + :height (+ height 2) + :fill "#31EFB8" + :stroke "#31EFB8" + :stroke-width 1 + :fill-opacity 0.2}])])))) (defn frame-wrapper [shape-container show-interactions?] @@ -81,6 +87,10 @@ [shape-container show-interactions?] (generic-wrapper-factory (group/group-shape shape-container) show-interactions?)) +(defn svg-raw-wrapper + [shape-container show-interactions?] + (generic-wrapper-factory (svg-raw/svg-raw-shape shape-container) show-interactions?)) + (defn rect-wrapper [show-interactions?] (generic-wrapper-factory rect/rect-shape show-interactions?)) @@ -133,6 +143,20 @@ :show-interactions? show-interactions?})] [:> group-wrapper props])))) +(defn svg-raw-container-factory + [objects show-interactions?] + (let [shape-container (shape-container-factory objects show-interactions?) + svg-raw-wrapper (svg-raw-wrapper shape-container show-interactions?)] + (mf/fnc svg-raw-container + {::mf/wrap-props false} + [props] + (let [shape (unchecked-get props "shape") + childs (mapv #(get objects %) (:shapes shape)) + props (obj/merge! #js {} props + #js {:childs childs + :show-interactions? show-interactions?})] + [:> svg-raw-wrapper props])))) + (defn shape-container-factory [objects show-interactions?] (let [path-wrapper (path-wrapper show-interactions?) @@ -144,8 +168,11 @@ {::mf/wrap-props false} [props] (let [group-container (mf/use-memo - (mf/deps objects) - #(group-container-factory objects show-interactions?)) + (mf/deps objects) + #(group-container-factory objects show-interactions?)) + svg-raw-container (mf/use-memo + (mf/deps objects) + #(svg-raw-container-factory objects show-interactions?)) shape (unchecked-get props "shape") frame (unchecked-get props "frame")] (when (and shape (not (:hidden shape))) @@ -153,15 +180,15 @@ (geom/translate-to-frame frame)) opts #js {:shape shape}] (case (:type shape) - :frame [:g.empty] - :text [:> text-wrapper opts] - :rect [:> rect-wrapper opts] - :path [:> path-wrapper opts] - :image [:> image-wrapper opts] - :circle [:> circle-wrapper opts] - :group [:> group-container - {:shape shape - :frame frame}]))))))) + :frame [:g.empty] + :text [:> text-wrapper opts] + :rect [:> rect-wrapper opts] + :path [:> path-wrapper opts] + :image [:> image-wrapper opts] + :circle [:> circle-wrapper opts] + :group [:> group-container {:shape shape :frame frame}] + :svg-raw [:> svg-raw-container {:shape shape :frame frame}] + ))))))) (mf/defc frame-svg {::mf/wrap [mf/memo]} diff --git a/frontend/src/app/main/ui/workspace/left_toolbar.cljs b/frontend/src/app/main/ui/workspace/left_toolbar.cljs index 12e3606e8..4f57c9b2d 100644 --- a/frontend/src/app/main/ui/workspace/left_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/left_toolbar.cljs @@ -29,18 +29,15 @@ on-click (mf/use-callback #(dom/click (mf/ref-val ref))) - on-uploaded + handle-image-upload (mf/use-callback (fn [image] - (->> {:name (:name image) - :width (:width image) - :height (:height image) - :metadata {:width (:width image) - :height (:height image) - :mtype (:mtype image) - :id (:id image)}} - (dw/create-and-add-shape :image 0 0) - (st/emit!)))) + (st/emit! (dw/image-upload image 0 0)))) + + handle-svg-upload + (mf/use-callback + (fn [svg] + (st/emit! (dw/svg-upload svg 0 0)))) on-files-selected (mf/use-callback @@ -50,7 +47,8 @@ (with-meta {:file-id (:id file) :local? true :data (seq blobs)} - {:on-success on-uploaded})))))] + {:on-image handle-image-upload + :on-svg handle-svg-upload})))))] [:li.tooltip.tooltip-right {:alt (tr "workspace.toolbar.image") diff --git a/frontend/src/app/main/ui/workspace/shapes.cljs b/frontend/src/app/main/ui/workspace/shapes.cljs index ec3fb5d5c..92e1d7563 100644 --- a/frontend/src/app/main/ui/workspace/shapes.cljs +++ b/frontend/src/app/main/ui/workspace/shapes.cljs @@ -28,6 +28,7 @@ [app.main.ui.workspace.shapes.common :as common] [app.main.ui.workspace.shapes.frame :as frame] [app.main.ui.workspace.shapes.group :as group] + [app.main.ui.workspace.shapes.svg-raw :as svg-raw] [app.main.ui.workspace.shapes.path :as path] [app.main.ui.workspace.shapes.text :as text] [app.util.object :as obj] @@ -37,6 +38,7 @@ [rumext.alpha :as mf])) (declare group-wrapper) +(declare svg-raw-wrapper) (declare frame-wrapper) (def circle-wrapper (common/generic-wrapper-factory circle/circle-shape)) @@ -85,22 +87,28 @@ (when (and shape (or ghost? (not moving?)) (not (:hidden shape))) - [:g.shape-wrapper {:style {:cursor (if alt? cur/duplicate nil)}} - (case (:type shape) - :path [:> path/path-wrapper opts] - :text [:> text/text-wrapper opts] - :group [:> group-wrapper opts] - :rect [:> rect-wrapper opts] - :image [:> image-wrapper opts] - :circle [:> circle-wrapper opts] + (if (and (= (:type shape) :svg-raw) + (not= :svg (get-in shape [:content :tag]))) + ;; When we don't want to add a wrapper to internal raw svg elements + [:> svg-raw-wrapper opts] + [:g.shape-wrapper {:style {:cursor (if alt? cur/duplicate nil)}} + (case (:type shape) + :path [:> path/path-wrapper opts] + :text [:> text/text-wrapper opts] + :group [:> group-wrapper opts] + :rect [:> rect-wrapper opts] + :image [:> image-wrapper opts] + :circle [:> circle-wrapper opts] + :svg-raw [:> svg-raw-wrapper opts] - ;; Only used when drawing a new frame. - :frame [:> frame-wrapper {:shape shape}] - nil) + ;; Only used when drawing a new frame. + :frame [:> frame-wrapper {:shape shape}] + nil) - (when (debug? :bounding-boxes) - [:& bounding-box {:shape shape :frame frame}])]))) + (when (debug? :bounding-boxes) + [:& bounding-box {:shape shape :frame frame}])])))) (def group-wrapper (group/group-wrapper-factory shape-wrapper)) +(def svg-raw-wrapper (svg-raw/svg-raw-wrapper-factory shape-wrapper)) (def frame-wrapper (frame/frame-wrapper-factory shape-wrapper)) diff --git a/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs b/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs new file mode 100644 index 000000000..037b76e37 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs @@ -0,0 +1,93 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.workspace.shapes.svg-raw + (:require + [app.main.refs :as refs] + [app.main.ui.shapes.svg-raw :as svg-raw] + [app.main.ui.shapes.shape :refer [shape-container]] + [app.main.ui.workspace.effects :as we] + [rumext.alpha :as mf] + [app.common.geom.shapes :as gsh] + [app.main.ui.context :as muc])) + +;; This is a list of svg tags that can be grouped in shape-container +;; this allows them to have gradients, shadows and masks +(def svg-elements #{:svg :circle :ellipse :image :line :path :polygon :polyline :rect :symbol :text :textPath}) + +(defn- svg-raw-wrapper-factory-equals? + [np op] + (let [n-shape (unchecked-get np "shape") + o-shape (unchecked-get op "shape") + n-frame (unchecked-get np "frame") + o-frame (unchecked-get op "frame")] + (and (= n-frame o-frame) + (= n-shape o-shape)))) + +(defn svg-raw-wrapper-factory + [shape-wrapper] + (let [svg-raw-shape (svg-raw/svg-raw-shape shape-wrapper)] + (mf/fnc svg-raw-wrapper + {::mf/wrap [#(mf/memo' % svg-raw-wrapper-factory-equals?)] + ::mf/wrap-props false} + [props] + (let [shape (unchecked-get props "shape") + frame (unchecked-get props "frame") + + {:keys [id x y width height]} shape + + childs-ref (mf/use-memo (mf/deps shape) #(refs/objects-by-id (:shapes shape))) + childs (mf/deref childs-ref) + + {:keys [id x y width height]} shape + transform (gsh/transform-matrix shape) + + tag (get-in shape [:content :tag]) + + handle-mouse-down (we/use-mouse-down shape) + handle-context-menu (we/use-context-menu shape) + handle-pointer-enter (we/use-pointer-enter shape) + handle-pointer-leave (we/use-pointer-leave shape) + + def-ctx? (mf/use-ctx muc/def-ctx)] + + (cond + (and (contains? svg-elements tag) (not def-ctx?)) + [:> shape-container { :shape shape } + [:& svg-raw-shape + {:frame frame + :shape shape + :childs childs}] + + (when (= tag :svg) + [:rect.group-actions + {:x x + :y y + :transform transform + :width width + :height height + :fill "transparent" + :on-mouse-down handle-mouse-down + :on-context-menu handle-context-menu + :on-pointer-over handle-pointer-enter + :on-pointer-out handle-pointer-leave}])] + + ;; We cannot wrap inside groups the shapes that go inside the defs tag + ;; we use the context so we know when we should not render the container + (= tag :defs) + [:& (mf/provider muc/def-ctx) {:value true} + [:& svg-raw-shape {:frame frame + :shape shape + :childs childs}]] + + :else + [:& svg-raw-shape {:frame frame + :shape shape + :childs childs}]))))) + diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs index 43a79f4c9..4490d25c6 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs @@ -166,8 +166,9 @@ (fn [blobs] (let [params (with-meta {:file-id file-id :local? false - :data (seq blobs)} - {:on-success on-media-uploaded})] + :data (seq blobs) + :svg-as-images true} + {:on-image on-media-uploaded})] (st/emit! (dw/upload-media-objects params))))) on-delete @@ -212,9 +213,10 @@ on-drag-start (mf/use-callback - (fn [{:keys [name id]} event] + (fn [{:keys [name id mtype]} event] (dnd/set-data! event "text/asset-id" (str id)) (dnd/set-data! event "text/asset-name" name) + (dnd/set-data! event "text/asset-type" mtype) (dnd/set-allowed-effect! event "move")))] [:div.asset-group diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index 6fa7ecfad..ed2855291 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -45,6 +45,7 @@ (if (:masked-group? shape) i/mask i/folder)) + :svg-raw i/file-svg nil)) ;; --- Layer Name diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs index 2e61b3da5..571e76de7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs @@ -29,7 +29,7 @@ [app.main.ui.workspace.sidebar.options.path :as path] [app.main.ui.workspace.sidebar.options.rect :as rect] [app.main.ui.workspace.sidebar.options.text :as text] - [app.main.ui.workspace.sidebar.options.text :as text] + [app.main.ui.workspace.sidebar.options.svg-raw :as svg-raw] [app.util.i18n :as i18n :refer [tr t]] [app.util.object :as obj] [beicon.core :as rx] @@ -42,14 +42,15 @@ [{:keys [shape shapes-with-children page-id file-id]}] [:* (case (:type shape) - :frame [:& frame/options {:shape shape}] - :group [:& group/options {:shape shape :shape-with-children shapes-with-children}] - :text [:& text/options {:shape shape}] - :rect [:& rect/options {:shape shape}] - :icon [:& icon/options {:shape shape}] - :circle [:& circle/options {:shape shape}] - :path [:& path/options {:shape shape}] - :image [:& image/options {:shape shape}] + :frame [:& frame/options {:shape shape}] + :group [:& group/options {:shape shape :shape-with-children shapes-with-children}] + :text [:& text/options {:shape shape}] + :rect [:& rect/options {:shape shape}] + :icon [:& icon/options {:shape shape}] + :circle [:& circle/options {:shape shape}] + :path [:& path/options {:shape shape}] + :image [:& image/options {:shape shape}] + :svg-raw [:& svg-raw/options {:shape shape}] nil) [:& exports-menu {:shape shape @@ -105,3 +106,4 @@ :page-id page-id :section section}])) + diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/multiple.cljs index 1db91f69a..3dd87282b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/multiple.cljs @@ -72,6 +72,14 @@ :text :ignore} :circle + {:measure :shape + :fill :shape + :shadow :shape + :blur :shape + :stroke :shape + :text :ignore} + + :svg-raw {:measure :shape :fill :shape :shadow :shape diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/svg_raw.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/svg_raw.cljs new file mode 100644 index 000000000..b570a3707 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/options/svg_raw.cljs @@ -0,0 +1,114 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.main.ui.workspace.sidebar.options.svg-raw + (:require + [rumext.alpha :as mf] + [cuerdas.core :as str] + [app.util.data :as d] + [app.main.ui.workspace.sidebar.options.measures :refer [measure-attrs measures-menu]] + [app.main.ui.workspace.sidebar.options.fill :refer [fill-attrs fill-menu]] + [app.main.ui.workspace.sidebar.options.stroke :refer [stroke-attrs stroke-menu]] + [app.main.ui.workspace.sidebar.options.shadow :refer [shadow-menu]] + [app.main.ui.workspace.sidebar.options.blur :refer [blur-menu]])) + +;; This is a list of svg tags that can be grouped in shape-container +;; this allows them to have gradients, shadows and masks +(def svg-elements #{:svg :circle :ellipse :image :line :path :polygon :polyline :rect :symbol :text :textPath}) + +(defn hex->number [hex] 1) +(defn shorthex->longhex [hex] + (let [[_ r g b] hex] + (str "#" r r g g b b))) + +(defn parse-color [color] + (cond + (or (not color) (= color "none")) nil + + (and (str/starts-with? color "#") (= (count color) 4)) + {:color (shorthex->longhex color) + :opacity 1} + + (and (str/starts-with? color "#") (= (count color) 9)) + {:color (subs color 1 6) + :opacity (-> (subs color 7 2) (hex->number))} + + ;; TODO CHECK IF IT'S A GRADIENT + + :else nil)) + + +(defn get-fill-values [shape] + (let [fill-values (or (select-keys shape fill-attrs)) + color (-> (get-in shape [:content :attrs :fill]) + (parse-color)) + + fill-values (if (and (empty? fill-values) color) + {:fill-color (:color color) + :fill-opacity (:opacity color)} + fill-values)] + fill-values)) + +(defn get-stroke-values [shape] + (let [stroke-values (or (select-keys shape stroke-attrs)) + color (-> (get-in shape [:content :attrs :stroke]) + (parse-color)) + + stroke-color (:color color "#000000") + stroke-opacity (:opacity color 1) + stroke-style (-> (get-in shape [:content :attrs :stroke-style] (if color "solid" "none")) + keyword) + stroke-alignment :center + stroke-width (-> (get-in shape [:content :attrs :stroke-width] "1") + (d/parse-int)) + + stroke-values (if (empty? stroke-values) + {:stroke-color stroke-color + :stroke-opacity stroke-opacity + :stroke-style stroke-style + :stroke-alignment stroke-alignment + :stroke-width stroke-width} + + stroke-values)] + stroke-values)) + +(mf/defc options + {::mf/wrap [mf/memo]} + [{:keys [shape] :as props}] + + (let [ids [(:id shape)] + type (:type shape) + {:keys [tag attrs] :as content} (:content shape) + measure-values (select-keys shape measure-attrs) + fill-values (get-fill-values shape) + stroke-values (get-stroke-values shape)] + + (when (contains? svg-elements tag) + [:* + (cond + (= tag :svg) + [:* + [:& measures-menu {:ids ids + :type type + :values measure-values}]] + + :else + [:* + [:& fill-menu {:ids ids + :type type + :values fill-values}] + [:& stroke-menu {:ids ids + :type type + :values stroke-values}]]) + + [:& shadow-menu {:ids ids + :values (select-keys shape [:shadow])}] + + [:& blur-menu {:ids ids + :values (select-keys shape [:blur])}]]))) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index a96a94df3..03f25c234 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -14,6 +14,7 @@ [app.common.geom.shapes :as gsh] [app.common.math :as mth] [app.common.uuid :as uuid] + [app.config :as cfg] [app.main.constants :as c] [app.main.data.colors :as dwc] [app.main.data.fetch :as mdf] @@ -39,22 +40,22 @@ [app.main.ui.workspace.shapes :refer [shape-wrapper frame-wrapper]] [app.main.ui.workspace.shapes.interactions :refer [interactions]] [app.main.ui.workspace.shapes.outline :refer [outline]] + [app.main.ui.workspace.shapes.path.actions :refer [path-actions]] [app.main.ui.workspace.snap-distances :refer [snap-distances]] [app.main.ui.workspace.snap-points :refer [snap-points]] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] + [app.util.http :as http] [app.util.object :as obj] [app.util.perf :as perf] [app.util.timers :as timers] - [app.util.http :as http] [beicon.core :as rx] [clojure.set :as set] [cuerdas.core :as str] [goog.events :as events] [potok.core :as ptk] [promesa.core :as p] - [rumext.alpha :as mf] - [app.main.ui.workspace.shapes.path.actions :refer [path-actions]]) + [rumext.alpha :as mf]) (:import goog.events.EventType)) ;; --- Coordinates Widget @@ -452,28 +453,25 @@ (dnd/has-type? e "text/asset-id")) (dom/prevent-default e)))) - on-uploaded + on-image-uploaded (mf/use-callback (fn [image {:keys [x y]}] - (prn "on-uploaded" image x y) - (let [shape {:name (:name image) - :width (:width image) - :height (:height image) - :x (- x (/ (:width image) 2)) - :y (- y (/ (:height image) 2)) - :metadata {:width (:width image) - :height (:height image) - :name (:name image) - :id (:id image) - :mtype (:mtype image)}}] - (st/emit! (dw/create-and-add-shape :image x y shape))))) + (st/emit! (dw/image-upload image x y)))) + + on-svg-uploaded + (mf/use-callback + (fn [image {:keys [x y]}] + (st/emit! (dw/svg-upload image x y)))) on-drop (mf/use-callback (fn [event] (dom/prevent-default event) (let [point (gpt/point (.-clientX event) (.-clientY event)) - viewport-coord (translate-point-to-viewport point)] + viewport-coord (translate-point-to-viewport point) + asset-id (-> (dnd/get-data event "text/asset-id") uuid/uuid) + asset-name (dnd/get-data event "text/asset-name") + asset-type (dnd/get-data event "text/asset-type")] (cond (dnd/has-type? event "app/shape") (let [shape (dnd/get-data event "app/shape") @@ -493,9 +491,9 @@ (:id component) (gpt/point final-x final-y)))) + ;; Will trigger when the user drags an image from a browser to the viewport (dnd/has-type? event "text/uri-list") (let [data (dnd/get-data event "text/uri-list") - name (dnd/get-data event "text/asset-name") lines (str/lines data) urls (filter #(and (not (str/blank? %)) (not (str/starts-with? % "#"))) @@ -504,21 +502,35 @@ (dw/upload-media-objects (with-meta {:file-id (:id file) :local? true - :uris urls - :name name} - {:on-success #(on-uploaded % viewport-coord)})))) + :uris urls} + {:on-image #(on-image-uploaded % viewport-coord) + :on-svg #(on-svg-uploaded % viewport-coord)})))) + ;; Will trigger when the user drags an SVG asset from the assets panel + (and (dnd/has-type? event "text/asset-id") (= asset-type "image/svg+xml")) + (let [path (cfg/resolve-file-media {:id asset-id})] + (st/emit! + (dw/upload-media-objects + (with-meta {:file-id (:id file) + :local? true + :uris [path] + :name asset-name + :mtype asset-type} + {:on-svg #(on-svg-uploaded % viewport-coord)})))) + + ;; Will trigger when the user drags an image from the assets SVG (dnd/has-type? event "text/asset-id") - (let [id (-> (dnd/get-data event "text/asset-id") uuid/uuid) - name (dnd/get-data event "text/asset-name") - params {:file-id (:id file) + (let [params {:file-id (:id file) :local? true - :object-id id - :name name}] + :object-id asset-id + :name asset-name}] (st/emit! (dw/clone-media-object (with-meta params - {:on-success #(on-uploaded % viewport-coord)})))) + {:on-success #(on-image-uploaded % viewport-coord)})))) + ;; Will trigger when the user drags a file from their file explorer into the viewport + ;; Or the user pastes an image + ;; Or the user uploads an image using the image tool :else (let [files (dnd/get-files event) params {:file-id (:id file) @@ -526,7 +538,8 @@ :data (seq files)}] (st/emit! (dw/upload-media-objects (with-meta params - {:on-success #(on-uploaded % viewport-coord)})))))))) + {:on-image #(on-image-uploaded % viewport-coord) + :on-svg #(on-svg-uploaded % viewport-coord)})))))))) on-paste (mf/use-callback @@ -591,7 +604,9 @@ :file-id (:id file)}]) [:svg.viewport - {:preserveAspectRatio "xMidYMid meet" + {:xmlns "http://www.w3.org/2000/svg" + :xmlnsXlink "http://www.w3.org/1999/xlink" + :preserveAspectRatio "xMidYMid meet" :key page-id :width (:width vport 0) :height (:height vport 0)