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])))))