From bf5f845789820a8a2262fbae46fce47ca5e0736e Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 28 May 2021 12:59:43 +0200 Subject: [PATCH 1/9] :sparkles: Import/Export framework first version --- common/src/app/common/file_builder.cljc | 70 +++++++++++++++++ common/src/app/common/pages/init.cljc | 7 +- common/src/app/common/uuid_impl.js | 3 +- frontend/shadow-cljs.edn | 23 +++++- frontend/src/app/libs/file_builder.cljs | 23 ++++++ frontend/src/app/libs/render.cljs | 28 +++++++ frontend/src/app/main/render.cljs | 74 ++++++++++++++++++ .../src/app/main/ui/dashboard/import.cljs | 50 ++++++++++++ frontend/src/app/main/ui/icons.cljs | 4 +- frontend/src/app/worker.cljs | 5 +- frontend/src/app/worker/export.cljs | 77 +++++++++++++++++++ frontend/src/app/worker/import.cljs | 56 ++++++++++++++ 12 files changed, 413 insertions(+), 7 deletions(-) create mode 100644 common/src/app/common/file_builder.cljc create mode 100644 frontend/src/app/libs/file_builder.cljs create mode 100644 frontend/src/app/libs/render.cljs create mode 100644 frontend/src/app/main/render.cljs create mode 100644 frontend/src/app/main/ui/dashboard/import.cljs create mode 100644 frontend/src/app/worker/export.cljs create mode 100644 frontend/src/app/worker/import.cljs diff --git a/common/src/app/common/file_builder.cljc b/common/src/app/common/file_builder.cljc new file mode 100644 index 000000000..aa36f5e0c --- /dev/null +++ b/common/src/app/common/file_builder.cljc @@ -0,0 +1,70 @@ +;; 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) UXBOX Labs SL + +(ns app.common.file-builder + "A version parsing helper." + (:require + [app.common.spec :as us] + [app.common.uuid :as uuid] + [app.common.pages.init :as init] + [app.common.pages.changes :as ch] + )) + +(def root-frame uuid/zero) + +(defn create-file + ([name] + (let [id (uuid/next)] + {:id id + :name name + :data (-> init/empty-file-data + (assoc :id id)) + + ;; We keep the changes so we can send them to the backend + :changes []}))) + +;; TODO: Change to `false` +(def verify-on-commit? true) + +(defn commit-change [file change] + (-> file + (update :changes conj change) + (update :data ch/process-changes [change] verify-on-commit?))) + +(defn add-page + [file name] + + (let [page-id (uuid/next)] + (-> file + (commit-change + {:type :add-page + :id page-id + :name name + :page (-> init/empty-page-data + (assoc :name name))}) + + ;; Current page being edited + (assoc :current-page-id page-id) + + ;; Current parent stack we'll be nesting + (assoc :parent-stack [root-frame])))) + +(defn add-artboard [file data]) + +(defn close-artboard [file]) + +(defn add-group [file data]) +(defn close-group [file data]) + +(defn create-rect [file data]) +(defn create-circle [file data]) +(defn create-path [file data]) +(defn create-text [file data]) +(defn create-image [file data]) + +(defn close-page [file]) + +(defn generate-changes [file]) diff --git a/common/src/app/common/pages/init.cljc b/common/src/app/common/pages/init.cljc index 08d222f34..0b19a6f0a 100644 --- a/common/src/app/common/pages/init.cljc +++ b/common/src/app/common/pages/init.cljc @@ -126,10 +126,11 @@ :height (:height selection-rect)}) (defn make-file-data - ([file-id] (make-file-data file-id(uuid/next))) + ([file-id] + (make-file-data file-id (uuid/next))) + ([file-id page-id] - (let [ - pd (assoc empty-page-data + (let [pd (assoc empty-page-data :id page-id :name "Page-1")] (-> empty-file-data diff --git a/common/src/app/common/uuid_impl.js b/common/src/app/common/uuid_impl.js index 2c2a9f45b..e05f35853 100644 --- a/common/src/app/common/uuid_impl.js +++ b/common/src/app/common/uuid_impl.js @@ -16,7 +16,8 @@ goog.scope(function() { const self = app.common.uuid_impl; const fill = (() => { - if (typeof global.crypto !== "undefined") { + if (typeof global.crypto !== "undefined" && + typeof global.crypto.getRandomValues !== "undefined") { return (buf) => { global.crypto.getRandomValues(buf); return buf; diff --git a/frontend/shadow-cljs.edn b/frontend/shadow-cljs.edn index 82c7116a6..1d9527552 100644 --- a/frontend/shadow-cljs.edn +++ b/frontend/shadow-cljs.edn @@ -4,6 +4,7 @@ :jvm-opts ["-Xmx700m" "-Xms100m" "-XX:+UseSerialGC" "-XX:-OmitStackTraceInFastThrow"] :dev-http {8888 "classpath:public"} + :builds {:main {:target :browser @@ -35,6 +36,27 @@ :anon-fn-naming-policy :off :source-map-detail-level :all}}} + :lib-penpot + {:target :esm + :output-dir "resources/public/libs" + + :modules + {:penpot {:exports {:renderPage app.libs.render/render-page-export + :createFile app.libs.file-builder/create-file-export}}} + + :compiler-options + {:output-feature-set :es8 + :output-wrapper false + :warnings {:fn-deprecated false}} + + :release + {:compiler-options + {:fn-invoke-direct true + :source-map true + :elide-asserts true + :anon-fn-naming-policy :off + :source-map-detail-level :all}}} + :test {:target :node-test :output-to "target/tests.js" @@ -45,4 +67,3 @@ {:output-feature-set :es8 :output-wrapper false :warnings {:fn-deprecated false}}}}} - diff --git a/frontend/src/app/libs/file_builder.cljs b/frontend/src/app/libs/file_builder.cljs new file mode 100644 index 000000000..4f1c6a207 --- /dev/null +++ b/frontend/src/app/libs/file_builder.cljs @@ -0,0 +1,23 @@ +;; 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) UXBOX Labs SL + +(ns app.libs.file-builder + (:require + [app.common.data :as d] + [app.common.file-builder :as fb])) + +(deftype File [^:mutable file] + Object + (addPage [self name] + (set! file (fb/add-page file name)) + (str (:current-page-id file)))) + + +(defn create-file-export [^string name] + (File. (fb/create-file name))) + +(defn exports [] + #js { :createFile create-file-export }) diff --git a/frontend/src/app/libs/render.cljs b/frontend/src/app/libs/render.cljs new file mode 100644 index 000000000..73006a840 --- /dev/null +++ b/frontend/src/app/libs/render.cljs @@ -0,0 +1,28 @@ +;; 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) UXBOX Labs SL + +(ns app.libs.render + (:require + [app.common.uuid :as uuid] + [app.main.render :as r] + [beicon.core :as rx] + [promesa.core :as p])) + +(defn render-page-export + [file ^string page-id] + + ;; Better to expose the api as a promise to be consumed from JS + (let [page-id (uuid/uuid page-id) + file-data (.-file file) + data (get-in file-data [:data :pages-index page-id])] + (p/create + (fn [resolve reject] + (->> (r/render-page data) + (rx/take 1) + (rx/subs resolve reject))) ))) + +(defn exports [] + #js {:renderPage render-page-export}) diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs new file mode 100644 index 000000000..5ba1ef0d3 --- /dev/null +++ b/frontend/src/app/main/render.cljs @@ -0,0 +1,74 @@ +;; 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) UXBOX Labs SL + +(ns app.main.render + (:require + ["react-dom/server" :as rds] + [app.config :as cfg] + [app.main.exports :as exports] + [app.main.exports :as svg] + [app.main.fonts :as fonts] + [app.util.http :as http] + [beicon.core :as rx] + [clojure.set :as set] + [rumext.alpha :as mf])) + +(defn- text? [{type :type}] + (= type :text)) + +(defn- get-image-data [shape] + (cond + (= :image (:type shape)) + [(:metadata shape)] + + (some? (:fill-image shape)) + [(:fill-image shape)] + + :else + [])) + +(defn populate-images-cache + ([data] + (populate-images-cache data nil)) + + ([data {:keys [resolve-media?] :or {resolve-media? false}}] + (let [images (->> (:objects data) + (vals) + (mapcat get-image-data))] + (->> (rx/from images) + (rx/map #(cfg/resolve-file-media %)) + (rx/flat-map http/fetch-data-uri))))) + +(defn populate-fonts-cache [data] + (let [texts (->> (:objects data) + (vals) + (filterv text?) + (mapv :content)) ] + + (->> (rx/from texts) + (rx/map fonts/get-content-fonts) + (rx/reduce set/union #{}) + (rx/flat-map identity) + (rx/flat-map fonts/fetch-font-css) + (rx/flat-map fonts/extract-fontface-urls) + (rx/flat-map http/fetch-data-uri)))) + +(defn render-page + [data] + (rx/concat + (->> (rx/merge + (populate-images-cache data) + (populate-fonts-cache data)) + (rx/ignore)) + + (->> (rx/of data) + (rx/map + (fn [data] + (let [elem (mf/element exports/page-svg #js {:data data :embed? true})] + (rds/renderToStaticMarkup elem))))))) + + + diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs new file mode 100644 index 000000000..cd7e86cea --- /dev/null +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -0,0 +1,50 @@ +;; 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) UXBOX Labs SL + +(ns app.main.ui.dashboard.import + (:require + [app.main.ui.components.file-uploader :refer [file-uploader]] + [app.main.ui.icons :as i] + [app.main.worker :as uw] + [app.util.dom :as dom] + [app.util.logging :as log] + [beicon.core :as rx] + [rumext.alpha :as mf])) + +(log/set-level! :warn) + +(defn use-import-file + [project-id] + (mf/use-callback + (mf/deps project-id) + (fn [files] + (when files + (let [files (->> files (mapv dom/create-uri))] + (->> (uw/ask-many! + {:cmd :import-file + :project-id project-id + :files files}) + + (rx/subs + (fn [result] + (log/debug :action "import-result" :result result))))))))) + +(mf/defc import-button + [{:keys [project-id]}] + + (let [file-input (mf/use-ref nil) + on-file-selected (use-import-file project-id)] + [:form.import-file + [:button.import-file-icon {:type "button" + :on-click #(dom/click (mf/ref-val file-input))} i/import] + [:& file-uploader {:accept "application/zip" + :multi true + :input-ref file-input + :on-selected on-file-selected}]])) + + + + diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 598ba3b48..e577e082b 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -5,6 +5,7 @@ ;; Copyright (c) UXBOX Labs SL (ns app.main.ui.icons + (:refer-clojure :exclude [import]) (:require-macros [app.main.ui.icons :refer [icon-xref]]) (:require [rumext.alpha :as mf])) @@ -53,6 +54,7 @@ (def icon-set (icon-xref :icon-set)) (def icon-verify (icon-xref :icon-verify)) (def image (icon-xref :image)) +(def import (icon-xref :import)) (def infocard (icon-xref :infocard)) (def interaction (icon-xref :interaction)) (def layers (icon-xref :layers)) @@ -60,9 +62,9 @@ (def libraries (icon-xref :libraries)) (def library (icon-xref :library)) (def line (icon-xref :line)) +(def line-height (icon-xref :line-height)) (def listing-enum (icon-xref :listing-enum)) (def listing-thumbs (icon-xref :listing-thumbs)) -(def line-height (icon-xref :line-height)) (def loader (icon-xref :loader)) (def lock (icon-xref :lock)) (def logo (icon-xref :uxbox-logo)) diff --git a/frontend/src/app/worker.cljs b/frontend/src/app/worker.cljs index 05d9ffc51..44cd628f6 100644 --- a/frontend/src/app/worker.cljs +++ b/frontend/src/app/worker.cljs @@ -6,6 +6,7 @@ (ns app.worker (:require + [app.common.exceptions :as ex] [app.common.spec :as us] [app.common.transit :as t] @@ -14,6 +15,9 @@ [app.util.worker :as w] [app.worker.impl :as impl] [app.worker.selection] + + [app.worker.import] + [app.worker.export] [app.worker.snaps] [app.worker.thumbnails] [beicon.core :as rx] @@ -159,4 +163,3 @@ (set! process-message-sub (subscribe-buffer-messages)) (.addEventListener js/self "message" on-message)) - diff --git a/frontend/src/app/worker/export.cljs b/frontend/src/app/worker/export.cljs new file mode 100644 index 000000000..bb86dbfca --- /dev/null +++ b/frontend/src/app/worker/export.cljs @@ -0,0 +1,77 @@ +;; 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) UXBOX Labs SL + +(ns app.worker.export + (:require + [app.main.render :as r] + [app.util.dom :as dom] + [app.util.http :as http] + [app.util.zip :as uz] + [app.worker.impl :as impl] + [beicon.core :as rx])) + +(defn- handle-response + [response] + (cond + (http/success? response) + (rx/of (:body response)) + + (http/client-error? response) + (rx/throw (:body response)) + + :else + (rx/throw {:type :unexpected + :code (:error response)}))) + +(defn get-page-data + [{file-name :file-name {:keys [id name] :as data} :data}] + (->> (r/render-page data) + (rx/map (fn [markup] + {:id id + :name name + :file-name file-name + :markup markup})))) + +(defn query-file [file-id] + (->> (http/send! {:uri "/api/rpc/query/file" + :query {:id file-id} + :method :get}) + (rx/map http/conditional-decode-transit) + (rx/mapcat handle-response))) + +(defn process-pages [file] + (let [pages (get-in file [:data :pages]) + pages-index (get-in file [:data :pages-index])] + (->> pages + (map #(hash-map + :file-name (:name file) + :data (get pages-index %)))))) + +(defn collect-page + [coll {:keys [id file-name name markup] :as page}] + (conj coll [(str file-name "/" name ".svg") markup])) + +(defmethod impl/handler :export-file + [{:keys [team-id files] :as message}] + + (let [render-stream + (->> (rx/from (->> files (mapv :id))) + (rx/merge-map query-file) + (rx/flat-map process-pages) + (rx/observe-on :async) + (rx/flat-map get-page-data) + (rx/share))] + + (rx/merge + (->> render-stream + (rx/map #(hash-map :type :progress + :data (str "Render " (:file-name %) " - " (:name %))))) + (->> render-stream + (rx/reduce collect-page []) + (rx/tap #(prn %)) + (rx/flat-map uz/compress-files) + (rx/map #(hash-map :type :finish + :data (dom/create-uri %))))))) diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs new file mode 100644 index 000000000..5ac524726 --- /dev/null +++ b/frontend/src/app/worker/import.cljs @@ -0,0 +1,56 @@ +;; 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) UXBOX Labs SL + +(ns app.worker.import + (:require + [app.common.data :as d] + [app.common.file-builder :as fb] + [app.util.zip :as uz] + [app.worker.impl :as impl] + [beicon.core :as rx] + [cuerdas.core :as str] + [tubax.core :as tubax])) + +(defn parse-file-name + [dir] + (if (str/ends-with? dir "/") + (subs dir 0 (dec (count dir))) + dir)) + +(defn parse-page-name [path] + (let [[file page] (str/split path "/")] + (str/replace page ".svg" ""))) + +(defn import-page [file {:keys [path data]}] + (let [page-name (parse-page-name path)] + (-> file + (fb/add-page page-name)))) + +(defmethod impl/handler :import-file + [{:keys [project-id files]}] + + (let [extract-stream + (->> (rx/from files) + (rx/merge-map uz/extract-files)) + + dir-str + (->> extract-stream + (rx/filter #(contains? % :dir)) + (rx/map :dir)) + + file-str + (->> extract-stream + (rx/filter #(not (contains? % :dir))) + (rx/map #(d/update-when % :content tubax/xml->clj)))] + + (->> dir-str + (rx/merge-map + (fn [dir] + (->> file-str + (rx/filter #(str/starts-with? (:path %) dir)) + (rx/reduce import-page (fb/create-file (parse-file-name dir)))))) + + (rx/map #(select-keys % [:id :name]))))) From 6cbbfa6499d8ca6237a7b3327c2815c1fad995a1 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 31 May 2021 18:06:28 +0200 Subject: [PATCH 2/9] :recycle: Refactor custom stroke --- frontend/src/app/main/ui/shapes/circle.cljs | 5 +- .../src/app/main/ui/shapes/custom_stroke.cljs | 220 ++++++++++-------- frontend/src/app/main/ui/shapes/path.cljs | 14 +- frontend/src/app/main/ui/shapes/rect.cljs | 26 +-- frontend/src/app/main/ui/shapes/shape.cljs | 4 +- .../app/main/ui/workspace/shapes/path.cljs | 3 +- frontend/src/app/util/object.cljs | 31 ++- 7 files changed, 166 insertions(+), 137 deletions(-) diff --git a/frontend/src/app/main/ui/shapes/circle.cljs b/frontend/src/app/main/ui/shapes/circle.cljs index b3cdecbf0..da19c2003 100644 --- a/frontend/src/app/main/ui/shapes/circle.cljs +++ b/frontend/src/app/main/ui/shapes/circle.cljs @@ -32,6 +32,5 @@ :ry ry :transform transform}))] - [:& shape-custom-stroke {:shape shape - :base-props props - :elem-name "ellipse"}])) + [:& shape-custom-stroke {:shape shape} + [:> :ellipse props]])) diff --git a/frontend/src/app/main/ui/shapes/custom_stroke.cljs b/frontend/src/app/main/ui/shapes/custom_stroke.cljs index 643f07485..f3759ce0c 100644 --- a/frontend/src/app/main/ui/shapes/custom_stroke.cljs +++ b/frontend/src/app/main/ui/shapes/custom_stroke.cljs @@ -9,7 +9,116 @@ [rumext.alpha :as mf] [app.common.uuid :as uuid] [app.common.geom.shapes :as geom] - [app.util.object :as obj])) + [app.util.object :as obj] + [app.main.ui.context :as muc])) + +(defn add-props + [props new-props] + (-> props + (obj/merge (clj->js new-props)))) + +(defn add-style + [props new-style] + (let [old-style (obj/get props "style") + style (obj/merge old-style (clj->js new-style))] + (-> props (obj/merge #js {:style style})))) + +(mf/defc inner-stroke-clip-path + [{:keys [shape render-id]}] + (let [clip-id (str "inner-stroke-" render-id) + shape-id (str "stroke-shape-" render-id)] + [:> "clipPath" #js {:id clip-id} + [:use {:href (str "#" shape-id)}]])) + +(mf/defc outer-stroke-mask + [{:keys [shape render-id]}] + (let [stroke-mask-id (str "outer-stroke-" render-id) + shape-id (str "stroke-shape-" render-id) + stroke-width (:stroke-width shape 0)] + [:mask {:id stroke-mask-id} + [:use {:href (str "#" shape-id) + :style #js {:fill "none" :stroke "white" :strokeWidth (* stroke-width 2)}}] + + [:use {:href (str "#" shape-id) + :style #js {:fill "black"}}]])) + +(mf/defc stroke-defs + [{:keys [shape render-id]}] + (cond + (and (= :inner (:stroke-alignment shape :center)) + (> (:stroke-width shape 0) 0)) + [:& inner-stroke-clip-path {:shape shape + :render-id render-id}] + + (and (= :outer (:stroke-alignment shape :center)) + (> (:stroke-width shape 0) 0)) + [:& outer-stroke-mask {:shape shape + :render-id render-id}])) + +;; 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 +(mf/defc outer-stroke + {::mf/wrap-props false} + [props] + + (let [render-id (mf/use-ctx muc/render-ctx) + child (obj/get props "children") + base-props (obj/get child "props") + elem-name (obj/get child "type") + shape (obj/get props "shape") + stroke-width (:stroke-width shape 0) + stroke-mask-id (str "outer-stroke-" render-id) + shape-id (str "stroke-shape-" render-id)] + + [:g.outer-stroke-shape + [:symbol + [:> elem-name (-> (obj/clone base-props) + (obj/set! "id" shape-id) + (obj/without ["style"]))]] + + [:use {:href (str "#" shape-id) + :mask (str "url(#" stroke-mask-id ")") + :style (-> (obj/get base-props "style") + (obj/clone) + (obj/update! "strokeWidth" * 2) + (obj/without ["fill" "fillOpacity"]) + (obj/set! "fill" "none"))}] + + [:use {:href (str "#" shape-id) + :style (-> (obj/get base-props "style") + (obj/clone) + (obj/without ["stroke" "strokeWidth" "strokeOpacity" "strokeStyle" "strokeDasharray"]))}]])) + + +;; Inner alignment: display the shape with double width stroke, +;; and clip the result with the original shape without stroke. +(mf/defc inner-stroke + {::mf/wrap-props false} + [props] + (let [render-id (mf/use-ctx muc/render-ctx) + child (obj/get props "children") + base-props (obj/get child "props") + elem-name (obj/get child "type") + shape (obj/get props "shape") + transform (obj/get base-props "transform") + + stroke-width (:stroke-width shape 0) + + clip-id (str "inner-stroke-" render-id) + shape-id (str "stroke-shape-" render-id) + + shape-props (-> base-props + (add-props {:id shape-id + :transform nil + :clipPath (str "url('#" clip-id "')")}) + (add-style {:strokeWidth (* stroke-width 2)}))] + + [:g.inner-stroke-shape {:transform transform} + [:> elem-name shape-props]])) + ; The SVG standard does not implement yet the 'stroke-alignment' ; attribute, to define the position of the stroke relative to the @@ -19,100 +128,25 @@ (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") - base-style (obj/get base-props "style") - {:keys [x y width height]} (:selrect shape) - stroke-id (mf/use-var (uuid/next)) + (let [child (obj/get props "children") + shape (obj/get props "shape") + stroke-width (:stroke-width shape 0) stroke-style (:stroke-style shape :none) - stroke-position (:stroke-alignment shape :center)] + stroke-position (:stroke-alignment shape :center) + has-stroke? (and (and (> stroke-width 0) + (not= stroke-style :none))) + inner? (= :inner stroke-position) + outer? (= :outer stroke-position)] + (cond - ;; Center alignment (or no stroke): the default in SVG - (or (= stroke-style :none) (= stroke-position :center)) - [:> elem-name (obj/merge! #js {} base-props)] + (and has-stroke? inner?) + [:& inner-stroke {:shape shape} + child] - ;; 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-" @stroke-id) + (and has-stroke? outer?) + [:& outer-stroke {:shape shape} + child] - clip-props (obj/merge - base-props - #js {:transform nil - :style (obj/merge - base-style - #js {:stroke nil - :strokeWidth nil - :strokeOpacity nil - :strokeDasharray nil - :fill "white" - :fillOpacity 1})}) - - stroke-width (obj/get base-style "strokeWidth" 0) - shape-props (obj/merge - base-props - #js {:clipPath (str "url('#" clip-id "')") - :style (obj/merge - base-style - #js {:strokeWidth (* stroke-width 2)})})] - [:* - [:> "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 [stroke-mask-id (str "mask-" @stroke-id) - stroke-width (obj/get base-style "strokeWidth" 0) - mask-props1 (obj/merge - base-props - #js {:transform nil - :style (obj/merge - base-style - #js {:stroke "white" - :strokeWidth (* stroke-width 2) - :strokeOpacity 1 - :strokeDasharray nil - :fill "white" - :fillOpacity 1})}) - mask-props2 (obj/merge - base-props - #js {:transform nil - :style (obj/merge - base-style - #js {:stroke nil - :strokeWidth nil - :strokeOpacity nil - :strokeDasharray nil - :fill "black" - :fillOpacity 1})}) - - shape-props1 (obj/merge - base-props - #js {:style (obj/merge - base-style - #js {:stroke nil - :strokeWidth nil - :strokeOpacity nil - :strokeDasharray nil})}) - shape-props2 (obj/merge - base-props - #js {:mask (str "url('#" stroke-mask-id "')") - :style (obj/merge - base-style - #js {:strokeWidth (* stroke-width 2) - :fill "none" - :fillOpacity 0})})] - [:* - [:mask {:id stroke-mask-id} - [:> elem-name mask-props1] - [:> elem-name mask-props2]] - [:> elem-name shape-props1] - [:> elem-name shape-props2]])))) + :else + child))) diff --git a/frontend/src/app/main/ui/shapes/path.cljs b/frontend/src/app/main/ui/shapes/path.cljs index 256730eb2..972680305 100644 --- a/frontend/src/app/main/ui/shapes/path.cljs +++ b/frontend/src/app/main/ui/shapes/path.cljs @@ -26,16 +26,6 @@ props (-> (attrs/extract-style-attrs shape) (obj/merge! #js {:d pdata}))] - (if background? - [:g - [:path {:stroke "none" - :fill "none" - :stroke-width "20px" - :d pdata}] - [:& shape-custom-stroke {:shape shape - :base-props props - :elem-name "path"}]] - [:& shape-custom-stroke {:shape shape - :base-props props - :elem-name "path"}]))) + [:& shape-custom-stroke {:shape shape} + [:> :path props]])) diff --git a/frontend/src/app/main/ui/shapes/rect.cljs b/frontend/src/app/main/ui/shapes/rect.cljs index aeb61b17a..bb0ccd60a 100644 --- a/frontend/src/app/main/ui/shapes/rect.cljs +++ b/frontend/src/app/main/ui/shapes/rect.cljs @@ -6,23 +6,19 @@ (ns app.main.ui.shapes.rect (:require - [rumext.alpha :as mf] + [app.common.geom.shapes :as gsh] [app.main.ui.shapes.attrs :as attrs] [app.main.ui.shapes.custom-stroke :refer [shape-custom-stroke]] - [app.common.geom.shapes :as geom] - [app.util.object :as obj] [app.main.ui.shapes.gradients :refer [gradient]] - - [cuerdas.core :as str] - [app.common.uuid :as uuid] - [app.common.geom.point :as gpt])) + [app.util.object :as obj] + [rumext.alpha :as mf])) (mf/defc rect-shape {::mf/wrap-props false} [props] (let [shape (unchecked-get props "shape") {:keys [id x y width height]} shape - transform (geom/transform-matrix shape) + transform (gsh/transform-matrix shape) props (-> (attrs/extract-style-attrs shape) (obj/merge! @@ -30,11 +26,11 @@ :y y :transform transform :width width - :height height}))] + :height height})) - [:& shape-custom-stroke {:shape shape - :base-props props - :elem-name - (if (.-d props) - "path" - "rect")}])) + path? (some? (.-d props))] + + [:& shape-custom-stroke {:shape shape} + (if path? + [:> :path props] + [:> :rect props])])) diff --git a/frontend/src/app/main/ui/shapes/shape.cljs b/frontend/src/app/main/ui/shapes/shape.cljs index 82dddd238..cd05e9d11 100644 --- a/frontend/src/app/main/ui/shapes/shape.cljs +++ b/frontend/src/app/main/ui/shapes/shape.cljs @@ -9,6 +9,7 @@ [app.common.data :as d] [app.common.uuid :as uuid] [app.main.ui.context :as muc] + [app.main.ui.shapes.custom-stroke :as cs] [app.main.ui.shapes.fill-image :as fim] [app.main.ui.shapes.filters :as filters] [app.main.ui.shapes.gradients :as grad] @@ -56,5 +57,6 @@ [:& filters/filters {:shape shape :filter-id filter-id}] [:& grad/gradient {:shape shape :attr :fill-color-gradient}] [:& grad/gradient {:shape shape :attr :stroke-color-gradient}] - [:& fim/fill-image-pattern {:shape shape :render-id render-id}]] + [:& fim/fill-image-pattern {:shape shape :render-id render-id}] + [:& cs/stroke-defs {:shape shape :render-id render-id}]] children]])) diff --git a/frontend/src/app/main/ui/workspace/shapes/path.cljs b/frontend/src/app/main/ui/workspace/shapes/path.cljs index 22232ca01..c9183c3fe 100644 --- a/frontend/src/app/main/ui/workspace/shapes/path.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/path.cljs @@ -28,5 +28,4 @@ [:> shape-container {:shape shape :pointer-events (when editing? "none")} - [:& path/path-shape {:shape shape - :background? true}]])) + [:& path/path-shape {:shape shape}]])) diff --git a/frontend/src/app/util/object.cljs b/frontend/src/app/util/object.cljs index ad6697a42..03c244704 100644 --- a/frontend/src/app/util/object.cljs +++ b/frontend/src/app/util/object.cljs @@ -27,17 +27,18 @@ (js/Object.keys ^js obj)) (defn get-in - [obj keys] - (loop [key (first keys) - keys (rest keys) - res obj] - (if (nil? key) - res - (if (nil? res) - res - (recur (first keys) - (rest keys) - (unchecked-get res key)))))) + ([obj keys] + (get-in obj keys nil)) + + ([obj keys default] + (loop [key (first keys) + keys (rest keys) + res obj] + (if (or (nil? key) (nil? res)) + (or res default) + (recur (first keys) + (rest keys) + (unchecked-get res key)))))) (defn without [obj keys] @@ -68,6 +69,14 @@ (unchecked-set obj key value) obj) +(defn update! + [obj key f & args] + (let [found (get obj key ::not-found)] + (if-not (identical? ::not-found found) + (do (unchecked-set obj key (apply f found args)) + obj) + obj))) + (defn- props-key-fn [key] (if (or (= key :class) (= key :class-name)) From a76bf1d0b25d726b4ea7cb4318de22b54ec666e8 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 2 Jun 2021 15:08:28 +0200 Subject: [PATCH 3/9] :bug: Fix problem with export assets --- frontend/src/app/util/dom.cljs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 5864e285c..22c3840ac 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -333,4 +333,5 @@ (defn trigger-download [filename blob] - (trigger-download-uri filename (.-type ^js blob) (dom/create-uri blob))) + (trigger-download-uri filename (.-type ^js blob) (create-uri blob))) + From 9f36f4fbe79bbd2a5e9c25e8b4a122b989aafbba Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 2 Jun 2021 15:09:34 +0200 Subject: [PATCH 4/9] :sparkles: Save as dialog option --- frontend/src/app/util/dom.cljs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 22c3840ac..b5acc982a 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -8,10 +8,11 @@ (:require [app.common.exceptions :as ex] [app.common.geom.point :as gpt] - [app.util.object :as obj] [app.util.globals :as globals] + [app.util.object :as obj] [cuerdas.core :as str] - [goog.dom :as dom])) + [goog.dom :as dom] + [promesa.core :as p])) ;; --- Deprecated methods @@ -335,3 +336,21 @@ [filename blob] (trigger-download-uri filename (.-type ^js blob) (create-uri blob))) +(defn save-as + [uri filename mtype description] + + ;; Only chrome supports the save dialog + (if (obj/contains? globals/window "showSaveFilePicker") + (let [extension (mtype->extension mtype) + opts {:suggestedName (str filename "." extension) + :types [{:description description + :accept { mtype [(str "." extension)]}}]}] + + (p/let [file-system (.showSaveFilePicker globals/window (clj->js opts)) + writable (.createWritable file-system) + response (js/fetch uri) + blob (.blob response) + _ (.write writable blob)] + (.close writable))) + + (trigger-download-uri filename mtype uri))) From b76fef1e445ec65583825d174194cc8e96ff8f3e Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 2 Jun 2021 15:46:41 +0200 Subject: [PATCH 5/9] :sparkles: Change create file to send data from the frontend --- backend/src/app/rpc/mutations/files.clj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/app/rpc/mutations/files.clj b/backend/src/app/rpc/mutations/files.clj index d6fb24c59..d90e453fb 100644 --- a/backend/src/app/rpc/mutations/files.clj +++ b/backend/src/app/rpc/mutations/files.clj @@ -54,11 +54,11 @@ (db/insert! conn :file-profile-rel)))) (defn create-file - [conn {:keys [id name project-id is-shared] + [conn {:keys [id name project-id is-shared data] :or {is-shared false} :as params}] - (let [id (or id (uuid/next)) - data (cp/make-file-data id) + (let [id (or id (:id data) (uuid/next)) + data (or data (cp/make-file-data id)) file (db/insert! conn :file {:id id :project-id project-id From f197124ee5ad7a7322300c30d96bbaacd68488c1 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 2 Jun 2021 15:50:32 +0200 Subject: [PATCH 6/9] :sparkles: Changes to render to support exporting --- frontend/src/app/main/ui/render.cljs | 3 +- .../src/app/main/ui/shapes/custom_stroke.cljs | 21 ++++-- frontend/src/app/main/ui/shapes/shape.cljs | 64 +++++++++++++++---- .../main/ui/workspace/viewport/gradients.cljs | 5 +- 4 files changed, 70 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/main/ui/render.cljs b/frontend/src/app/main/ui/render.cljs index f7de7b862..c6853ecd2 100644 --- a/frontend/src/app/main/ui/render.cljs +++ b/frontend/src/app/main/ui/render.cljs @@ -78,7 +78,8 @@ :height height :version "1.1" :xmlnsXlink "http://www.w3.org/1999/xlink" - :xmlns "http://www.w3.org/2000/svg"} + :xmlns "http://www.w3.org/2000/svg" + :xmlns:penpot "https://penpot.app/xmlns"} (case (:type object) :frame [:& frame-wrapper {:shape object :view-box vbox}] :group [:> shape-container {:shape object} diff --git a/frontend/src/app/main/ui/shapes/custom_stroke.cljs b/frontend/src/app/main/ui/shapes/custom_stroke.cljs index f3759ce0c..1e19f219f 100644 --- a/frontend/src/app/main/ui/shapes/custom_stroke.cljs +++ b/frontend/src/app/main/ui/shapes/custom_stroke.cljs @@ -6,11 +6,13 @@ (ns app.main.ui.shapes.custom-stroke (:require - [rumext.alpha :as mf] - [app.common.uuid :as uuid] + [app.common.data :as d] [app.common.geom.shapes :as geom] + [app.common.uuid :as uuid] + [app.main.ui.context :as muc] [app.util.object :as obj] - [app.main.ui.context :as muc])) + [cuerdas.core :as str] + [rumext.alpha :as mf])) (defn add-props [props new-props] @@ -71,13 +73,22 @@ shape (obj/get props "shape") stroke-width (:stroke-width shape 0) stroke-mask-id (str "outer-stroke-" render-id) - shape-id (str "stroke-shape-" render-id)] + shape-id (str "stroke-shape-" render-id) + + style-str (->> (obj/get base-props "style") + (js->clj) + (mapv (fn [[k v]] + (-> (d/name k) + (str/kebab) + (str ":" v)))) + (str/join ";"))] [:g.outer-stroke-shape [:symbol [:> elem-name (-> (obj/clone base-props) (obj/set! "id" shape-id) - (obj/without ["style"]))]] + (obj/set! "data-style" style-str) + (obj/without ["style"]))]] [:use {:href (str "#" shape-id) :mask (str "url(#" stroke-mask-id ")") diff --git a/frontend/src/app/main/ui/shapes/shape.cljs b/frontend/src/app/main/ui/shapes/shape.cljs index cd05e9d11..5a8d4fecd 100644 --- a/frontend/src/app/main/ui/shapes/shape.cljs +++ b/frontend/src/app/main/ui/shapes/shape.cljs @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.uuid :as uuid] + [app.common.geom.matrix :as gmt] [app.main.ui.context :as muc] [app.main.ui.shapes.custom-stroke :as cs] [app.main.ui.shapes.fill-image :as fim] @@ -17,6 +18,36 @@ [app.util.object :as obj] [rumext.alpha :as mf])) +(defn add-metadata + "Adds as metadata properties that we cannot deduce from the exported SVG" + [props shape] + (let [add! + (fn [props attr val] + (let [ns-attr (str "penpot:" (-> attr d/name))] + (-> props + (obj/set! ns-attr val)))) + frame? (= :frame type)] + (-> props + (add! :name (-> shape :name)) + (add! :blocked (-> shape (:blocked false) str)) + (add! :hidden (-> shape (:hidden false) str)) + (add! :type (-> shape :type d/name)) + + (add! :stroke-style (-> shape (:stroke-style :none) d/name)) + (add! :stroke-alignment (-> shape (:stroke-alignment :center) d/name)) + + (add! :transform (-> shape (:transform (gmt/matrix)) str)) + (add! :transform-inverse (-> shape (:transform-inverse (gmt/matrix)) str)) + + (cond-> (some? (:r1 shape)) + (-> (add! :r1 (-> shape (:r1 0) str)) + (add! :r2 (-> shape (:r2 0) str)) + (add! :r3 (-> shape (:r3 0) str)) + (add! :r4 (-> shape (:r4 0) str)))) + + (cond-> frame? + (obj/set! "xmlns:penpot" "https://penpot.app/xmlns"))))) + (mf/defc shape-container {::mf/forward-ref true ::mf/wrap-props false} @@ -34,24 +65,29 @@ {:keys [x y width height type]} shape frame? (= :frame type) - group-props (-> (obj/clone props) - (obj/without ["shape" "children"]) - (obj/set! "ref" ref) - (obj/set! "id" (str "shape-" (:id shape))) - (obj/set! "filter" (filters/filter-str filter-id shape)) - (obj/set! "style" styles) - (cond-> frame? - (-> (obj/set! "x" x) - (obj/set! "y" y) - (obj/set! "width" width) - (obj/set! "height" height) - (obj/set! "xmlnsXlink" "http://www.w3.org/1999/xlink") - (obj/set! "xmlns" "http://www.w3.org/2000/svg")))) + wrapper-props + (-> (obj/clone props) + (obj/without ["shape" "children"]) + (obj/set! "ref" ref) + (obj/set! "id" (str "shape-" (:id shape))) + (obj/set! "filter" (filters/filter-str filter-id shape)) + (obj/set! "style" styles) + + (cond-> frame? + (-> (obj/set! "x" x) + (obj/set! "y" y) + (obj/set! "width" width) + (obj/set! "height" height) + (obj/set! "xmlnsXlink" "http://www.w3.org/1999/xlink") + (obj/set! "xmlns" "http://www.w3.org/2000/svg"))) + + (add-metadata shape)) wrapper-tag (if frame? "svg" "g")] + [:& (mf/provider muc/render-ctx) {:value render-id} - [:> wrapper-tag group-props + [:> wrapper-tag wrapper-props [:defs [:& defs/svg-defs {:shape shape :render-id render-id}] [:& filters/filters {:shape shape :filter-id filter-id}] diff --git a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs index 380c96f77..e65bc12d0 100644 --- a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs @@ -278,7 +278,6 @@ (let [point (gpt/transform point transform-inverse) end-x (/ (- (:x point) x) width) end-y (/ (- (:y point) y) height) - end-x (mth/precision end-x 2) end-y (mth/precision end-y 2)] (change! {:end-x end-x :end-y end-y}))) @@ -287,8 +286,8 @@ (let [scale-factor-y (/ gradient-length (/ height 2)) norm-dist (/ (gpt/distance point from-p) (* (/ width 2) scale-factor-y))] - - (change! {:width norm-dist})))] + (when (and norm-dist (mth/finite? norm-dist)) + (change! {:width norm-dist}))))] (when (and gradient (= id (:shape-id gradient)) From 21aa23e7f5f9cc9a1aa36504a2c1153114be7a67 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 2 Jun 2021 15:51:20 +0200 Subject: [PATCH 7/9] :sparkles: Parsing and file builder --- common/src/app/common/file_builder.cljc | 137 +++++++++++++++---- common/src/app/common/geom/matrix.cljc | 10 ++ common/src/app/common/geom/shapes.cljc | 4 + common/src/app/common/pages.cljc | 1 + common/src/app/common/pages/init.cljc | 6 + frontend/src/app/util/import/parser.cljc | 164 +++++++++++++++++++++++ frontend/src/app/util/object.cljs | 6 +- frontend/src/app/util/path/parser.cljs | 9 +- frontend/src/app/util/svg.cljs | 2 - 9 files changed, 307 insertions(+), 32 deletions(-) create mode 100644 frontend/src/app/util/import/parser.cljc diff --git a/common/src/app/common/file_builder.cljc b/common/src/app/common/file_builder.cljc index aa36f5e0c..807d507c2 100644 --- a/common/src/app/common/file_builder.cljc +++ b/common/src/app/common/file_builder.cljc @@ -7,14 +7,52 @@ (ns app.common.file-builder "A version parsing helper." (:require - [app.common.spec :as us] - [app.common.uuid :as uuid] - [app.common.pages.init :as init] + [app.common.geom.shapes :as gsh] [app.common.pages.changes :as ch] - )) + [app.common.pages.init :as init] + [app.common.pages.spec :as spec] + [app.common.spec :as us] + [app.common.spec :as us] + [app.common.uuid :as uuid])) (def root-frame uuid/zero) +;; This flag controls if we should execute spec validation after every commit +(def verify-on-commit? true) + +(defn- commit-change [file change] + (when verify-on-commit? + (us/assert ::spec/change change)) + (-> file + (update :changes conj change) + (update :data ch/process-changes [change] verify-on-commit?))) + +(defn- lookup-objects + ([file] + (lookup-objects file (:current-page-id file))) + + ([file page-id] + (get-in file [:data :pages-index page-id :objects]))) + +(defn- lookup-shape [file shape-id] + (-> (lookup-objects file) + (get shape-id))) + +(defn- commit-shape [file obj] + (let [page-id (:current-page-id file) + frame-id (:current-frame-id file) + parent-id (-> file :parent-stack peek)] + (-> file + (commit-change + {:type :add-obj + :id (:id obj) + :page-id page-id + :frame-id frame-id + :parent-id parent-id + :obj obj})))) + +;; PUBLIC API + (defn create-file ([name] (let [id (uuid/next)] @@ -26,17 +64,8 @@ ;; We keep the changes so we can send them to the backend :changes []}))) -;; TODO: Change to `false` -(def verify-on-commit? true) - -(defn commit-change [file change] - (-> file - (update :changes conj change) - (update :data ch/process-changes [change] verify-on-commit?))) - (defn add-page [file name] - (let [page-id (uuid/next)] (-> file (commit-change @@ -49,22 +78,82 @@ ;; Current page being edited (assoc :current-page-id page-id) + ;; Current frame-id + (assoc :current-frame-id root-frame) + ;; Current parent stack we'll be nesting (assoc :parent-stack [root-frame])))) -(defn add-artboard [file data]) +(defn add-artboard [file data] + (let [obj (-> (init/make-minimal-shape :frame) + (merge data))] + (-> file + (commit-shape obj) + (assoc :current-frame-id (:id obj)) + (update :parent-stack conj (:id obj))))) -(defn close-artboard [file]) +(defn close-artboard [file] + (-> file + (assoc :current-frame-id root-frame) + (update :parent-stack pop))) -(defn add-group [file data]) -(defn close-group [file data]) +(defn add-group [file data] + (let [frame-id (:current-frame-id file) + selrect init/empty-selrect + name (:name data) + obj (-> (init/make-minimal-group frame-id selrect name) + (merge data))] + (-> file + (commit-shape obj) + (update :parent-stack conj (:id obj))))) -(defn create-rect [file data]) -(defn create-circle [file data]) -(defn create-path [file data]) -(defn create-text [file data]) -(defn create-image [file data]) +(defn close-group [file] + (let [group-id (-> file :parent-stack peek) + group (lookup-shape file group-id) + shapes (->> group :shapes (mapv #(lookup-shape file %))) + selrect (gsh/selection-rect shapes) + points (gsh/rect->points selrect)] -(defn close-page [file]) + (-> file + (commit-change + {:type :mod-obj + :page-id (:current-page-id file) + :id group-id + :operations + [{:type :set :attr :selrect :val selrect} + {:type :set :attr :points :val points}]}) + (update :parent-stack pop)))) -(defn generate-changes [file]) +(defn create-shape [file type data] + (let [frame-id (:current-frame-id file) + frame (when-not (= frame-id root-frame) + (lookup-shape file frame-id)) + obj (-> (init/make-minimal-shape type) + (merge data) + (cond-> frame + (gsh/translate-from-frame frame)))] + (commit-shape file obj))) + +(defn create-rect [file data] + (create-shape file :rect data)) + +(defn create-circle [file data] + (create-shape file :circle data)) + +(defn create-path [file data] + (create-shape file :path data)) + +(defn create-text [file data] + (create-shape file :text data)) + +(defn create-image [file data] + (create-shape file :image data)) + +(defn close-page [file] + (-> file + (dissoc :current-page-id) + (dissoc :parent-stack))) + +(defn generate-changes + [file] + (:changes file)) diff --git a/common/src/app/common/geom/matrix.cljc b/common/src/app/common/geom/matrix.cljc index 1c0a83482..fc2513f4a 100644 --- a/common/src/app/common/geom/matrix.cljc +++ b/common/src/app/common/geom/matrix.cljc @@ -8,6 +8,7 @@ (:require #?(:cljs [cljs.pprint :as pp] :clj [clojure.pprint :as pp]) + [app.common.data :as d] [app.common.geom.point :as gpt] [app.common.math :as mth])) @@ -25,6 +26,15 @@ ([a b c d e f] (Matrix. a b c d e f))) +(def number-regex #"[+-]?\d*(\.\d+)?(e[+-]?\d+)?") + +(defn str->matrix + [matrix-str] + (let [params (->> (re-seq number-regex matrix-str) + (filter #(-> % first empty? not)) + (map (comp d/parse-double first)))] + (apply matrix params))) + (defn multiply ([{m1a :a m1b :b m1c :c m1d :d m1e :e m1f :f} {m2a :a m2b :b m2c :c m2d :d m2e :e m2f :f}] diff --git a/common/src/app/common/geom/shapes.cljc b/common/src/app/common/geom/shapes.cljc index 96e489157..75fc70661 100644 --- a/common/src/app/common/geom/shapes.cljc +++ b/common/src/app/common/geom/shapes.cljc @@ -100,6 +100,10 @@ [shape {:keys [x y]}] (gtr/move shape (gpt/negate (gpt/point x y))) ) +(defn translate-from-frame + [shape {:keys [x y]}] + (gtr/move shape (gpt/point x y)) ) + ;; --- Helpers (defn fully-contained? diff --git a/common/src/app/common/pages.cljc b/common/src/app/common/pages.cljc index 32bd26084..0e69c9d53 100644 --- a/common/src/app/common/pages.cljc +++ b/common/src/app/common/pages.cljc @@ -84,6 +84,7 @@ (d/export init/make-file-data) (d/export init/make-minimal-shape) (d/export init/make-minimal-group) +(d/export init/empty-file-data) ;; Specs diff --git a/common/src/app/common/pages/init.cljc b/common/src/app/common/pages/init.cljc index 0b19a6f0a..ca42025f4 100644 --- a/common/src/app/common/pages/init.cljc +++ b/common/src/app/common/pages/init.cljc @@ -85,6 +85,12 @@ {:type :svg-raw}]) +(def empty-selrect + {:x 0 :y 0 + :x1 0 :y1 0 + :x2 1 :y2 1 + :width 1 :height 1}) + (defn make-minimal-shape [type] (let [type (cond (= type :curve) :path diff --git a/frontend/src/app/util/import/parser.cljc b/frontend/src/app/util/import/parser.cljc new file mode 100644 index 000000000..6847b194f --- /dev/null +++ b/frontend/src/app/util/import/parser.cljc @@ -0,0 +1,164 @@ +;; 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) UXBOX Labs SL + +(ns app.util.import.parser + (:require + [app.common.data :as d] + [app.common.geom.matrix :as gmt] + [app.common.geom.shapes :as gsh] + [cuerdas.core :as str] + [app.util.path.parser :as upp])) + +(defn valid? + [root] + (contains? (:attrs root) :xmlns:penpot)) + +(defn branch? + [node] + (and (contains? node :content) + (some? (:content node)))) + +(defn close? + [node] + (and (vector? node) + (= ::close (first node)))) + +(defn get-type + [node] + (if (close? node) + (second node) + (-> (get-in node [:attrs :penpot:type]) + (keyword)))) + +(defn shape? + [node] + (or (close? node) + (contains? (:attrs node) :penpot:type))) + +(defn get-attr + ([m att] + (get-attr m att identity)) + ([m att val-fn] + (let [ns-att (->> att d/name (str "penpot:") keyword) + val (get-in m [:attrs ns-att])] + (when val (val-fn val))))) + +(defn get-children + [node] + (cond-> (:content node) + ;; We add a "fake" node to know when we are leaving the shape children + (shape? node) + (conj [::close (get-type node)]))) + +(defn node-seq + [content] + (->> content (tree-seq branch? get-children))) + +(defn get-transform + [type node]) + +(defn parse-style + "Transform style list into a map" + [style-str] + (if (string? style-str) + (->> (str/split style-str ";") + (map str/trim) + (map #(str/split % ":")) + (group-by first) + (map (fn [[key val]] + (vector (keyword key) (second (first val))))) + (into {})) + style-str)) + +(defn add-attrs + [m attrs] + (reduce-kv + (fn [m k v] + (if (#{:style :data-style} k) + (assoc m :style (parse-style v)) + (assoc m k v))) + m + attrs)) + +(defn get-data-node + [node] + + (let [data-tags #{:ellipse :rect :path}] + (->> node + (node-seq) + (filter #(contains? data-tags (:tag %))) + (map #(:attrs %)) + (reduce add-attrs {})))) + +(def search-data-node? #{:rect :image :path :text :circle}) +(def has-position? #{:frame :rect :image :text}) + +(defn parse-position + [props data] + (let [values (->> (select-keys data [:x :y :width :height]) + (d/mapm (fn [_ val] (d/parse-double val))))] + (d/merge props values))) + +(defn parse-circle + [props data] + (let [values (->> (select-keys data [:cx :cy :rx :ry]) + (d/mapm (fn [_ val] (d/parse-double val))))] + + {:x (- (:cx values) (:rx values)) + :y (- (:cy values) (:ry values)) + :width (* (:rx values) 2) + :height (* (:ry values) 2)})) + +(defn parse-path + [props data] + (let [content (upp/parse-path (:d data)) + selrect (gsh/content->selrect content) + points (gsh/rect->points selrect)] + + (-> props + (assoc :content content) + (assoc :selrect selrect) + (assoc :points points)))) + +(defn extract-data + [type node] + (let [data (if (search-data-node? type) + (get-data-node node) + (:attrs node))] + (cond-> {} + (has-position? type) + (-> (parse-position data) + (gsh/setup-selrect)) + + (= type :circle) + (-> (parse-circle data) + (gsh/setup-selrect)) + + (= type :path) + (parse-path data)))) + +(defn str->bool + [val] + (= val "true")) + +(defn parse-data + [type node] + + (when-not (close? node) + (let [name (get-attr node :name) + blocked (get-attr node :blocked str->bool) + hidden (get-attr node :hidden str->bool) + transform (get-attr node :transform gmt/str->matrix) + transform-inverse (get-attr node :transform-inverse gmt/str->matrix)] + + (-> (extract-data type node) + (assoc :name name) + (assoc :blocked blocked) + (assoc :hidden hidden) + (cond-> (some? transform) + (assoc :transform transform)) + (cond-> (some? transform-inverse) + (assoc :transform-inverse transform-inverse)))))) diff --git a/frontend/src/app/util/object.cljs b/frontend/src/app/util/object.cljs index 03c244704..12aabcb60 100644 --- a/frontend/src/app/util/object.cljs +++ b/frontend/src/app/util/object.cljs @@ -6,7 +6,7 @@ (ns app.util.object "A collection of helpers for work with javascript objects." - (:refer-clojure :exclude [set! get get-in merge clone]) + (:refer-clojure :exclude [set! get get-in merge clone contains?]) (:require [cuerdas.core :as str] [goog.object :as gobj] @@ -22,6 +22,10 @@ (let [result (get obj k)] (if (undefined? result) default result)))) +(defn contains? + [obj k] + (some? (unchecked-get obj k))) + (defn get-keys [obj] (js/Object.keys ^js obj)) diff --git a/frontend/src/app/util/path/parser.cljs b/frontend/src/app/util/path/parser.cljs index 09f491555..fc23adc61 100644 --- a/frontend/src/app/util/path/parser.cljs +++ b/frontend/src/app/util/path/parser.cljs @@ -9,14 +9,13 @@ [app.common.data :as d] [app.common.geom.point :as gpt] [app.common.geom.shapes.path :as gshp] + [app.common.math :as mth] [app.util.path.arc-to-curve :refer [a2c]] [app.util.path.commands :as upc] - [app.util.svg :as usvg] - [cuerdas.core :as str] - [clojure.set :as set] - [app.common.math :as mth] [app.util.path.geom :as upg] - )) + [app.util.svg :as usvg] + [clojure.set :as set] + [cuerdas.core :as str])) ;; (def commands-regex #"(?i)[mzlhvcsqta][^mzlhvcsqta]*") diff --git a/frontend/src/app/util/svg.cljs b/frontend/src/app/util/svg.cljs index 48e64746e..12c9e0d97 100644 --- a/frontend/src/app/util/svg.cljs +++ b/frontend/src/app/util/svg.cljs @@ -745,8 +745,6 @@ (reduce gmt/multiply (gmt/matrix) matrices)) (gmt/matrix))) - - (defn format-move [[x y]] (str "M" x " " y)) (defn format-line [[x y]] (str "L" x " " y)) From 61545ea13ebeca7cd88a6be88d5b5f2de11a20ad Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 2 Jun 2021 15:52:51 +0200 Subject: [PATCH 8/9] :sparkles: Import/export workers --- frontend/src/app/worker/export.cljs | 25 +------ frontend/src/app/worker/import.cljs | 103 +++++++++++++++++++++++++--- 2 files changed, 97 insertions(+), 31 deletions(-) diff --git a/frontend/src/app/worker/export.cljs b/frontend/src/app/worker/export.cljs index bb86dbfca..1d1dcb108 100644 --- a/frontend/src/app/worker/export.cljs +++ b/frontend/src/app/worker/export.cljs @@ -7,25 +7,12 @@ (ns app.worker.export (:require [app.main.render :as r] + [app.main.repo :as rp] [app.util.dom :as dom] - [app.util.http :as http] [app.util.zip :as uz] [app.worker.impl :as impl] [beicon.core :as rx])) -(defn- handle-response - [response] - (cond - (http/success? response) - (rx/of (:body response)) - - (http/client-error? response) - (rx/throw (:body response)) - - :else - (rx/throw {:type :unexpected - :code (:error response)}))) - (defn get-page-data [{file-name :file-name {:keys [id name] :as data} :data}] (->> (r/render-page data) @@ -35,13 +22,6 @@ :file-name file-name :markup markup})))) -(defn query-file [file-id] - (->> (http/send! {:uri "/api/rpc/query/file" - :query {:id file-id} - :method :get}) - (rx/map http/conditional-decode-transit) - (rx/mapcat handle-response))) - (defn process-pages [file] (let [pages (get-in file [:data :pages]) pages-index (get-in file [:data :pages-index])] @@ -59,7 +39,7 @@ (let [render-stream (->> (rx/from (->> files (mapv :id))) - (rx/merge-map query-file) + (rx/merge-map #(rp/query :file {:id %})) (rx/flat-map process-pages) (rx/observe-on :async) (rx/flat-map get-page-data) @@ -71,7 +51,6 @@ :data (str "Render " (:file-name %) " - " (:name %))))) (->> render-stream (rx/reduce collect-page []) - (rx/tap #(prn %)) (rx/flat-map uz/compress-files) (rx/map #(hash-map :type :finish :data (dom/create-uri %))))))) diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index 5ac524726..350035ed9 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -8,26 +8,109 @@ (:require [app.common.data :as d] [app.common.file-builder :as fb] + [app.common.pages :as cp] + [app.common.uuid :as uuid] + [app.main.repo :as rp] + [app.util.import.parser :as cip] [app.util.zip :as uz] [app.worker.impl :as impl] [beicon.core :as rx] [cuerdas.core :as str] [tubax.core :as tubax])) +;; Upload changes batches size +(def change-batch-size 100) + +(defn create-empty-file + "Create a new file on the back-end" + [project-id file] + (rp/mutation + :create-file + {:id (:id file) + :name (:name file) + :project-id project-id + :data (-> cp/empty-file-data + (assoc :id (:id file)))})) + +(defn send-changes + "Creates batches of changes to be sent to the backend" + [file init-revn] + (let [revn (atom init-revn) + file-id (:id file) + session-id (uuid/next) + changes-batches + (->> (fb/generate-changes file) + (partition change-batch-size change-batch-size nil) + (mapv vec))] + + (->> (rx/from changes-batches) + (rx/merge-map + (fn [cur-changes-batch] + (rp/mutation + :update-file + {:id file-id + :session-id session-id + :revn @revn + :changes cur-changes-batch}))) + + (rx/tap #(reset! revn (:revn %)))))) + +(defn persist-file + "Sends to the back-end the imported data" + [project-id file] + (->> (create-empty-file project-id file) + (rx/flat-map #(send-changes file (:revn %))))) + (defn parse-file-name [dir] (if (str/ends-with? dir "/") (subs dir 0 (dec (count dir))) dir)) -(defn parse-page-name [path] +(defn parse-page-name + [path] (let [[file page] (str/split path "/")] (str/replace page ".svg" ""))) -(defn import-page [file {:keys [path data]}] +(defn add-shape-file + [file node] + + (let [type (cip/get-type node) + close? (cip/close? node) + data (cip/parse-data type node)] + + (if close? + (case type + :frame + (fb/close-artboard file) + + :group + (fb/close-group file) + + ;; default + file) + + (case type + :frame (fb/add-artboard file data) + :group (fb/add-group file data) + :rect (fb/create-rect file data) + :circle (fb/create-circle file data) + :path (fb/create-path file data) + :text (fb/create-text file data) + :image (fb/create-image file data) + + ;; default + file)))) + +(defn import-page + [file {:keys [path content]}] (let [page-name (parse-page-name path)] - (-> file - (fb/add-page page-name)))) + (when (cip/valid? content) + (let [nodes (->> content cip/node-seq)] + (->> nodes + (filter cip/shape?) + (reduce add-shape-file (fb/add-page file page-name)) + (fb/close-page)))))) (defmethod impl/handler :import-file [{:keys [project-id files]}] @@ -49,8 +132,12 @@ (->> dir-str (rx/merge-map (fn [dir] - (->> file-str - (rx/filter #(str/starts-with? (:path %) dir)) - (rx/reduce import-page (fb/create-file (parse-file-name dir)))))) + (let [file (fb/create-file (parse-file-name dir))] + (rx/concat + (->> file-str + (rx/filter #(str/starts-with? (:path %) dir)) + (rx/reduce import-page file) + (rx/flat-map #(persist-file project-id %)) + (rx/ignore)) - (rx/map #(select-keys % [:id :name]))))) + (rx/of (select-keys file [:id :name]))))))))) From d855b930c52470f23f9f7738b16e1ff8c1a3fa2d Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 2 Jun 2021 16:09:50 +0200 Subject: [PATCH 9/9] :sparkles: Temporary UI --- .../styles/main/partials/dashboard.scss | 27 +++++++++++++++++++ .../src/app/main/ui/dashboard/file_menu.cljs | 22 +++++++++++++-- .../src/app/main/ui/dashboard/import.cljs | 23 ++++++++++------ .../src/app/main/ui/dashboard/projects.cljs | 15 +++++++++-- frontend/translations/en.po | 8 +++++- 5 files changed, 82 insertions(+), 13 deletions(-) diff --git a/frontend/resources/styles/main/partials/dashboard.scss b/frontend/resources/styles/main/partials/dashboard.scss index f8423ce48..780dd8794 100644 --- a/frontend/resources/styles/main/partials/dashboard.scss +++ b/frontend/resources/styles/main/partials/dashboard.scss @@ -165,3 +165,30 @@ } } } + +.import-file-btn { + align-items: center; + display: flex; + flex-direction: column; + height: 2rem; + justify-content: center; + overflow: hidden; + padding: 4px; + width: 2rem; + + background: none; + border: 1px solid $color-gray-20; + border-radius: 2px; + cursor: pointer; + transition: all 0.4s; + margin-left: 1rem; + + &:hover { + background: $color-primary; + } + + svg { + width: 16px; + height: 16px; + } +} diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index f24a8d412..6dcedd52a 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -11,8 +11,9 @@ [app.main.data.modal :as modal] [app.main.repo :as rp] [app.main.store :as st] - [app.main.ui.context :as ctx] [app.main.ui.components.context-menu :refer [context-menu]] + [app.main.ui.context :as ctx] + [app.main.worker :as uw] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.router :as rt] @@ -150,7 +151,22 @@ :hint (tr "modals.remove-shared-confirm.hint") :cancel-label :omit :accept-label (tr "modals.remove-shared-confirm.accept") - :on-accept del-shared})))] + :on-accept del-shared}))) + + on-export-files + (fn [event] + (->> (uw/ask-many! + {:cmd :export-file + :team-id current-team-id + :files files}) + (rx/subs + (fn [{:keys [type data] :as msg}] + (case type + :progress + (prn "[Progress]" data) + + :finish + (dom/save-as data "export" "application/zip" "Export package (*.zip)"))))))] (mf/use-effect (fn [] @@ -176,6 +192,7 @@ [[(tr "dashboard.duplicate-multi" file-count) on-duplicate] (when (or (seq current-projects) (seq other-teams)) [(tr "dashboard.move-to-multi" file-count) nil sub-options]) + #_[(tr "dashboard.export-multi" file-count) on-export-files] [:separator] [(tr "labels.delete-multi-files" file-count) on-delete]] @@ -187,6 +204,7 @@ (if (:is-shared file) [(tr "dashboard.remove-shared") on-del-shared] [(tr "dashboard.add-shared") on-add-shared]) + #_[(tr "dashboard.export-single") on-export-files] [:separator] [(tr "labels.delete") on-delete]])] diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index cd7e86cea..c30e48f03 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -14,12 +14,12 @@ [beicon.core :as rx] [rumext.alpha :as mf])) -(log/set-level! :warn) +(log/set-level! :debug) (defn use-import-file - [project-id] + [project-id on-finish-import] (mf/use-callback - (mf/deps project-id) + (mf/deps project-id on-finish-import) (fn [files] (when files (let [files (->> files (mapv dom/create-uri))] @@ -30,16 +30,23 @@ (rx/subs (fn [result] - (log/debug :action "import-result" :result result))))))))) + (log/debug :action "import-result" :result result)) + + (fn [err] + (log/debug :action "import-error" :result err)) + + (fn [] + (log/debug :action "import-end") + (when on-finish-import (on-finish-import)))))))))) (mf/defc import-button - [{:keys [project-id]}] + [{:keys [project-id on-finish-import]}] (let [file-input (mf/use-ref nil) - on-file-selected (use-import-file project-id)] + on-file-selected (use-import-file project-id on-finish-import)] [:form.import-file - [:button.import-file-icon {:type "button" - :on-click #(dom/click (mf/ref-val file-input))} i/import] + [:button.import-file-btn {:type "button" + :on-click #(dom/click (mf/ref-val file-input))} i/import] [:& file-uploader {:accept "application/zip" :multi true :input-ref file-input diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 342d377f0..7921624aa 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -21,7 +21,8 @@ [app.util.router :as rt] [app.util.time :as dt] [okulary.core :as l] - [rumext.alpha :as mf])) + [rumext.alpha :as mf] + [app.main.ui.dashboard.import :refer [import-button]])) (mf/defc header {::mf/wrap [mf/memo]} @@ -30,6 +31,7 @@ [:header.dashboard-header [:div.dashboard-title [:h1 (tr "dashboard.projects-title")]] + [:a.btn-secondary.btn-small {:on-click create} (tr "dashboard.new-project")]])) @@ -96,7 +98,13 @@ (fn [] (let [mdata {:on-success on-file-created} params {:project-id (:id project)}] - (st/emit! (dd/create-file (with-meta params mdata))))))] + (st/emit! (dd/create-file (with-meta params mdata)))))) + + on-finish-import + (mf/use-callback + (fn [] + (st/emit! (dd/fetch-recent-files) + (dd/clear-selected-files))))] [:div.dashboard-project-row {:class (when first? "first")} [:div.project @@ -130,6 +138,9 @@ (dt/timeago {:locale locale}))] [:span.recent-files-row-title-info (str ", " time)])) + #_[:& import-button {:project-id (:id project) + :on-finish-import on-finish-import}] + [:a.btn-secondary.btn-small {:on-click create-file} (tr "dashboard.new-file")]] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 1721be209..31d41d091 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2633,4 +2633,10 @@ msgid "workspace.updates.update" msgstr "Update" msgid "workspace.viewport.click-to-close-path" -msgstr "Click to close the path" \ No newline at end of file +msgstr "Click to close the path" + +msgid "dashboard.export-single" +msgstr "Export file" + +msgid "dashboard.export-multi" +msgstr "Export %s files"