From 2fb4e722406d0dd3a48cde2bc723e3f700c6f5e0 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 2 Jul 2020 14:48:17 +0200 Subject: [PATCH] :tada: Add stacked exports. --- common/uxbox/common/pages.cljc | 11 +- common/uxbox/common/spec.cljc | 1 + exporter/package-lock.json | 104 +++++++++++- exporter/package.json | 3 + exporter/shadow-cljs.edn | 3 +- exporter/src/app/browser.cljs | 33 ++-- exporter/src/app/http.cljs | 57 ++++--- exporter/src/app/http/bitmap_export.cljs | 120 ++++++++++++++ exporter/src/app/http/screenshot.cljs | 73 --------- exporter/src/app/zipfile.cljs | 13 ++ frontend/resources/locales.json | 89 ++++++----- .../partials/sidebar-element-options.scss | 39 +++++ .../main/ui/workspace/sidebar/options.cljs | 60 +------ .../ui/workspace/sidebar/options/exports.cljs | 151 ++++++++++++++++++ 14 files changed, 549 insertions(+), 208 deletions(-) create mode 100644 exporter/src/app/http/bitmap_export.cljs delete mode 100644 exporter/src/app/http/screenshot.cljs create mode 100644 exporter/src/app/zipfile.cljs create mode 100644 frontend/src/uxbox/main/ui/workspace/sidebar/options/exports.cljs diff --git a/common/uxbox/common/pages.cljc b/common/uxbox/common/pages.cljc index c018d243f..d3dc85e22 100644 --- a/common/uxbox/common/pages.cljc +++ b/common/uxbox/common/pages.cljc @@ -80,7 +80,7 @@ (s/def ::stroke-width number?) (s/def ::stroke-alignment #{:center :inner :outer}) (s/def ::text-align #{"left" "right" "center" "justify"}) -(s/def ::type #{:rect :path :circle :image :text :canvas :curve :icon :frame :group}) +(s/def ::type keyword?) (s/def ::x number?) (s/def ::y number?) (s/def ::cx number?) @@ -93,6 +93,14 @@ (s/def ::x2 number?) (s/def ::y2 number?) +(s/def ::suffix string?) +(s/def ::scale number?) +(s/def ::export + (s/keys :req-un [::type ::suffix ::scale])) + +(s/def ::exports (s/coll-of ::export :kind vector?)) + + (s/def ::selrect (s/keys :req-un [::x ::y ::x1 @@ -124,6 +132,7 @@ ::rx ::ry ::cx ::cy ::x ::y + ::exports ::stroke-color ::stroke-opacity ::stroke-style diff --git a/common/uxbox/common/spec.cljc b/common/uxbox/common/spec.cljc index 920f8f1b8..d959fbbbd 100644 --- a/common/uxbox/common/spec.cljc +++ b/common/uxbox/common/spec.cljc @@ -86,6 +86,7 @@ ;; --- Default Specs +(s/def ::keyword keyword?) (s/def ::inst inst?) (s/def ::string string?) (s/def ::email (s/conformer email-conformer str)) diff --git a/exporter/package-lock.json b/exporter/package-lock.json index 447ca3c57..cdbfff417 100644 --- a/exporter/package-lock.json +++ b/exporter/package-lock.json @@ -261,6 +261,11 @@ "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=", "dev": true }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, "cache-content-type": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", @@ -344,8 +349,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "create-ecdh": { "version": "4.0.3", @@ -697,11 +701,29 @@ } } }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, "ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" + }, + "inflation": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.0.0.tgz", + "integrity": "sha1-i0F+R8KPklpFEz2RTKH9OJEH8w8=" + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -724,8 +746,7 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isexe": { "version": "2.0.0", @@ -733,6 +754,41 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, + "jszip": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.5.0.tgz", + "integrity": "sha512-WRtu7TPCmYePR1nazfrtuF216cIVon/3GWOvHS9QR5bIwSbnxtdpma6un3jyGGNhHsKCSzn5Ypk+EkDRvTGiFA==", + "requires": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "set-immediate-shim": "~1.0.1" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, "keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -795,6 +851,14 @@ } } }, + "lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "requires": { + "immediate": "~3.0.5" + } + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -993,8 +1057,7 @@ "pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, "parse-asn1": { "version": "5.1.5", @@ -1053,8 +1116,7 @@ "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "progress": { "version": "2.0.3", @@ -1190,6 +1252,17 @@ "safe-buffer": "^5.1.0" } }, + "raw-body": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", + "integrity": "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.3", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, "readable-stream": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", @@ -1234,6 +1307,16 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "set-immediate-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", + "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=" + }, "setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -1497,6 +1580,11 @@ "through": "^2.3.8" } }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, "url": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", diff --git a/exporter/package.json b/exporter/package.json index ede2d8992..f0f99731a 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -9,9 +9,12 @@ "author": "UXBOX LABS SL", "license": "SEE LICENSE IN ", "dependencies": { + "inflation": "^2.0.0", + "jszip": "^3.5.0", "koa": "^2.13.0", "puppeteer": "^4.0.1", "puppeteer-cluster": "^0.21.0", + "raw-body": "^2.4.1", "xregexp": "^4.3.0" }, "devDependencies": { diff --git a/exporter/shadow-cljs.edn b/exporter/shadow-cljs.edn index 590fdea8e..ee9ed07c5 100644 --- a/exporter/shadow-cljs.edn +++ b/exporter/shadow-cljs.edn @@ -4,7 +4,8 @@ [funcool/cuerdas "2020.03.26-3"] [lambdaisland/glogi "1.0.63"] [metosin/reitit-core "0.5.2"] - [com.cognitect/transit-cljs "0.8.264"]] + [com.cognitect/transit-cljs "0.8.264"] + [frankiesardo/linked "1.3.0"]] :source-paths ["src" "../common"] :nrepl {:port 3497} diff --git a/exporter/src/app/browser.cljs b/exporter/src/app/browser.cljs index 2e22a3021..a0b572613 100644 --- a/exporter/src/app/browser.cljs +++ b/exporter/src/app/browser.cljs @@ -15,11 +15,13 @@ (f page))))) (defn emulate! - [page {:keys [viewport user-agent] - :or {user-agent USER-AGENT}}] + [page {:keys [viewport user-agent scale] + :or {user-agent USER-AGENT + scale 1}}] (let [[width height] viewport] (.emulate page #js {:viewport #js {:width width - :height height} + :height height + :deviceScaleFactor scale} :userAgent user-agent}))) (defn navigate! @@ -33,10 +35,20 @@ (.waitFor ^js page ms)) (defn screenshot - ([page] (screenshot page nil)) - ([page {:keys [full-page?] - :or {full-page? true}}] - (.screenshot ^js page #js {:fullPage full-page? :omitBackground true}))) + ([frame] (screenshot frame nil)) + ([frame {:keys [full-page? omit-background?] + :or {full-page? false + omit-background? false}}] + (.screenshot ^js frame #js {:fullPage full-page? + :omitBackground omit-background?}))) + +(defn eval! + [frame f] + (.evaluate ^js frame f)) + +(defn select + [frame selector] + (.$ ^js frame selector)) (defn set-cookie! [page {:keys [key value domain]}] @@ -47,16 +59,15 @@ (defn start! ([] (start! nil)) ([{:keys [concurrency concurrency-strategy] - :or {concurrency 2 - concurrency-strategy :browser}}] + :or {concurrency 10 + concurrency-strategy :incognito}}] (let [ccst (case concurrency-strategy :browser (.-CONCURRENCY_BROWSER ^js ppc/Cluster) :incognito (.-CONCURRENCY_CONTEXT ^js ppc/Cluster) :page (.-CONCURRENCY_PAGE ^js ppc/Cluster)) opts #js {:concurrency ccst :maxConcurrency concurrency - :puppeteerOptions #js {:args #js ["--no-sandbox" - "--explicitly-allowed-ports=6000"]}}] + :puppeteerOptions #js {:args #js ["--no-sandbox"]}}] (.launch ^js ppc/Cluster opts)))) (defn stop! diff --git a/exporter/src/app/http.cljs b/exporter/src/app/http.cljs index bd4add2a1..1b78e21e1 100644 --- a/exporter/src/app/http.cljs +++ b/exporter/src/app/http.cljs @@ -3,13 +3,14 @@ [promesa.core :as p] [lambdaisland.glogi :as log] [app.browser :as bwr] - [app.http.screenshot :refer [bitmap-handler - page-handler]] + [app.http.bitmap-export :refer [bitmap-export-handler]] [app.util.transit :as t] [reitit.core :as r] [cuerdas.core :as str] ["koa" :as koa] - ["http" :as http]) + ["http" :as http] + ["inflation" :as inflate] + ["raw-body" :as raw-body]) (:import goog.Uri)) @@ -26,11 +27,7 @@ [router ctx] (let [uri (.parse Uri (unchecked-get ctx "originalUrl"))] (when-let [match (r/match-by-path router (.getPath uri))] - (let [qparams (query-params uri) - params {:path (:path-params match) :query qparams}] - (assoc match - :params params - :query-params qparams))))) + (assoc match :query-params (query-params uri))))) (defn- handle-error [error request] @@ -72,15 +69,33 @@ (transient {}) (js/Object.keys orig))))) +(def parse-body? + #{"POST" "PUT" "DELETE"}) + +(defn- parse-body + [ctx] + (let [headers (unchecked-get ctx "headers") + ctype (unchecked-get headers "content-type")] + (when (parse-body? (.-method ^js ctx)) + (-> (inflate (.-req ^js ctx)) + (raw-body #js {:limit "5mb" :encoding "utf8"}) + (p/then (fn [data] + (cond-> data + (= ctype "application/transit+json") + (t/decode)))))))) + (defn- wrap-handler [f extra] (fn [ctx] - (let [cookies (unchecked-get ctx "cookies") - headers (parse-headers ctx) - request (assoc extra - :ctx ctx - :headers headers - :cookies cookies)] + (p/let [cookies (unchecked-get ctx "cookies") + headers (parse-headers ctx) + body (parse-body ctx) + request (assoc extra + :method (str/lower (unchecked-get ctx "method")) + :body body + :ctx ctx + :headers headers + :cookies cookies)] (-> (p/do! (f request)) (p/then (fn [rsp] (when (map? rsp) @@ -91,16 +106,20 @@ (def routes [["/export" - ["/bitmap" {:handler bitmap-handler}] - ["/page" {:handler page-handler}]]]) + ["/bitmap" {:handler bitmap-export-handler}]]]) (defn- router-handler [router] - (fn [{:keys [ctx] :as req}] + (fn [{:keys [ctx body] :as request}] (let [route (match router ctx) - request (assoc req + params (merge {} + (:query-params route) + (:path-params route) + (when (map? body) body)) + request (assoc request :route route - :params (:params route)) + :params params) + handler (get-in route [:data :handler])] (if (and route handler) (handler request) diff --git a/exporter/src/app/http/bitmap_export.cljs b/exporter/src/app/http/bitmap_export.cljs new file mode 100644 index 000000000..bb86807ec --- /dev/null +++ b/exporter/src/app/http/bitmap_export.cljs @@ -0,0 +1,120 @@ +(ns app.http.bitmap-export + (:require + [cuerdas.core :as str] + [app.browser :as bwr] + [app.config :as cfg] + [app.zipfile :as zip] + [lambdaisland.glogi :as log] + [cljs.spec.alpha :as s] + [promesa.core :as p] + [uxbox.common.exceptions :as exc :include-macros true] + [uxbox.common.data :as d] + [uxbox.common.pages :as cp] + [uxbox.common.spec :as us]) + (:import + goog.Uri)) + +(defn- screenshot-object + [browser {:keys [page-id object-id token scale suffix]}] + (letfn [(handle [page] + (let [path (str "/render-object/" page-id "/" object-id) + uri (doto (Uri. (:public-uri cfg/config)) + (.setPath "/") + (.setFragment path)) + cookie {:domain (str (.getDomain uri) + ":" + (.getPort uri)) + :key "auth-token" + :value token}] + (log/info :uri (.toString uri)) + (screenshot page (.toString uri) cookie))) + + (screenshot [page uri cookie] + (p/do! + (bwr/emulate! page {:viewport [1920 1080] + :scale scale}) + (bwr/set-cookie! page cookie) + (bwr/navigate! page uri) + (bwr/eval! page (js* "() => document.body.style.background = 'transparent'")) + (p/let [dom (bwr/select page "#screenshot")] + (bwr/screenshot dom {:omit-background? true + :type type}))))] + + (bwr/exec! browser handle))) + +(s/def ::name ::us/string) +(s/def ::page-id ::us/uuid) +(s/def ::object-id ::us/uuid) +(s/def ::scale ::us/number) +(s/def ::suffix ::us/string) +(s/def ::type ::us/keyword) + +(s/def ::suffix string?) +(s/def ::scale number?) +(s/def ::export + (s/keys :req-un [::type ::suffix ::scale])) + +(s/def ::exports (s/coll-of ::export :kind vector?)) + +(s/def ::bitmap-handler-params + (s/keys :req-un [::page-id ::object-id ::name ::exports])) + +(declare handle-single-export) +(declare handle-multiple-export) + +(defn bitmap-export-handler + [{:keys [params browser cookies] :as request}] + (let [{:keys [exports page-id object-id name]} (us/conform ::bitmap-handler-params params) + token (.get ^js cookies "auth-token")] + (case (count exports) + 0 (exc/raise :type :validation :code :missing-exports) + 1 (handle-single-export + request + (assoc (first exports) + :name name + :token token + :page-id page-id + :object-id object-id)) + (handle-multiple-export + request + (->> (d/enumerate exports) + (map (fn [[index item]] + (assoc item + :name name + :index index + :token token + :page-id page-id + :object-id object-id)))))))) + +(defn perform-bitmap-export + [browser params] + (p/let [content (screenshot-object browser params)] + {:content content + :filename (str (str/slug (:name params)) + (if (not (str/blank? (:suffix params ""))) + (:suffix params "") + (let [index (:index params 0)] + (when (pos? index) + (str "-" (inc index))))) + ".png") + :length (alength content) + :mime-type "image/png"})) + +(defn handle-single-export + [{:keys [browser]} params] + (p/let [result (perform-bitmap-export browser params)] + {:status 200 + :body (:content result) + :headers {"content-type" (:mime-type result) + "content-length" (:length result)}})) + +(defn handle-multiple-export + [{:keys [browser]} exports] + (let [proms (map (partial perform-bitmap-export browser) exports)] + (-> (p/all proms) + (p/then (fn [results] + (reduce #(zip/add! %1 (:filename %2) (:content %2)) (zip/create) results))) + (p/then (fn [fzip] + {:status 200 + :headers {"content-type" "application/zip"} + :body (.generateNodeStream ^js fzip)}))))) diff --git a/exporter/src/app/http/screenshot.cljs b/exporter/src/app/http/screenshot.cljs deleted file mode 100644 index feed7fe04..000000000 --- a/exporter/src/app/http/screenshot.cljs +++ /dev/null @@ -1,73 +0,0 @@ -(ns app.http.screenshot - (:require - [app.browser :as bwr] - [app.config :as cfg] - [lambdaisland.glogi :as log] - [cljs.spec.alpha :as s] - [promesa.core :as p] - [uxbox.common.exceptions :as exc :include-macros true] - [uxbox.common.spec :as us]) - (:import - goog.Uri)) - -(defn- load-and-screenshot - [page url cookie] - (p/do! - (bwr/emulate! page {:viewport [1920 1080]}) - (bwr/set-cookie! page cookie) - (bwr/navigate! page url) - (bwr/sleep page 500) - (.evaluate page (js* "() => document.body.style.background = 'transparent'")) - ;; (.screenshot ^js page #js {:omitBackground true :fullPage true}) - (p/let [dom (.$ page "#screenshot")] - (.screenshot ^js dom #js {:omitBackground true})))) - -(defn- take-screenshot - [browser {:keys [page-id object-id token]}] - (letfn [(on-browser [page] - (let [path (str "/render-object/" page-id "/" object-id) - uri (doto (Uri. (:public-uri cfg/config)) - (.setPath "/") - (.setFragment path)) - cookie {:domain (str (.getDomain uri) - ":" - (.getPort uri)) - :key "auth-token" - :value token}] - (log/info :uri (.toString uri)) - (load-and-screenshot page (.toString uri) cookie)))] - (bwr/exec! browser on-browser))) - -(s/def ::page-id ::us/uuid) -(s/def ::object-id ::us/uuid) -(s/def ::bitmap-handler-params - (s/keys :req-un [::page-id ::object-id])) - -(defn bitmap-handler - [{:keys [params browser cookies] :as request}] - (let [params (us/conform ::bitmap-handler-params (:query params)) - token (.get ^js cookies "auth-token")] - (-> (take-screenshot browser {:page-id (:page-id params) - :object-id (:object-id params) - :token token}) - (p/then (fn [result] - {:status 200 - :body result - :headers {"content-type" "image/png" - "content-length" (alength result)}}))))) - -(defn page-handler - [{:keys [params browser] :as request}] - (letfn [(screenshot [page uri] - (p/do! - (bwr/emulate! page {:viewport [1920 1080]}) - (bwr/navigate! page uri) - (bwr/sleep page 500) - ;; (.evaluate page (js* "() => document.body.style.background = 'transparent'")) - (.screenshot ^js page #js {:omitBackground false})))] - (p/let [uri (get-in params [:query :uri]) - sht (bwr/exec! browser #(screenshot % uri))] - {:status 200 - :body sht - :headers {"content-type" "image/png" - "content-length" (alength sht)}}))) diff --git a/exporter/src/app/zipfile.cljs b/exporter/src/app/zipfile.cljs new file mode 100644 index 000000000..ac9c16fe6 --- /dev/null +++ b/exporter/src/app/zipfile.cljs @@ -0,0 +1,13 @@ +(ns app.zipfile + (:require + ["jszip" :as jszip])) + +(defn create + [] + (new jszip)) + +(defn add! + [zfile name data] + (.file ^js zfile name data) + zfile) + diff --git a/frontend/resources/locales.json b/frontend/resources/locales.json index 110309dae..479c1c895 100644 --- a/frontend/resources/locales.json +++ b/frontend/resources/locales.json @@ -416,7 +416,7 @@ } }, "dashboard.sidebar.drafts" : { - "used-in" : [ "src/uxbox/main/ui/dashboard/sidebar.cljs:129" ], + "used-in" : [ "src/uxbox/main/ui/dashboard/sidebar.cljs:128" ], "translations" : { "en" : "Drafts", "fr" : "Brouillons", @@ -424,7 +424,7 @@ } }, "dashboard.sidebar.libraries" : { - "used-in" : [ "src/uxbox/main/ui/dashboard/sidebar.cljs:135" ], + "used-in" : [ "src/uxbox/main/ui/dashboard/sidebar.cljs:134" ], "translations" : { "en" : "Libraries", "fr" : "Librairies", @@ -432,7 +432,7 @@ } }, "dashboard.sidebar.recent" : { - "used-in" : [ "src/uxbox/main/ui/dashboard/sidebar.cljs:122" ], + "used-in" : [ "src/uxbox/main/ui/dashboard/sidebar.cljs:121" ], "translations" : { "en" : "Recent", "fr" : "Récent", @@ -528,7 +528,7 @@ } }, "ds.search.placeholder" : { - "used-in" : [ "src/uxbox/main/ui/dashboard/sidebar.cljs:188" ], + "used-in" : [ "src/uxbox/main/ui/dashboard/sidebar.cljs:187" ], "translations" : { "en" : "Search...", "fr" : "Rechercher...", @@ -584,7 +584,7 @@ } }, "errors.image-format-unsupported" : { - "used-in" : [ "src/uxbox/main/data/images.cljs:376", "src/uxbox/main/data/workspace/persistence.cljs:365", "src/uxbox/main/data/users.cljs:177" ], + "used-in" : [ "src/uxbox/main/data/users.cljs:177", "src/uxbox/main/data/workspace/persistence.cljs:365", "src/uxbox/main/data/images.cljs:376" ], "translations" : { "en" : "The image format is not supported (must be svg, jpg or png).", "fr" : "Le format d'image n'est pas supporté (doit être svg, jpg ou png).", @@ -592,7 +592,7 @@ } }, "errors.image-too-large" : { - "used-in" : [ "src/uxbox/main/data/images.cljs:374", "src/uxbox/main/data/workspace/persistence.cljs:363", "src/uxbox/main/data/users.cljs:175" ], + "used-in" : [ "src/uxbox/main/data/users.cljs:175", "src/uxbox/main/data/workspace/persistence.cljs:363", "src/uxbox/main/data/images.cljs:374" ], "translations" : { "en" : "The image is too large to be inserted (must be under 5mb).", "fr" : "L'image est trop grande (doit être inférieure à 5 Mo).", @@ -632,7 +632,7 @@ } }, "errors.unexpected-error" : { - "used-in" : [ "src/uxbox/main/data/images.cljs:385", "src/uxbox/main/data/workspace/persistence.cljs:334", "src/uxbox/main/data/workspace/persistence.cljs:374", "src/uxbox/main/data/users.cljs:185", "src/uxbox/main/ui/auth/register.cljs:54", "src/uxbox/main/ui/settings/change_email.cljs:51" ], + "used-in" : [ "src/uxbox/main/data/users.cljs:185", "src/uxbox/main/data/workspace/persistence.cljs:334", "src/uxbox/main/data/workspace/persistence.cljs:374", "src/uxbox/main/data/images.cljs:385", "src/uxbox/main/ui/settings/change_email.cljs:51", "src/uxbox/main/ui/workspace/sidebar/options/exports.cljs:65", "src/uxbox/main/ui/auth/register.cljs:54" ], "translations" : { "en" : "An unexpected error occurred.", "fr" : "Une erreur inattendue c'est produite", @@ -672,7 +672,7 @@ } }, "image.loading" : { - "used-in" : [ "src/uxbox/main/data/images.cljs:393", "src/uxbox/main/data/workspace/persistence.cljs:341", "src/uxbox/main/data/workspace/persistence.cljs:382", "src/uxbox/main/data/users.cljs:191" ], + "used-in" : [ "src/uxbox/main/data/users.cljs:191", "src/uxbox/main/data/workspace/persistence.cljs:341", "src/uxbox/main/data/workspace/persistence.cljs:382", "src/uxbox/main/data/images.cljs:393" ], "translations" : { "en" : "Loading image...", "fr" : "Chargement de l'image...", @@ -840,11 +840,12 @@ } }, "settings.multiple" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:132", "src/uxbox/main/ui/workspace/sidebar/options/rows/color_row.cljs:117", "src/uxbox/main/ui/workspace/sidebar/options/rows/color_row.cljs:126" ], "translations" : { - "en" : "Multiple", - "es" : "Múltiple" - } + "en" : null, + "fr" : null, + "es" : null + }, + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/rows/color_row.cljs:117", "src/uxbox/main/ui/workspace/sidebar/options/rows/color_row.cljs:126", "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:132" ] }, "settings.new-email-label" : { "used-in" : [ "src/uxbox/main/ui/settings/change_email.cljs:75" ], @@ -1367,27 +1368,33 @@ } }, "workspace.options.design" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options.cljs:70" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options.cljs:65" ], "translations" : { "en" : "Design", "fr" : "Conception", "es" : "Diseño" } }, - "workspace.options.export-object" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options.cljs:78" ], + "workspace.options.export" : { + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/exports.cljs:115", "src/uxbox/main/ui/workspace/sidebar/options/exports.cljs:148" ], "translations" : { "en" : "Export" } }, + "workspace.options.export-object" : { + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/exports.cljs:144" ], + "translations" : { + "en" : "Export shape" + } + }, "workspace.options.exporting-object" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options.cljs:77" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/exports.cljs:143" ], "translations" : { "en" : "Exporting..." } }, "workspace.options.fill" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/fill.cljs:40", "src/uxbox/main/ui/workspace/sidebar/options/text.cljs:382" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/text.cljs:382", "src/uxbox/main/ui/workspace/sidebar/options/fill.cljs:40" ], "translations" : { "en" : "Fill", "fr" : "Remplissage", @@ -1395,7 +1402,7 @@ } }, "workspace.options.font-options" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/text.cljs:388" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/text.cljs:387" ], "translations" : { "en" : "Text", "fr" : "Texte", @@ -1715,18 +1722,20 @@ } }, "workspace.options.group-fill" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/fill.cljs:39" ], "translations" : { - "en" : "Group fill", - "es" : "Relleno de grupo" - } + "en" : null, + "fr" : null, + "es" : null + }, + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/fill.cljs:39" ] }, "workspace.options.group-stroke" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:65" ], "translations" : { - "en" : "Group stroke", - "es" : "Borde de grupo" - } + "en" : null, + "fr" : null, + "es" : null + }, + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:65" ] }, "workspace.options.navigate-to" : { "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/interactions.cljs:59" ], @@ -1745,7 +1754,7 @@ } }, "workspace.options.position" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:112", "src/uxbox/main/ui/workspace/sidebar/options/frame.cljs:125" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:144", "src/uxbox/main/ui/workspace/sidebar/options/frame.cljs:125" ], "translations" : { "en" : "Position", "fr" : "Position", @@ -1753,7 +1762,7 @@ } }, "workspace.options.prototype" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options.cljs:80" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options.cljs:74" ], "translations" : { "en" : "Prototype", "fr" : "Prototype", @@ -1761,7 +1770,7 @@ } }, "workspace.options.radius" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:158" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:186" ], "translations" : { "en" : "Radius", "fr" : "Rayon", @@ -1769,7 +1778,7 @@ } }, "workspace.options.rotation" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:131" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:163" ], "translations" : { "en" : "Rotation", "fr" : "Rotation", @@ -1793,21 +1802,23 @@ } }, "workspace.options.selection-fill" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/fill.cljs:38" ], "translations" : { - "en" : "Selection fill", - "es" : "Relleno de selección" - } + "en" : null, + "fr" : null, + "es" : null + }, + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/fill.cljs:38" ] }, "workspace.options.selection-stroke" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:64" ], "translations" : { - "en" : "Selection stroke", - "es" : "Borde de selección" - } + "en" : null, + "fr" : null, + "es" : null + }, + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/stroke.cljs:64" ] }, "workspace.options.size" : { - "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:82", "src/uxbox/main/ui/workspace/sidebar/options/frame.cljs:98" ], + "used-in" : [ "src/uxbox/main/ui/workspace/sidebar/options/measures.cljs:114", "src/uxbox/main/ui/workspace/sidebar/options/frame.cljs:98" ], "translations" : { "en" : "Size", "fr" : "Taille", diff --git a/frontend/resources/styles/main/partials/sidebar-element-options.scss b/frontend/resources/styles/main/partials/sidebar-element-options.scss index 1dd887221..d3151f6d2 100644 --- a/frontend/resources/styles/main/partials/sidebar-element-options.scss +++ b/frontend/resources/styles/main/partials/sidebar-element-options.scss @@ -760,3 +760,42 @@ cursor: auto; } } + + +.element-set-options-group { + display: flex; + padding: 3px; + border: 1px solid $color-black; + border-radius: 4px; + + &:hover { + background: #1F1F1F; + } +} + +.exports-options { + .element-set-options-group { + justify-content: space-between; + .delete-icon { + display: flex; + min-width: 40px; + min-height: 40px; + justify-content: center; + align-items: center; + cursor: pointer; + svg { + width: 20px; + height: 20px; + fill: $color-gray-20; + } + } + + &:not(:first-child) { + margin-top: 7px; + } + } + + .download-button { + margin-top: 10px; + } +} diff --git a/frontend/src/uxbox/main/ui/workspace/sidebar/options.cljs b/frontend/src/uxbox/main/ui/workspace/sidebar/options.cljs index a250c3418..32bfb2fcc 100644 --- a/frontend/src/uxbox/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/uxbox/main/ui/workspace/sidebar/options.cljs @@ -10,11 +10,8 @@ (ns uxbox.main.ui.workspace.sidebar.options (:require [beicon.core :as rx] - [cljs.spec.alpha :as s] - [cuerdas.core :as str] [rumext.alpha :as mf] [uxbox.common.spec :as us] - [uxbox.main.data.messages :as dm] [uxbox.main.data.workspace :as udw] [uxbox.main.refs :as refs] [uxbox.main.store :as st] @@ -22,6 +19,7 @@ [uxbox.main.ui.icons :as i] [uxbox.main.ui.workspace.sidebar.align :refer [align-options]] [uxbox.main.ui.workspace.sidebar.options.circle :as circle] + [uxbox.main.ui.workspace.sidebar.options.exports :refer [exports-menu]] [uxbox.main.ui.workspace.sidebar.options.frame :as frame] [uxbox.main.ui.workspace.sidebar.options.group :as group] [uxbox.main.ui.workspace.sidebar.options.icon :as icon] @@ -32,61 +30,11 @@ [uxbox.main.ui.workspace.sidebar.options.interactions :refer [interactions-menu]] [uxbox.main.ui.workspace.sidebar.options.path :as path] [uxbox.main.ui.workspace.sidebar.options.rect :as rect] - [uxbox.util.dom :as dom] - [uxbox.util.http :as http] - [uxbox.util.i18n :as i18n :refer [tr t]] - [uxbox.util.object :as obj])) + [uxbox.main.ui.workspace.sidebar.options.text :as text] + [uxbox.util.i18n :as i18n :refer [tr t]])) ;; --- Options -(defn- request-screenshot - [page-id shape-id] - (http/send! {:method :get - :uri "/export/bitmap" - :query {:page-id page-id - :object-id shape-id}} - {:credentials? true - :response-type :blob})) - -(defn- trigger-download - [name blob] - (let [link (dom/create-element "a") - uri (dom/create-uri blob)] - (obj/set! link "href" uri) - (obj/set! link "download" (str/slug name)) - (obj/set! (.-style ^js link) "display" "none") - (.appendChild (.-body ^js js/document) link) - (.click link) - (.remove link))) - -(mf/defc shape-export - {::mf/wrap [mf/memo]} - [{:keys [shape page] :as props}] - (let [loading? (mf/use-state false) - locale (mf/deref i18n/locale) - on-click (fn [event] - (dom/prevent-default event) - (swap! loading? not) - (->> (request-screenshot (:id page) (:id shape)) - (rx/subs - (fn [{:keys [status body] :as response}] - (if (= status 200) - (trigger-download (:name shape) body) - (st/emit! (dm/error (tr "errors.unexpected-error"))))) - (constantly nil) - (fn [] - (swap! loading? not)))))] - - [:div.element-set - [:div.btn-large.btn-icon-dark - {:on-click (when-not @loading? on-click) - :class (dom/classnames - :btn-disabled @loading?) - :disabled @loading?} - (if @loading? - (t locale "workspace.options.exporting-object") - (t locale "workspace.options.export-object"))]])) - (mf/defc shape-options {::mf/wrap [#(mf/throttle % 60)]} [{:keys [shape page] :as props}] @@ -102,7 +50,7 @@ :curve [:& path/options {:shape shape}] :image [:& image/options {:shape shape}] nil) - [:& shape-export {:shape shape :page page}]]) + [:& exports-menu {:shape shape :page page}]]) (mf/defc options-content diff --git a/frontend/src/uxbox/main/ui/workspace/sidebar/options/exports.cljs b/frontend/src/uxbox/main/ui/workspace/sidebar/options/exports.cljs new file mode 100644 index 000000000..b383cd91d --- /dev/null +++ b/frontend/src/uxbox/main/ui/workspace/sidebar/options/exports.cljs @@ -0,0 +1,151 @@ +;; 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 uxbox.main.ui.workspace.sidebar.options.exports + (:require + [cuerdas.core :as str] + [beicon.core :as rx] + [rumext.alpha :as mf] + [uxbox.common.data :as d] + [uxbox.main.ui.icons :as i] + [uxbox.main.data.messages :as dm] + [uxbox.main.data.workspace :as udw] + [uxbox.main.store :as st] + [uxbox.util.object :as obj] + [uxbox.util.dom :as dom] + [uxbox.util.http-api :as http] + [uxbox.util.i18n :as i18n :refer [tr t]])) + +(defn- request-export + [shape exports] + (http/send! {:method :post + :uri "/export/bitmap" + :response-type :blob + :auth true + :body {:page-id (:page-id shape) + :object-id (:id shape) + :name (:name shape) + :exports exports}})) + +(defn- trigger-download + [name blob] + (let [link (dom/create-element "a") + uri (dom/create-uri blob)] + (obj/set! link "href" uri) + (obj/set! link "download" (str/slug name)) + (obj/set! (.-style ^js link) "display" "none") + (.appendChild (.-body ^js js/document) link) + (.click link) + (.remove link))) + +(mf/defc exports-menu + [{:keys [shape page] :as props}] + (let [locale (mf/deref i18n/locale) + exports (:exports shape []) + loading? (mf/use-state false) + + on-download + (mf/use-callback + (mf/deps shape) + (fn [event] + (dom/prevent-default event) + (swap! loading? not) + (->> (request-export (assoc shape :page-id (:id page)) exports) + (rx/subs + (fn [{:keys [status body] :as response}] + (js/console.log status body) + (if (= status 200) + (trigger-download (:name shape) body) + (st/emit! (dm/error (tr "errors.unexpected-error"))))) + (constantly nil) + (fn [] + (swap! loading? not)))))) + + _ (prn "exports-menu" exports) + + add-export + (mf/use-callback + (mf/deps shape) + (fn [] + (let [xspec {:type :png + :suffix "" + :scale 1}] + (st/emit! (udw/update-shape (:id shape) + {:exports (conj exports xspec)}))))) + delete-export + (mf/use-callback + (mf/deps shape) + (fn [index] + (let [[before after] (split-at index exports) + exports (d/concat [] before (rest after))] + (st/emit! (udw/update-shape (:id shape) + {:exports exports}))))) + + on-scale-change + (mf/use-callback + (mf/deps shape) + (fn [index event] + (let [target (dom/get-target event) + value (dom/get-value target) + value (d/parse-double value) + exports (assoc-in exports [index :scale] value)] + (st/emit! (udw/update-shape (:id shape) + {:exports exports}))))) + + on-suffix-change + (mf/use-callback + (mf/deps shape) + (fn [index event] + (let [target (dom/get-target event) + value (dom/get-value target) + exports (assoc-in exports [index :suffix] value)] + (st/emit! (udw/update-shape (:id shape) + {:exports exports})))))] + + + (if (seq exports) + [:div.element-set.exports-options + [:div.element-set-title + [:span (t locale "workspace.options.export")] + [:div.add-page {:on-click add-export} i/close]] + [:div.element-set-content + (for [[index export] (d/enumerate exports)] + [:div.element-set-options-group + {:key index} + [:select.input-select {:on-change (partial on-scale-change index) + :value (:scale export)} + [:option {:value "0.5"} "0.5x"] + [:option {:value "0.75"} "0.75x"] + [:option {:value "1"} "1x"] + [:option {:value "1.5"} "1.5x"] + [:option {:value "2"} "2x"] + [:option {:value "4"} "4x"] + [:option {:value "6"} "6x"]] + [:input.input-text {:value (:suffix export) + :on-change (partial on-suffix-change index)}] + [:select.input-select + [:option {:value "png"} "PNG"]] + [:div.delete-icon {:on-click (partial delete-export index)} + i/trash]]) + + [:div.btn-large.btn-icon-dark.download-button + {:on-click (when-not @loading? on-download) + :class (dom/classnames + :btn-disabled @loading?) + :disabled @loading?} + (if @loading? + (t locale "workspace.options.exporting-object") + (t locale "workspace.options.export-object"))]]] + + [:div.element-set + [:div.element-set-title + [:span (t locale "workspace.options.export")] + [:div.add-page {:on-click add-export} i/close]]]))) + +