diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc index a8db38407..7d5cb5e51 100644 --- a/common/src/app/common/colors.cljc +++ b/common/src/app/common/colors.cljc @@ -10,10 +10,13 @@ (def black "#000000") (def canvas "#E8E9EA") (def default-layout "#DE4762") +(def gray-10 "#E3E3E3") (def gray-20 "#B1B2B5") (def gray-30 "#7B7D85") +(def gray-40 "#64666A") (def info "#59B9E2") (def test "#fabada") (def white "#FFFFFF") (def primary "#31EFB8") - +(def danger "#E65244") +(def warning "#FC8802") diff --git a/common/src/app/common/pages/common.cljc b/common/src/app/common/pages/common.cljc index 1a586beb5..306a846b6 100644 --- a/common/src/app/common/pages/common.cljc +++ b/common/src/app/common/pages/common.cljc @@ -65,7 +65,8 @@ :masked-group? :mask-group :constraints-h :constraints-group :constraints-v :constraints-group - :fixed-scroll :constraints-group}) + :fixed-scroll :constraints-group + :exports :exports-group}) ;; Attributes that may directly be edited by the user with forms (def editable-attrs @@ -99,7 +100,9 @@ :stroke-opacity :stroke-color-gradient :stroke-cap-start - :stroke-cap-end} + :stroke-cap-end + + :exports} :group #{:proportion-lock :width :height @@ -120,7 +123,9 @@ :shadow - :blur} + :blur + + :exports} :rect #{:proportion-lock :width :height @@ -147,7 +152,7 @@ :fill-color-ref-id :fill-color-ref-file :fill-color-gradient - + :strokes :stroke-style :stroke-alignment @@ -162,7 +167,9 @@ :shadow - :blur} + :blur + + :exports} :circle #{:proportion-lock :width :height @@ -202,7 +209,9 @@ :shadow - :blur} + :blur + + :exports} :path #{:proportion-lock :width :height @@ -242,7 +251,9 @@ :shadow - :blur} + :blur + + :exports} :text #{:proportion-lock :width :height @@ -305,7 +316,9 @@ :text-transform - :grow-type} + :grow-type + + :exports} :image #{:proportion-lock :width :height @@ -328,7 +341,9 @@ :shadow - :blur} + :blur + + :exports} :svg-raw #{:proportion-lock :width :height @@ -370,7 +385,9 @@ :shadow - :blur} + :blur + + :exports} :bool #{:proportion-lock :width :height @@ -410,5 +427,7 @@ :shadow - :blur}}) + :blur + + :exports}}) diff --git a/frontend/resources/images/export-no-shapes.png b/frontend/resources/images/export-no-shapes.png new file mode 100644 index 000000000..3e8a143c3 Binary files /dev/null and b/frontend/resources/images/export-no-shapes.png differ diff --git a/frontend/resources/images/icons/checkbox-intermediate.svg b/frontend/resources/images/icons/checkbox-intermediate.svg new file mode 100644 index 000000000..26502626c --- /dev/null +++ b/frontend/resources/images/icons/checkbox-intermediate.svg @@ -0,0 +1 @@ +<svg id="screenshot" viewBox="10064.99280029184 896.9999999999992 40.00000000000364 40.000000000000796" width="40" height="40" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="-webkit-print-color-adjust: exact;"><g id="shape-3042ec00-9b94-11ec-b905-cb847d55c7a9"><path d="M10064.99280029184,897.0000000000006L10064.99280029184,937L10104.992800291844,936.9999999999995L10104.992800291842,896.9999999999992L10064.99280029184,897.0000000000006ZL10064.99280029184,897.0000000000006ZM10067.920800291844,899.9319999999997L10102.064800291844,899.9319999999999L10102.064800291844,934.0679999999994L10067.920800291844,934.068L10067.920800291844,899.9319999999997ZL10067.920800291844,899.9319999999997ZM10073,915L10097,915L10097,919L10073,919L10073,915Z"/></g></svg> diff --git a/frontend/resources/styles/main/partials/modal.scss b/frontend/resources/styles/main/partials/modal.scss index 44ebe2550..222dea85c 100644 --- a/frontend/resources/styles/main/partials/modal.scss +++ b/frontend/resources/styles/main/partials/modal.scss @@ -96,7 +96,6 @@ height: 30px; justify-content: center; margin-right: 16px; - width: 30px; svg { transform: rotate(45deg); @@ -140,7 +139,7 @@ .modal-footer { display: flex; height: 63px; - padding: 0px 16px; + padding: 0px 18px; border-top: 1px solid $color-gray-10; .action-buttons { @@ -235,12 +234,17 @@ } .import-dialog, -.export-dialog { +.export-dialog, +.export-shapes-dialog { background-color: $color-white; border: 1px solid $color-gray-20; width: 30rem; min-height: 14rem; + &.no-shapes { + width: 39rem; + } + p { font-size: $fs14; color: $color-black; @@ -259,9 +263,9 @@ border: 1px solid $color-gray-20; background: $color-white; border-radius: 3px; - padding: 0.3rem 1.25rem; + padding: 0.5rem 2.25rem; cursor: pointer; - margin-right: 8px; + margin-right: 18px; &:hover { background: $color-gray-20; @@ -274,7 +278,7 @@ border: 1px solid $color-primary; color: $color-black; cursor: pointer; - padding: 0.3rem 1.25rem; + padding: 0.5rem 2.25rem; &[disabled] { border: 1px solid #e3e3e3; @@ -295,7 +299,7 @@ padding-left: 2rem; h2 { - font-size: $fs14; + font-size: $fs18; } } @@ -303,11 +307,6 @@ padding: 1rem; } - svg { - max-width: 18px; - max-height: 18px; - } - .file-entry { margin: 0.75rem 1rem; user-select: none; @@ -515,6 +514,38 @@ &.selected { border: 1px solid $color-primary; } + + &.table-row { + align-items: center; + display: flex; + flex-wrap: wrap; + height: 45px; + justify-content: space-between; + padding: 0px 0px; + width: 100%; + } + + .table-field { + flex-grow: 0; + padding: 0px 4px; + overflow: hidden; + text-overflow: ellipsis; + width: 50px; + + &.check { + width: 30px; + } + + &.scale { + flex-grow: 1; + width: 15%; + } + + &.name { + flex-grow: 1; + width: 40%; + } + } } .option-container { @@ -1306,3 +1337,216 @@ } } } + +// Export shapes + +.export-progress-modal-overlay { + display: flex; + justify-content: center; + position: fixed; + right: 1rem; + top: 3rem; + padding: 16px 18px; + background-color: $color-white; + border: 1px solid $color-gray-20; + border-radius: 3px; + z-index: 1000; + + &.transparent { + background-color: rgba($color-white, 0); + } + + .export-progress-modal-container { + display: flex; + flex-direction: column; + justify-content: space-around; + height: 100%; + width: 100%; + + .progress-bar { + margin-top: 1rem; + } + + .export-progress-modal-header { + align-items: center; + display: flex; + justify-content: stretch; + margin-bottom: 7px; + + .modal-close-button { + display: flex; + justify-content: center; + background-color: transparent; + border: none; + cursor: pointer; + padding: 2px 0; + + svg { + height: 18px; + width: 18px; + transform: rotate(45deg); + } + } + + .export-progress-modal-title { + padding: 0; + margin: 0; + color: $color-black; + flex-grow: 1; + font-size: $fs16; + } + + .progress { + color: $color-gray-30; + font-size: $fs16; + margin-bottom: 0; + padding-right: 16px; + text-align: right; + } + + .retry { + font-size: $fs12; + margin-right: 16px; + padding: 4px 12px; + } + } + } +} + +.export-shapes-dialog { + .modal-content { + padding: 0; + } + + .body { + overflow-y: auto; + margin: 0.5rem 0.5rem 0.5rem 0; + } + + .field { + flex-grow: 0; + margin: 10px 0; + padding: 0px 4px; + overflow: hidden; + text-overflow: ellipsis; + width: 50px; + + &.image { + align-items: center; + border: 1px solid $color-gray-10; + border-radius: 3px; + display: flex; + justify-content: center; + height: 32px; + width: 32px; + + svg { + height: 20px; + width: 24px; + } + } + + &.check { + cursor: pointer; + height: 18px; + padding: 0; + width: 30px; + svg { + fill: $color-white; + max-width: 18px; + max-height: 18px; + } + + & .checked { + svg { + background-color: $color-primary; + } + } + + & .unchecked { + svg { + background-color: $color-gray-10; + } + } + } + + &.title { + flex-grow: 1; + font-size: $fs12; + color: $color-black; + } + + &.name { + flex-grow: 1; + font-size: $fs16; + color: $color-black; + width: 45%; + white-space: nowrap; + } + + &.scale { + width: 25%; + } + + &.scale, + &.extension { + color: $color-gray-30; + font-size: $fs12; + } + } + + .header { + align-items: center; + border-bottom: 1px solid $color-gray-10; + display: flex; + flex-wrap: wrap; + height: 32px; + justify-content: space-between; + padding: 0.5rem 2rem; + margin: 0; + width: 100%; + + .field { + margin: 0; + } + } + + .row { + align-items: center; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + height: 3rem; + margin: 0 0.5rem 0 2rem; + width: calc(100% - 2.5rem); + + &:not(:first-child) { + border-top: 1px solid $color-gray-10; + } + } + + .modal-footer { + padding: 18px; + } + + .no-selection { + padding: 2rem 1rem 2rem 2rem; + + img { + color: $color-primary-dark; + float: right; + margin-left: 4rem; + width: 176px; + } + + a { + font-size: $fs12; + } + + p { + color: $color-gray-40; + font-size: $fs16; + padding: 1rem 0 0 0; + } + } +} diff --git a/frontend/resources/styles/main/partials/workspace-header.scss b/frontend/resources/styles/main/partials/workspace-header.scss index 3a5f1af69..a657af70e 100644 --- a/frontend/resources/styles/main/partials/workspace-header.scss +++ b/frontend/resources/styles/main/partials/workspace-header.scss @@ -298,8 +298,14 @@ } } +.export-progress-widget { + cursor: pointer; + padding-top: 6px; +} + .persistence-status-widget { display: flex; + margin-left: 0px; margin-right: 10px; /* border: 1px solid red; */ width: 150px; diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 9fb795d9c..4c8a40355 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -11,6 +11,7 @@ [app.config :as cf] [app.main.data.events :as ev] [app.main.data.users :as du] + [app.main.data.websocket :as ws] [app.main.errors] [app.main.sentry :as sentry] [app.main.store :as st] @@ -58,7 +59,15 @@ (->> stream (rx/filter du/profile-fetched?) (rx/take 1) - (rx/map #(rt/init-routes))))))) + (rx/map #(rt/init-routes))) + + (->> stream + (rx/filter du/profile-fetched?) + (rx/map deref) + (rx/filter du/is-authenticated?) + (rx/take 1) + (rx/map #(ws/initialize))))))) + (def essential-only? (let [href (.-href ^js glob/location)] diff --git a/frontend/src/app/main/data/exports.cljs b/frontend/src/app/main/data/exports.cljs new file mode 100644 index 000000000..380f098f2 --- /dev/null +++ b/frontend/src/app/main/data/exports.cljs @@ -0,0 +1,225 @@ +;; 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.data.exports + (:require + [app.main.data.modal :as modal] + [app.main.data.workspace.persistence :as dwp] + [app.main.data.workspace.state-helpers :as wsh] + [app.main.repo :as rp] + [app.main.store :as st] + [app.util.dom :as dom] + [app.util.time :as dt] + [app.util.websocket :as ws] + [beicon.core :as rx] + [potok.core :as ptk])) + +(def default-timeout 5000) + +(defn toggle-detail-visibililty + [] + (ptk/reify ::toggle-detail-visibililty + ptk/UpdateEvent + (update [_ state] + (update-in state [:export :detail-visible] not)))) + +(defn toggle-widget-visibililty + [] + (ptk/reify ::toggle-widget-visibility + ptk/UpdateEvent + (update [_ state] + (update-in state [:export :widget-visible] not)))) + +(defn clear-export-state + [id] + (ptk/reify ::clear-export-state + ptk/UpdateEvent + (update [_ state] + ;; only clear if the existing export is the same + (let [existing-id (-> state :export :id)] + (if (and (some? existing-id) + (not= id existing-id)) + state + (dissoc state :export)))))) + +(defn show-workspace-export-dialog + ([] (show-workspace-export-dialog nil)) + ([{:keys [selected]}] + (ptk/reify ::show-workspace-export-dialog + ptk/WatchEvent + (watch [_ state _] + (let [file-id (:current-file-id state) + page-id (:current-page-id state) + + filename (-> (wsh/lookup-page state page-id) :name) + selected (or selected (wsh/lookup-selected state page-id {})) + + shapes (if (seq selected) + (wsh/lookup-shapes state selected) + (wsh/filter-shapes state #(pos? (count (:exports %))))) + + exports (for [shape shapes + export (:exports shape)] + (-> export + (assoc :enabled true) + (assoc :page-id page-id) + (assoc :file-id file-id) + (assoc :object-id (:id shape)) + (assoc :shape (dissoc shape :exports)) + (assoc :name (:name shape))))] + + (rx/of (modal/show :export-shapes + {:exports (vec exports) + :filename filename}))))))) + +(defn show-viewer-export-dialog + [{:keys [shapes filename page-id file-id exports]}] + (ptk/reify ::show-viewer-export-dialog + ptk/WatchEvent + (watch [_ _ _] + (let [exports (for [shape shapes + export exports] + (-> export + (assoc :enabled true) + (assoc :page-id page-id) + (assoc :file-id file-id) + (assoc :object-id (:id shape)) + (assoc :shape (dissoc shape :exports)) + (assoc :name (:name shape))))] + (rx/of (modal/show :export-shapes {:exports (vec exports) + :filename filename})))))) + +(defn- initialize-export-status + [exports filename resource-id] + (ptk/reify ::initialize-export-status + ptk/UpdateEvent + (update [_ state] + (assoc state :export {:in-progress true + :resource-id resource-id + :healthy? true + :error false + :progress 0 + :widget-visible true + :detail-visible true + :exports exports + :filename filename + :last-update (dt/now)})))) + +(defn- update-export-status + [{:keys [progress status resource-id name] :as data}] + (ptk/reify ::update-export-status + ptk/UpdateEvent + (update [_ state] + (let [time-diff (dt/diff (dt/now) + (get-in state [:export :last-update])) + healthy? (< time-diff (dt/duration {:seconds 6}))] + (cond-> state + (= status "running") + (update :export assoc :progress (:done progress) :last-update (dt/now) :healthy? healthy?) + + (= status "error") + (update :export assoc :error (:cause data) :last-update (dt/now) :healthy? healthy?) + + (= status "ended") + (update :export assoc :in-progress false :last-update (dt/now) :healthy? healthy?)))) + + ptk/WatchEvent + (watch [_ _ _] + (when (= status "ended") + (->> (rp/query! :download-export-resource resource-id) + (rx/delay 500) + (rx/map #(dom/trigger-download name %))))))) + +(defn request-simple-export + [{:keys [export filename]}] + (ptk/reify ::request-simple-export + ptk/UpdateEvent + (update [_ state] + (update state :export assoc :in-progress true)) + + ptk/WatchEvent + (watch [_ state _] + (let [profile-id (:profile-id state) + params {:exports [export] + :profile-id profile-id}] + (rx/concat + (rx/of ::dwp/force-persist) + (->> (rp/query! :export-shapes-simple params) + (rx/map (fn [data] + (dom/trigger-download filename data) + (fn [state] + (dissoc state :export)))))))))) + +(defn request-multiple-export + [{:keys [filename exports] :as params}] + (ptk/reify ::request-multiple-export + ptk/WatchEvent + (watch [_ state _] + (let [resource-id (volatile! nil) + profile-id (:profile-id state) + ws-conn (:ws-conn state) + params {:exports exports + :name filename + :profile-id profile-id + :wait false} + + progress-stream + (->> (ws/get-rcv-stream ws-conn) + (rx/filter ws/message-event?) + (rx/map :payload) + (rx/filter #(= :export-update (:type %))) + (rx/filter #(= @resource-id (:resource-id %))) + (rx/share)) + + stoper + (->> progress-stream + (rx/filter #(or (= "ended" (:status %)) + (= "error" (:status %)))))] + + (swap! st/ongoing-tasks conj :export) + + (rx/merge + ;; Force that all data is persisted; best effort. + (rx/of ::dwp/force-persist) + + ;; Launch the exportation process and stores the resource id + ;; locally. + (->> (rp/query! :export-shapes-multiple params) + (rx/tap (fn [{:keys [id]}] + (vreset! resource-id id))) + (rx/map (fn [{:keys [id]}] + (initialize-export-status exports filename id)))) + + ;; We proceed to update the export state with incoming + ;; progress updates. We delay the stoper for give some time + ;; to update the status with ended or errored status before + ;; close the stream. + (->> progress-stream + (rx/map update-export-status) + (rx/take-until (rx/delay 500 stoper)) + (rx/finalize (fn [] + (swap! st/ongoing-tasks disj :export)))) + + ;; We hide need to hide the ui elements of the export after + ;; some interval. We also delay a litle bit more the stopper + ;; for ensure that after some security time, the stream is + ;; completelly closed. + (->> progress-stream + (rx/filter #(= "ended" (:status %))) + (rx/take 1) + (rx/delay default-timeout) + (rx/map #(clear-export-state @resource-id)) + (rx/take-until (rx/delay 6000 stoper)))))))) + + +(defn retry-last-export + [] + (ptk/reify ::retry-last-export + ptk/WatchEvent + (watch [_ state _] + (let [{:keys [exports filename]} (:export state)] + (rx/of (request-multiple-export {:exports exports :filename filename})))))) + diff --git a/frontend/src/app/main/data/users.cljs b/frontend/src/app/main/data/users.cljs index cda89444a..a13b37116 100644 --- a/frontend/src/app/main/data/users.cljs +++ b/frontend/src/app/main/data/users.cljs @@ -13,6 +13,7 @@ [app.config :as cf] [app.main.data.events :as ev] [app.main.data.media :as di] + [app.main.data.websocket :as ws] [app.main.repo :as rp] [app.util.i18n :as i18n] [app.util.router :as rt] @@ -167,7 +168,8 @@ (when (is-authenticated? profile) (->> (rx/of (profile-fetched profile) (fetch-teams) - (get-redirect-event)) + (get-redirect-event) + (ws/initialize)) (rx/observe-on :async))))))) (s/def ::invitation-token ::us/not-empty-string) @@ -268,10 +270,12 @@ ptk/WatchEvent (watch [_ _ _] - ;; NOTE: We need the `effect` of the current event to be - ;; executed before the redirect. - (->> (rx/of (rt/nav :auth-login)) - (rx/observe-on :async))) + (rx/merge + ;; NOTE: We need the `effect` of the current event to be + ;; executed before the redirect. + (->> (rx/of (rt/nav :auth-login)) + (rx/observe-on :async)) + (rx/of (ws/finalize)))) ptk/EffectEvent (effect [_ _ _] diff --git a/frontend/src/app/main/data/websocket.cljs b/frontend/src/app/main/data/websocket.cljs new file mode 100644 index 000000000..1fbb26770 --- /dev/null +++ b/frontend/src/app/main/data/websocket.cljs @@ -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.main.data.websocket + (:require + [app.common.data.macros :as dm] + [app.common.uri :as u] + [app.config :as cf] + [app.util.websocket :as ws] + [beicon.core :as rx] + [potok.core :as ptk])) + +(dm/export ws/send!) + +(defn- prepare-uri + [params] + (let [base (-> (u/join cf/public-uri "ws/notifications") + (assoc :query (u/map->query-string params)))] + (cond-> base + (= "https" (:scheme base)) + (assoc :scheme "wss") + + (= "http" (:scheme base)) + (assoc :scheme "ws")))) + +(defn send + [message] + (ptk/reify ::send-message + ptk/EffectEvent + (effect [_ state _] + (let [ws-conn (:ws-conn state)] + (ws/send! ws-conn message))))) + +(defn initialize + [] + (ptk/reify ::initialize + ptk/UpdateEvent + (update [_ state] + (let [sid (:session-id state) + uri (prepare-uri {:session-id sid})] + (assoc state :ws-conn (ws/create uri)))) + + ptk/WatchEvent + (watch [_ state stream] + (let [ws-conn (:ws-conn state) + stoper (rx/merge + (rx/filter (ptk/type? ::finalize) stream) + (rx/filter (ptk/type? ::initialize) stream))] + + (->> (rx/merge + (->> (ws/get-rcv-stream ws-conn) + (rx/filter ws/message-event?) + (rx/map :payload) + (rx/map #(ptk/data-event ::message %))) + (->> (ws/get-rcv-stream ws-conn) + (rx/filter ws/opened-event?) + (rx/map (fn [_] (ptk/data-event ::opened {}))))) + (rx/take-until stoper)))))) + +;; --- Finalize Websocket + +(defn finalize + [] + (ptk/reify ::finalize + ptk/EffectEvent + (effect [_ state _] + (some-> (:ws-conn state) ws/close!)))) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 0300d3bf0..8f5f823eb 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -116,15 +116,16 @@ (rx/take 1) (rx/map deref) (rx/mapcat (fn [bundle] - (rx/merge - (rx/of (dwn/initialize file-id) - (dwp/initialize-file-persistence file-id) - (dwc/initialize-indices bundle)) + (let [team-id (-> bundle :project :team-id)] + (rx/merge + (rx/of (dwn/initialize team-id file-id) + (dwp/initialize-file-persistence file-id) + (dwc/initialize-indices bundle)) - (->> stream - (rx/filter #(= ::dwc/index-initialized %)) - (rx/take 1) - (rx/map #(file-initialized bundle))))))))) + (->> stream + (rx/filter #(= ::dwc/index-initialized %)) + (rx/take 1) + (rx/map #(file-initialized bundle)))))))))) ptk/EffectEvent (effect [_ _ _] @@ -982,7 +983,7 @@ pages (get-in state [:workspace-data :pages-index]) file-thumbnails (->> pages - (mapcat #(extract-file-thumbnails-from-page state selected %)))] + (mapcat #(extract-file-thumbnails-from-page state selected %)))] (rx/concat (rx/from (for [ft file-thumbnails] diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index f104e8874..e457b6a3a 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -7,18 +7,15 @@ (ns app.main.data.workspace.notifications (:require [app.common.data :as d] - [app.common.geom.point :as gpt] [app.common.spec :as us] [app.common.spec.change :as spec.change] - [app.common.transit :as t] - [app.common.uri :as u] - [app.config :as cf] + [app.common.uuid :as uuid] + [app.main.data.websocket :as dws] [app.main.data.workspace.changes :as dch] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.persistence :as dwp] [app.main.streams :as ms] [app.util.time :as dt] - [app.util.websockets :as ws] [beicon.core :as rx] [cljs.spec.alpha :as s] [clojure.set :as set] @@ -30,103 +27,91 @@ (declare handle-file-change) (declare handle-library-change) (declare handle-pointer-send) -(declare send-keepalive) - -(s/def ::type keyword?) -(s/def ::message - (s/keys :req-un [::type])) - -(defn prepare-uri - [params] - (let [base (-> (u/join cf/public-uri "ws/notifications") - (assoc :query (u/map->query-string params)))] - (cond-> base - (= "https" (:scheme base)) - (assoc :scheme "wss") - - (= "http" (:scheme base)) - (assoc :scheme "ws")))) +(declare handle-export-update) (defn initialize - [file-id] + [team-id file-id] (ptk/reify ::initialize - ptk/UpdateEvent - (update [_ state] - (let [sid (:session-id state) - uri (prepare-uri {:file-id file-id :session-id sid})] - (assoc-in state [:ws file-id] (ws/open uri)))) - ptk/WatchEvent (watch [_ state stream] - (let [wsession (get-in state [:ws file-id]) - stoper (->> stream - (rx/filter (ptk/type? ::finalize))) - interval (* 1000 60)] - (->> (rx/merge - ;; Each 60 seconds send a keepalive message for maintain - ;; this socket open. - (->> (rx/timer interval interval) - (rx/map #(send-keepalive file-id))) + (let [subs-id (uuid/next) + stoper (rx/filter (ptk/type? ::finalize) stream) - ;; Process all incoming messages. - (->> (ws/-stream wsession) - (rx/filter ws/message?) - (rx/map (comp t/decode-str :payload)) - (rx/filter #(s/valid? ::message %)) - (rx/map process-message)) + initmsg [{:type :subscribe-file + :subs-id subs-id + :file-id file-id} + {:type :subscribe-team + :team-id team-id}] - (rx/of (handle-presence {:type :connect - :session-id (:session-id state) - :profile-id (:profile-id state)})) + endmsg {:type :unsubscribe-file + :subs-id subs-id} - ;; Send back to backend all pointer messages. - (->> stream - (rx/filter ms/pointer-event?) - (rx/sample 50) - (rx/map #(handle-pointer-send file-id (:pt %))))) - (rx/take-until stoper)))))) + stream (->> (rx/merge + ;; Send the subscription message + (->> (rx/from initmsg) + (rx/map dws/send)) + + ;; Subscribe to notifications of the subscription + (->> stream + (rx/filter (ptk/type? ::dws/message)) + (rx/map deref) + (rx/map process-message) + (rx/filter #(= subs-id (:subs-id %)))) + + ;; On reconnect, send again the subscription messages + (->> stream + (rx/filter (ptk/type? ::dws/opened)) + (rx/mapcat #(->> (rx/from initmsg) + (rx/map dws/send)))) + + ;; Emit presence event for current user; + ;; this is because websocket server don't + ;; emits this for the same user. + (rx/of (handle-presence {:type :connect + :session-id (:session-id state) + :profile-id (:profile-id state)})) + + ;; Emit to all other connected users the current pointer + ;; position changes. + (->> stream + (rx/filter ms/pointer-event?) + (rx/sample 50) + (rx/map #(handle-pointer-send subs-id file-id (:pt %))))) + + (rx/take-until stoper))] + + (rx/concat stream (rx/of (dws/send endmsg))))))) (defn- process-message [{:keys [type] :as msg}] (case type - :connect (handle-presence msg) + :join-file (handle-presence msg) + :leave-file (handle-presence msg) :presence (handle-presence msg) :disconnect (handle-presence msg) :pointer-update (handle-pointer-update msg) :file-change (handle-file-change msg) :library-change (handle-library-change msg) - ::unknown)) - -(defn- send-keepalive - [file-id] - (ptk/reify ::send-keepalive - ptk/EffectEvent - (effect [_ state _] - (when-let [ws (get-in state [:ws file-id])] - (ws/send! ws {:type :keepalive}))))) + nil)) (defn- handle-pointer-send - [file-id point] + [subs-id file-id point] (ptk/reify ::handle-pointer-send - ptk/EffectEvent - (effect [_ state _] - (let [ws (get-in state [:ws file-id]) - pid (:current-page-id state) - msg {:type :pointer-update - :page-id pid - :x (:x point) - :y (:y point)}] - (ws/send! ws msg))))) + ptk/WatchEvent + (watch [_ state _] + (let [page-id (:current-page-id state) + message {:type :pointer-update + :subs-id subs-id + :file-id file-id + :page-id page-id + :position point}] + (rx/of (dws/send message)))))) ;; --- Finalize Websocket (defn finalize - [file-id] - (ptk/reify ::finalize - ptk/EffectEvent - (effect [_ state _] - (when-let [ws (get-in state [:ws file-id])] - (ws/-close ws))))) + [_] + (ptk/reify ::finalize)) ;; --- Handle: Presence @@ -165,7 +150,8 @@ (assoc :profile-id profile-id) (assoc :updated-at (dt/now)) (update :color update-color presence) - (assoc :text-color (if (contains? ["#00fa9a" "#ffd700" "#dda0dd" "#ffafda"] (update-color (:color presence) presence)) + (assoc :text-color (if (contains? ["#00fa9a" "#ffd700" "#dda0dd" "#ffafda"] + (update-color (:color presence) presence)) "#000" "#fff")))) @@ -179,20 +165,19 @@ (ptk/reify ::handle-presence ptk/UpdateEvent (update [_ state] - ;; (let [profiles (:users state)] - (if (= :disconnect type) + (if (or (= :disconnect type) (= :leave-file type)) (update state :workspace-presence dissoc session-id) (update state :workspace-presence update-presence)))))) (defn handle-pointer-update - [{:keys [page-id session-id x y] :as msg}] + [{:keys [page-id session-id position] :as msg}] (ptk/reify ::handle-pointer-update ptk/UpdateEvent (update [_ state] (update-in state [:workspace-presence session-id] (fn [session] (assoc session - :point (gpt/point x y) + :point position :updated-at (dt/now) :page-id page-id)))))) @@ -241,4 +226,3 @@ (when (contains? (:workspace-libraries state) file-id) (rx/of (dwl/ext-library-changed file-id modified-at revn changes) (dwl/notify-sync-file file-id)))))) - diff --git a/frontend/src/app/main/data/workspace/persistence.cljs b/frontend/src/app/main/data/workspace/persistence.cljs index 82f856c0a..9a6793b86 100644 --- a/frontend/src/app/main/data/workspace/persistence.cljs +++ b/frontend/src/app/main/data/workspace/persistence.cljs @@ -30,7 +30,6 @@ [app.main.store :as st] [app.util.http :as http] [app.util.i18n :as i18n :refer [tr]] - [app.util.object :as obj] [app.util.time :as dt] [app.util.uri :as uu] [beicon.core :as rx] @@ -71,7 +70,7 @@ on-dirty (fn [] ;; Enable reload stoper - (obj/set! js/window "onbeforeunload" (constantly false)) + (swap! st/ongoing-tasks conj :workspace-change) (st/emit! (update-persistence-status {:status :pending}))) on-saving @@ -81,7 +80,7 @@ on-saved (fn [] ;; Disable reload stoper - (obj/set! js/window "onbeforeunload" nil) + (swap! st/ongoing-tasks disj :workspace-change) (st/emit! (update-persistence-status {:status :saved})))] (->> (rx/merge (->> stream diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index b1b71a820..e02bdce16 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -7,6 +7,7 @@ (ns app.main.data.workspace.shortcuts (:require [app.main.data.events :as ev] + [app.main.data.exports :as de] [app.main.data.shortcuts :as ds] [app.main.data.workspace :as dw] [app.main.data.workspace.colors :as mdc] @@ -61,9 +62,14 @@ :command (ds/c-mod "shift+r") :fn #(st/emit! (toggle-layout-flag :rules))} - :select-all {:tooltip (ds/meta "A") - :command (ds/c-mod "a") - :fn #(st/emit! (dw/select-all))} + :export-shapes {:tooltip (ds/meta-shift "E") + :command (ds/c-mod "shift+e") + :fn #(st/emit! + (de/show-workspace-export-dialog))} + + :select-all {:tooltip (ds/meta "A") + :command (ds/c-mod "a") + :fn #(st/emit! (dw/select-all))} :toggle-grid {:tooltip (ds/meta "'") :command (ds/c-mod "'") @@ -329,7 +335,7 @@ :align-vcenter {:tooltip (ds/alt "V") :command "alt+v" :fn #(st/emit! (dw/align-objects :vcenter))} - + :align-bottom {:tooltip (ds/alt "S") :command "alt+s" :fn #(st/emit! (dw/align-objects :vbottom))} @@ -365,7 +371,7 @@ :toggle-focus-mode {:command "f" :tooltip "F" :fn #(st/emit! (dw/toggle-focus-mode))} - + :thumbnail-set {:tooltip (ds/shift "T") :command "shift+t" :fn #(st/emit! (dw/toggle-file-thumbnail-selected))} diff --git a/frontend/src/app/main/data/workspace/state_helpers.cljs b/frontend/src/app/main/data/workspace/state_helpers.cljs index bade10256..049c8c704 100644 --- a/frontend/src/app/main/data/workspace/state_helpers.cljs +++ b/frontend/src/app/main/data/workspace/state_helpers.cljs @@ -7,6 +7,7 @@ (ns app.main.data.workspace.state-helpers (:require [app.common.data :as d] + [app.common.data.macros :as dm] [app.common.pages.helpers :as cph])) (defn lookup-page @@ -35,14 +36,16 @@ ([state] (get-in state [:workspace-data :components]))) +;; TODO: improve performance of this + (defn lookup-selected ([state] (lookup-selected state nil)) - - ([state {:keys [omit-blocked?] - :or {omit-blocked? false}}] - (let [objects (lookup-page-objects state) - selected (->> (get-in state [:workspace-local :selected]) + ([state options] + (lookup-selected state (:current-page-id state) options)) + ([state page-id {:keys [omit-blocked?] :or {omit-blocked? false}}] + (let [objects (lookup-page-objects state page-id) + selected (->> (dm/get-in state [:workspace-local :selected]) (cph/clean-loops objects)) selectable? (fn [id] (and (contains? objects id) @@ -51,3 +54,17 @@ (into (d/ordered-set) (filter selectable?) selected)))) + +(defn lookup-shapes + ([state ids] + (lookup-shapes state (:current-page-id state) ids)) + ([state page-id ids] + (let [objects (lookup-page-objects state page-id)] + (into [] (keep (d/getf objects)) ids)))) + +(defn filter-shapes + ([state filter-fn] + (filter-shapes state (:current-page-id state) filter-fn)) + ([state page-id filter-fn] + (let [objects (lookup-page-objects state page-id)] + (into [] (filter filter-fn) (vals objects))))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index d6587b47c..12daf2712 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -12,7 +12,6 @@ [app.config :as cf] [app.main.data.messages :as msg] [app.main.data.users :as du] - [app.main.sentry :as sentry] [app.main.store :as st] [app.util.i18n :refer [tr]] [app.util.router :as rt] @@ -26,7 +25,7 @@ [error] (cond (instance? ExceptionInfo error) - (-> error sentry/capture-exception ex-data ptk/handle-error) + (-> error ex-data ptk/handle-error) (map? error) (ptk/handle-error error) @@ -34,7 +33,6 @@ :else (let [hint (ex-message error) msg (dm/str "Internal Error: " hint)] - (sentry/capture-exception error) (ts/schedule (st/emitf (rt/assign-exception error))) (js/console.group msg) @@ -68,7 +66,8 @@ ;; Error that happens on an active business model validation does not ;; passes an validation (example: profile can't leave a team). From ;; the user perspective a error flash message should be visualized but -;; user can continue operate on the application. +;; user can continue operate on the application. Can happen in backend +;; and frontend. (defmethod ptk/handle-error :validation [error] (ts/schedule @@ -92,6 +91,7 @@ ;; Error on parsing an SVG +;; TODO: looks unused and deprecated (defmethod ptk/handle-error :svg-parser [_] (ts/schedule @@ -100,6 +100,7 @@ :type :error :timeout 3000})))) +;; TODO: should be handled in the event and not as general error handler (defmethod ptk/handle-error :comment-error [_] (ts/schedule @@ -160,10 +161,9 @@ (defn on-unhandled-error [error] (if (instance? ExceptionInfo error) - (-> error sentry/capture-exception ex-data ptk/handle-error) + (-> error ex-data ptk/handle-error) (let [hint (ex-message error) msg (dm/str "Unhandled Internal Error: " hint)] - (sentry/capture-exception error) (ts/schedule (st/emitf (rt/assign-exception error))) (js/console.group msg) (ex/ignoring (js/console.error error)) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 1528dd1cc..14a4a9259 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -42,6 +42,9 @@ (def share-links (l/derived :share-links st/state)) +(def export + (l/derived :export st/state)) + ;; ---- Dashboard refs (def dashboard-local @@ -98,6 +101,7 @@ (def workspace-drawing (l/derived :workspace-drawing st/state)) +;; TODO: rename to workspace-selected (?) (def selected-shapes (l/derived wsh/lookup-selected st/state =)) @@ -105,6 +109,27 @@ [id] (l/derived #(contains? % id) selected-shapes)) +(def export-in-progress? + (l/derived :export-in-progress? export)) + +(def export-error? + (l/derived :export-error? export)) + +(def export-progress + (l/derived :export-progress export)) + +(def exports + (l/derived :exports export)) + +(def export-detail-visibililty + (l/derived :export-detail-visibililty export)) + +(def export-widget-visibililty + (l/derived :export-widget-visibililty export)) + +(def export-health + (l/derived :export-health export)) + (def selected-zoom (l/derived :zoom workspace-local)) @@ -233,11 +258,7 @@ (defn objects-by-id [ids] - (let [selector - (fn [state] - (let [objects (wsh/lookup-page-objects state)] - (into [] (keep (d/getf objects)) ids)))] - (l/derived selector st/state =))) + (l/derived #(wsh/lookup-shapes % ids) st/state =)) (defn- set-content-modifiers [state] (fn [id shape] diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index 9fa42387c..8574e1905 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -8,7 +8,7 @@ (:require [app.common.data :as d] [app.common.uri :as u] - [app.config :as cfg] + [app.config :as cf] [app.util.http :as http] [beicon.core :as rx])) @@ -40,7 +40,7 @@ :status status :data body}))) -(def ^:private base-uri cfg/public-uri) +(def ^:private base-uri cf/public-uri) (defn- send-query! "A simple helper for send and receive transit data on the penpot @@ -105,23 +105,44 @@ (rx/map http/conditional-decode-transit) (rx/mapcat handle-response))) -(defmethod query :export - [_ params] +(defn- send-export-command + [& {:keys [cmd params blob?]}] (->> (http/send! {:method :post :uri (u/join base-uri "export") - :body (http/transit-data params) + :body (http/transit-data (assoc params :cmd cmd)) :credentials "include" - :response-type :blob}) + :response-type (if blob? :blob :text)}) + (rx/map http/conditional-decode-transit) (rx/mapcat handle-response))) -(defmethod query :export-frames +(defmethod query :export-shapes-simple [_ params] - (->> (http/send! {:method :post - :uri (u/join base-uri "export-frames") - :body (http/transit-data params) - :credentials "include" - :response-type :blob}) - (rx/mapcat handle-response))) + (let [params (merge {:wait true} params)] + (->> (rx/of params) + (rx/mapcat #(send-export-command :cmd :export-shapes :params % :blob? false)) + (rx/mapcat #(send-export-command :cmd :get-resource :params % :blob? true))))) + +(defmethod query :export-shapes-multiple + [_ params] + (send-export-command :cmd :export-shapes :params params :blob? false)) + +(defmethod query :download-export-resource + [_ id] + (send-export-command :cmd :get-resource :params {:id id} :blob? true)) + +(defmethod query :export-frames + [_ exports] + (let [params {:uri (str base-uri) + :cmd :export-frames + :wait false + :exports exports}] + (->> (http/send! {:method :post + :uri (u/join base-uri "export") + :body (http/transit-data params) + :credentials "include" + :response-type :blob}) + (rx/mapcat handle-response) + (rx/ignore)))) (derive :upload-file-media-object ::multipart-upload) (derive :update-profile-photo ::multipart-upload) diff --git a/frontend/src/app/main/store.cljs b/frontend/src/app/main/store.cljs index 35936207d..01beedb9e 100644 --- a/frontend/src/app/main/store.cljs +++ b/frontend/src/app/main/store.cljs @@ -7,6 +7,7 @@ (ns app.main.store (:require-macros [app.main.store]) (:require + [app.util.object :as obj] [beicon.core :as rx] [okulary.core :as l] [potok.core :as ptk])) @@ -59,4 +60,10 @@ [& events] #(apply ptk/emit! state events)) +(defonce ongoing-tasks (l/atom #{})) +(add-watch ongoing-tasks ::ongoing-tasks + (fn [_ _ _ events] + (if (empty? events) + (obj/set! js/window "onbeforeunload" nil) + (obj/set! js/window "onbeforeunload" (constantly false))))) diff --git a/frontend/src/app/main/ui/export.cljs b/frontend/src/app/main/ui/export.cljs new file mode 100644 index 000000000..14f284554 --- /dev/null +++ b/frontend/src/app/main/ui/export.cljs @@ -0,0 +1,211 @@ +;; 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.export + "Assets exportation common components." + (:require + [app.common.colors :as clr] + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.main.data.exports :as de] + [app.main.data.modal :as modal] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.icons :as i] + [app.main.ui.workspace.shapes :refer [shape-wrapper]] + [app.util.dom :as dom] + [app.util.i18n :as i18n :refer [tr c]] + [cuerdas.core :as str] + [rumext.alpha :as mf])) + +(mf/defc export-shapes-dialog + {::mf/register modal/components + ::mf/register-as :export-shapes} + [{:keys [exports filename]}] + (let [lstate (mf/deref refs/export) + in-progress? (:in-progress lstate) + + exports (mf/use-state exports) + + all-exports (deref exports) + all-checked? (every? :enabled all-exports) + all-unchecked? (every? (complement :enabled) all-exports) + + enabled-exports (into [] (filter :enabled) all-exports) + + cancel-fn + (fn [event] + (dom/prevent-default event) + (st/emit! (modal/hide))) + + accept-fn + (fn [event] + (dom/prevent-default event) + (st/emit! (modal/hide) + (de/request-multiple-export {:filename filename :exports enabled-exports}))) + on-toggle-enabled + (fn [index] + (swap! exports update-in [index :enabled] not)) + + change-all + (fn [_] + (swap! exports (fn [exports] + (mapv #(assoc % :enabled (not all-checked?)) exports)))) + ] + [:div.modal-overlay + [:div.modal-container.export-shapes-dialog + {:class (when (empty? all-exports) "no-shapes")} + + [:div.modal-header + [:div.modal-header-title + [:h2 (tr "dashboard.export-shapes.title")]] + + [:div.modal-close-button + {:on-click cancel-fn} i/close]] + + [:* + [:div.modal-content + (if (> (count all-exports) 0) + [:* + [:div.header + [:div.field.check {:on-click change-all} + (cond + all-checked? [:span i/checkbox-checked] + all-unchecked? [:span i/checkbox-unchecked] + :else [:span i/checkbox-intermediate])] + [:div.field.title (tr "dashboard.export-shapes.selected" + (c (count enabled-exports)) + (c (count all-exports)))]] + + [:div.body + (for [[index {:keys [shape suffix] :as export}] (d/enumerate @exports)] + (let [{:keys [x y width height]} (:selrect shape)] + [:div.row + [:div.field.check {:on-click #(on-toggle-enabled index)} + (if (:enabled export) + [:span.checked i/checkbox-checked] + [:span.unchecked i/checkbox-unchecked])] + + [:div.field.image + [:svg {:view-box (dm/str x " " y " " width " " height) + :width 24 + :height 20 + :version "1.1" + :xmlns "http://www.w3.org/2000/svg" + :xmlnsXlink "http://www.w3.org/1999/xlink" + ;; Fix Chromium bug about color of html texts + ;; https://bugs.chromium.org/p/chromium/issues/detail?id=1244560#c5 + :style {:-webkit-print-color-adjust :exact}} + + [:& shape-wrapper {:shape shape}]]] + + [:div.field.name (cond-> (:name shape) suffix (str suffix))] + [:div.field.scale (dm/str (* width (:scale export)) "x" + (* height (:scale export)) "px ")] + [:div.field.extension (-> export :type d/name str/upper)]]))] + + [:div.modal-footer + [:div.action-buttons + [:input.cancel-button + {:type "button" + :value (tr "labels.cancel") + :on-click cancel-fn}] + + [:input.accept-button.primary + {:class (dom/classnames + :btn-disabled (or in-progress? all-unchecked?)) + :disabled (or in-progress? all-unchecked?) + :type "button" + :value (if in-progress? + (tr "workspace.options.exporting-object") + (tr "labels.export")) + :on-click (when-not in-progress? accept-fn)}]]]] + + [:div.no-selection + [:img {:src "images/export-no-shapes.png" :border "0"}] + [:p (tr "dashboard.export-shapes.no-elements")] + [:p (tr "dashboard.export-shapes.how-to")] + [:p [:a {:target "_blank" + :href "https://help.penpot.app/user-guide/exporting/ "} + (tr "dashboard.export-shapes.how-to-link")]]])]]]])) + +(mf/defc export-progress-widget + {::mf/wrap [mf/memo]} + [] + (let [state (mf/deref refs/export) + error? (:error state) + healthy? (:healthy? state) + detail-visible? (:detail-visible state) + widget-visible? (:widget-visible state) + progress (:progress state) + exports (:exports state) + total (count exports) + circ (* 2 Math/PI 12) + pct (- circ (* circ (/ progress total))) + + pwidth (if error? + 280 + (/ (* progress 280) total)) + color (cond + error? clr/danger + healthy? clr/primary + (not healthy?) clr/warning) + title (cond + error? (tr "workspace.options.exporting-object-error") + healthy? (tr "workspace.options.exporting-object") + (not healthy?) (tr "workspace.options.exporting-object-slow")) + + retry-last-export + (mf/use-fn #(st/emit! (de/retry-last-export))) + + toggle-detail-visibility + (mf/use-fn #(st/emit! (de/toggle-detail-visibililty)))] + + [:* + (when widget-visible? + [:div.export-progress-widget {:on-click toggle-detail-visibility} + [:svg {:width "32" :height "32"} + [:circle {:r "12" + :cx "16" + :cy "16" + :fill "transparent" + :stroke clr/gray-40 + :stroke-width "4"}] + [:circle {:r "12" + :cx "16" + :cy "16" + :fill "transparent" + :stroke color + :stroke-width "4" + :stroke-dasharray (dm/str circ " " circ) + :stroke-dashoffset pct + :transform "rotate(-90 16,16)" + :style {:transition "stroke-dashoffset 1s ease-in-out"}}]]]) + + (when detail-visible? + [:div.export-progress-modal-overlay + [:div.export-progress-modal-container + [:div.export-progress-modal-header + [:p.export-progress-modal-title title] + (if error? + [:button.btn-secondary.retry {:on-click retry-last-export} (tr "workspace.options.retry")] + [:p.progress (dm/str progress " / " total)]) + + [:button.modal-close-button {:on-click toggle-detail-visibility} i/close]] + + [:svg.progress-bar {:height 8 :width 280} + [:g + [:path {:d "M0 0 L280 0" + :stroke clr/gray-10 + :stroke-width 30}] + [:path {:d (dm/str "M0 0 L280 0") + :stroke color + :stroke-width 30 + :fill "transparent" + :stroke-dasharray 280 + :stroke-dashoffset (- 280 pwidth) + :style {:transition "stroke-dashoffset 1s ease-in-out"}}]]]]])])) + diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 944f46aae..c10126c61 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -39,6 +39,7 @@ (def chat (icon-xref :chat)) (def checkbox-checked (icon-xref :checkbox-checked)) (def checkbox-unchecked (icon-xref :checkbox-unchecked)) +(def checkbox-intermediate (icon-xref :checkbox-intermediate)) (def circle (icon-xref :circle)) (def close (icon-xref :close)) (def code (icon-xref :code)) diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes.cljs b/frontend/src/app/main/ui/viewer/handoff/attributes.cljs index 824876efd..52518b738 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes.cljs +++ b/frontend/src/app/main/ui/viewer/handoff/attributes.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.viewer.handoff.attributes (:require [app.common.geom.shapes :as gsh] + [app.main.ui.hooks :as hooks] [app.main.ui.viewer.handoff.attributes.blur :refer [blur-panel]] [app.main.ui.viewer.handoff.attributes.fill :refer [fill-panel]] [app.main.ui.viewer.handoff.attributes.image :refer [image-panel]] @@ -16,7 +17,6 @@ [app.main.ui.viewer.handoff.attributes.svg :refer [svg-panel]] [app.main.ui.viewer.handoff.attributes.text :refer [text-panel]] [app.main.ui.viewer.handoff.exports :refer [exports]] - [app.util.i18n :as i18n] [rumext.alpha :as mf])) (def type->options @@ -31,8 +31,9 @@ (mf/defc attributes [{:keys [page-id file-id shapes frame]}] - (let [locale (mf/deref i18n/locale) - shapes (->> shapes (map #(gsh/translate-to-frame % frame))) + (let [shapes (hooks/use-equal-memo shapes) + shapes (mf/with-memo [shapes] + (mapv #(gsh/translate-to-frame % frame) shapes)) type (if (= (count shapes) 1) (-> shapes first :type) :multiple) options (type->options type)] [:div.element-options @@ -47,10 +48,9 @@ :text text-panel :svg svg-panel) {:shapes shapes - :frame frame - :locale locale}]) - (when-not (= :multiple type) - [:& exports - {:shape (first shapes) - :page-id page-id - :file-id file-id}])])) + :frame frame}]) + [:& exports + {:shapes shapes + :type type + :page-id page-id + :file-id file-id}]])) diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes/blur.cljs b/frontend/src/app/main/ui/viewer/handoff/attributes/blur.cljs index fc9f382ea..849190bae 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes/blur.cljs +++ b/frontend/src/app/main/ui/viewer/handoff/attributes/blur.cljs @@ -8,7 +8,7 @@ (:require [app.main.ui.components.copy-button :refer [copy-button]] [app.util.code-gen :as cg] - [app.util.i18n :refer [t]] + [app.util.i18n :refer [tr]] [cuerdas.core :as str] [rumext.alpha :as mf])) @@ -22,17 +22,17 @@ {:to-prop "filter" :format #(str/fmt "blur(%spx)" (:value %))})) -(mf/defc blur-panel [{:keys [shapes locale]}] +(mf/defc blur-panel [{:keys [shapes]}] (let [shapes (->> shapes (filter has-blur?))] (when (seq shapes) [:div.attributes-block [:div.attributes-block-title - [:div.attributes-block-title-text (t locale "handoff.attributes.blur")] + [:div.attributes-block-title-text (tr "handoff.attributes.blur")] (when (= (count shapes) 1) [:& copy-button {:data (copy-data (first shapes))}])] (for [shape shapes] [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.blur.value")] + [:div.attributes-label (tr "handoff.attributes.blur.value")] [:div.attributes-value (-> shape :blur :value) "px"] [:& copy-button {:data (copy-data shape)}]])]))) diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes/common.cljs b/frontend/src/app/main/ui/viewer/handoff/attributes/common.cljs index 624ae2ab4..adda6a818 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes/common.cljs +++ b/frontend/src/app/main/ui/viewer/handoff/attributes/common.cljs @@ -11,7 +11,7 @@ [app.main.ui.components.copy-button :refer [copy-button]] [app.util.color :as uc] [app.util.dom :as dom] - [app.util.i18n :refer [t] :as i18n] + [app.util.i18n :refer [tr]] [cuerdas.core :as str] [okulary.core :as l] [rumext.alpha :as mf])) @@ -27,9 +27,7 @@ #(l/derived get-library st/state))) (mf/defc color-row [{:keys [color format copy-data on-change-format]}] - (let [locale (mf/deref i18n/locale) - - colors-library-ref (mf/use-memo + (let [colors-library-ref (mf/use-memo (mf/deps (:file-id color)) (make-colors-library-ref (:file-id color))) colors-library (mf/deref colors-library-ref) @@ -60,13 +58,13 @@ (when-not (and on-change-format (:gradient color)) [:select {:on-change #(-> (dom/get-target-val %) keyword on-change-format)} [:option {:value "hex"} - (t locale "handoff.attributes.color.hex")] + (tr "handoff.attributes.color.hex")] [:option {:value "rgba"} - (t locale "handoff.attributes.color.rgba")] + (tr "handoff.attributes.color.rgba")] [:option {:value "hsla"} - (t locale "handoff.attributes.color.hsla")]])] + (tr "handoff.attributes.color.hsla")]])] (when copy-data [:& copy-button {:data copy-data}])])) diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes/layout.cljs b/frontend/src/app/main/ui/viewer/handoff/attributes/layout.cljs index ccbd9abc9..fe7c7faa6 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes/layout.cljs +++ b/frontend/src/app/main/ui/viewer/handoff/attributes/layout.cljs @@ -9,7 +9,7 @@ [app.common.spec.radius :as ctr] [app.main.ui.components.copy-button :refer [copy-button]] [app.util.code-gen :as cg] - [app.util.i18n :refer [t]] + [app.util.i18n :refer [tr]] [cuerdas.core :as str] [rumext.alpha :as mf])) @@ -32,41 +32,41 @@ (cg/generate-css-props shape properties params))) (mf/defc layout-block - [{:keys [shape locale]}] + [{:keys [shape]}] (let [selrect (:selrect shape) {:keys [width height x y]} selrect] [:* [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.layout.width")] + [:div.attributes-label (tr "handoff.attributes.layout.width")] [:div.attributes-value width "px"] [:& copy-button {:data (copy-data selrect :width)}]] [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.layout.height")] + [:div.attributes-label (tr "handoff.attributes.layout.height")] [:div.attributes-value height "px"] [:& copy-button {:data (copy-data selrect :height)}]] (when (not= (:x shape) 0) [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.layout.left")] + [:div.attributes-label (tr "handoff.attributes.layout.left")] [:div.attributes-value x "px"] [:& copy-button {:data (copy-data selrect :x)}]]) (when (not= (:y shape) 0) [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.layout.top")] + [:div.attributes-label (tr "handoff.attributes.layout.top")] [:div.attributes-value y "px"] [:& copy-button {:data (copy-data selrect :y)}]]) (when (ctr/radius-1? shape) [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.layout.radius")] + [:div.attributes-label (tr "handoff.attributes.layout.radius")] [:div.attributes-value (:rx shape 0) "px"] [:& copy-button {:data (copy-data shape :rx)}]]) (when (ctr/radius-4? shape) [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.layout.radius")] + [:div.attributes-label (tr "handoff.attributes.layout.radius")] [:div.attributes-value (:r1 shape) ", " (:r2 shape) ", " @@ -76,19 +76,18 @@ (when (not= (:rotation shape 0) 0) [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.layout.rotation")] + [:div.attributes-label (tr "handoff.attributes.layout.rotation")] [:div.attributes-value (:rotation shape) "deg"] [:& copy-button {:data (copy-data shape :rotation)}]])])) (mf/defc layout-panel - [{:keys [shapes locale]}] + [{:keys [shapes]}] [:div.attributes-block [:div.attributes-block-title - [:div.attributes-block-title-text (t locale "handoff.attributes.layout")] + [:div.attributes-block-title-text (tr "handoff.attributes.layout")] (when (= (count shapes) 1) [:& copy-button {:data (copy-data (first shapes))}])] (for [shape shapes] - [:& layout-block {:shape shape - :locale locale}])]) + [:& layout-block {:shape shape}])]) diff --git a/frontend/src/app/main/ui/viewer/handoff/attributes/stroke.cljs b/frontend/src/app/main/ui/viewer/handoff/attributes/stroke.cljs index 1564d73c5..a5fea5283 100644 --- a/frontend/src/app/main/ui/viewer/handoff/attributes/stroke.cljs +++ b/frontend/src/app/main/ui/viewer/handoff/attributes/stroke.cljs @@ -11,7 +11,7 @@ [app.main.ui.viewer.handoff.attributes.common :refer [color-row]] [app.util.code-gen :as cg] [app.util.color :as uc] - [app.util.i18n :refer [t]] + [app.util.i18n :refer [tr]] [cuerdas.core :as str] [rumext.alpha :as mf])) @@ -52,7 +52,7 @@ :format #(uc/color->background (shape->color shape))})) (mf/defc stroke-block - [{:keys [shape locale]}] + [{:keys [shape]}] (let [color-format (mf/use-state :hex) color (shape->color shape)] [:* @@ -65,19 +65,19 @@ stroke-style (if (= stroke-style :svg) :solid stroke-style) stroke-alignment (or stroke-alignment :center)] [:div.attributes-stroke-row - [:div.attributes-label (t locale "handoff.attributes.stroke.width")] + [:div.attributes-label (tr "handoff.attributes.stroke.width")] [:div.attributes-value (:stroke-width shape) "px"] - [:div.attributes-value (->> stroke-style d/name (str "handoff.attributes.stroke.style.") (t locale))] - [:div.attributes-label (->> stroke-alignment d/name (str "handoff.attributes.stroke.alignment.") (t locale))] + [:div.attributes-value (->> stroke-style d/name (str "handoff.attributes.stroke.style.") (tr))] + [:div.attributes-label (->> stroke-alignment d/name (str "handoff.attributes.stroke.alignment.") (tr))] [:& copy-button {:data (copy-stroke-data shape)}]])])) (mf/defc stroke-panel - [{:keys [shapes locale]}] + [{:keys [shapes]}] (let [shapes (->> shapes (filter has-stroke?))] (when (seq shapes) [:div.attributes-block [:div.attributes-block-title - [:div.attributes-block-title-text (t locale "handoff.attributes.stroke")] + [:div.attributes-block-title-text (tr "handoff.attributes.stroke")] (when (= (count shapes) 1) [:& copy-button {:data (copy-stroke-data (first shapes))}])] @@ -85,8 +85,6 @@ (if (seq (:strokes shape)) (for [value (:strokes shape [])] [:& stroke-block {:key (str "stroke-color-" (:id shape)) - :shape value - :locale locale}]) + :shape value}]) [:& stroke-block {:key (str "stroke-color-" (:id shape)) - :shape shape - :locale locale}]))]))) + :shape shape}]))]))) diff --git a/frontend/src/app/main/ui/viewer/handoff/exports.cljs b/frontend/src/app/main/ui/viewer/handoff/exports.cljs index 930281d30..aec4715f4 100644 --- a/frontend/src/app/main/ui/viewer/handoff/exports.cljs +++ b/frontend/src/app/main/ui/viewer/handoff/exports.cljs @@ -7,21 +7,56 @@ (ns app.main.ui.viewer.handoff.exports (:require [app.common.data :as d] + [app.main.data.exports :as de] + [app.main.refs :as refs] + [app.main.store :as st] [app.main.ui.icons :as i] - [app.main.ui.workspace.sidebar.options.menus.exports :as we] [app.util.dom :as dom] - [app.util.i18n :refer [tr]] + [app.util.i18n :refer [tr c]] [rumext.alpha :as mf])) (mf/defc exports - [{:keys [shape page-id file-id] :as props}] - (let [exports (mf/use-state (:exports shape [])) + {::mf/wrap [#(mf/memo % =)]} + [{:keys [shapes page-id file-id type] :as props}] + (let [exports (mf/use-state []) + xstate (mf/deref refs/export) + vstate (mf/deref refs/viewer-data) + page (get-in vstate [:pages page-id]) + filename (if (= (count shapes) 1) + (let [sname (-> shapes first :name) + suffix (-> @exports first :suffix)] + (cond-> sname + (and (= 1 (count @exports)) (some? suffix)) + (str suffix))) + (:name page)) - [on-download loading?] (we/use-download-export shape page-id file-id @exports) + in-progress? (:in-progress xstate) + + on-download + (fn [event] + (dom/prevent-default event) + (if (= :multiple type) + (st/emit! (de/show-viewer-export-dialog {:shapes shapes + :exports @exports + :filename filename + :page-id page-id + :file-id file-id})) + + ;; In other all cases we only allowed to have a single + ;; shape-id because multiple shape-ids are handled + ;; separatelly by the export-modal. + (let [defaults {:page-id page-id + :file-id file-id + :name filename + :object-id (-> shapes first :id)} + exports (mapv #(merge % defaults) @exports)] + (if (= 1 (count exports)) + (st/emit! (de/request-simple-export {:export (first exports)})) + (st/emit! (de/request-multiple-export {:exports exports :filename filename})))))) add-export (mf/use-callback - (mf/deps shape) + (mf/deps shapes) (fn [] (let [xspec {:type :png :suffix "" @@ -30,7 +65,7 @@ delete-export (mf/use-callback - (mf/deps shape) + (mf/deps shapes) (fn [index] (swap! exports (fn [exports] (let [[before after] (split-at index exports)] @@ -38,7 +73,7 @@ on-scale-change (mf/use-callback - (mf/deps shape) + (mf/deps shapes) (fn [index event] (let [target (dom/get-target event) value (dom/get-value target) @@ -47,7 +82,7 @@ on-suffix-change (mf/use-callback - (mf/deps shape) + (mf/deps shapes) (fn [index event] (let [target (dom/get-target event) value (dom/get-value target)] @@ -55,7 +90,7 @@ on-type-change (mf/use-callback - (mf/deps shape) + (mf/deps shapes) (fn [index event] (let [target (dom/get-target event) value (dom/get-value target) @@ -63,9 +98,12 @@ (swap! exports assoc-in [index :type] value))))] (mf/use-effect - (mf/deps shape) + (mf/deps shapes) (fn [] - (reset! exports (:exports shape [])))) + (reset! exports (-> (mapv #(:exports % []) shapes) + flatten + distinct + vec)))) [:div.element-set.exports-options [:div.element-set-title @@ -99,10 +137,10 @@ i/minus]]) [:div.btn-icon-dark.download-button - {:on-click (when-not loading? on-download) - :class (dom/classnames :btn-disabled loading?) - :disabled loading?} - (if loading? + {:on-click (when-not in-progress? on-download) + :class (dom/classnames :btn-disabled in-progress?) + :disabled in-progress?} + (if in-progress? (tr "workspace.options.exporting-object") - (tr "workspace.options.export-object"))]])])) + (tr "workspace.options.export-object" (c (count shapes))))]])])) diff --git a/frontend/src/app/main/ui/viewer/handoff/right_sidebar.cljs b/frontend/src/app/main/ui/viewer/handoff/right_sidebar.cljs index ac9d30d3f..55ae526da 100644 --- a/frontend/src/app/main/ui/viewer/handoff/right_sidebar.cljs +++ b/frontend/src/app/main/ui/viewer/handoff/right_sidebar.cljs @@ -20,8 +20,8 @@ [{:keys [frame page file selected]}] (let [expanded (mf/use-state false) section (mf/use-state :info #_:code) - shapes (resolve-shapes (:objects page) selected) + first-shape (first shapes) selected-type (or (:type first-shape) :not-found) diff --git a/frontend/src/app/main/ui/viewer/handoff/selection_feedback.cljs b/frontend/src/app/main/ui/viewer/handoff/selection_feedback.cljs index a93fbbdea..4445eee30 100644 --- a/frontend/src/app/main/ui/viewer/handoff/selection_feedback.cljs +++ b/frontend/src/app/main/ui/viewer/handoff/selection_feedback.cljs @@ -6,6 +6,7 @@ (ns app.main.ui.viewer.handoff.selection-feedback (:require + [app.common.data :as d] [app.common.geom.shapes :as gsh] [app.main.ui.measurements :refer [selection-guides size-display measurement]] [rumext.alpha :as mf])) @@ -21,10 +22,8 @@ (defn resolve-shapes [objects ids] - (let [resolve-shape #(get objects %)] - (into [] (comp (map resolve-shape) - (filter some?)) - ids))) + (let [resolve-shape (d/getf objects)] + (into [] (keep resolve-shape) ids))) ;; ------------------------------------------------ ;; HELPERS diff --git a/frontend/src/app/main/ui/viewer/header.cljs b/frontend/src/app/main/ui/viewer/header.cljs index b32adfbc1..257b4f8d8 100644 --- a/frontend/src/app/main/ui/viewer/header.cljs +++ b/frontend/src/app/main/ui/viewer/header.cljs @@ -12,12 +12,13 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.export :refer [export-progress-widget]] [app.main.ui.formats :as fmt] [app.main.ui.icons :as i] [app.main.ui.viewer.comments :refer [comments-menu]] [app.main.ui.viewer.interactions :refer [flows-menu interactions-menu]] [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] + [app.util.i18n :refer [tr]] [rumext.alpha :as mf])) (mf/defc zoom-widget @@ -88,6 +89,7 @@ [:div.view-options]) + [:& export-progress-widget] [:& zoom-widget {:zoom zoom :on-increase (st/emitf dv/increase-zoom) diff --git a/frontend/src/app/main/ui/workspace/header.cljs b/frontend/src/app/main/ui/workspace/header.cljs index b7097bd39..221f1f2c0 100644 --- a/frontend/src/app/main/ui/workspace/header.cljs +++ b/frontend/src/app/main/ui/workspace/header.cljs @@ -7,9 +7,11 @@ (ns app.main.ui.workspace.header (:require [app.common.data :as d] + [app.common.data.macros :as dm] [app.config :as cf] [app.main.data.events :as ev] - [app.main.data.messages :as dm] + [app.main.data.exports :as de] + [app.main.data.messages :as msg] [app.main.data.modal :as modal] [app.main.data.workspace :as dw] [app.main.data.workspace.shortcuts :as sc] @@ -17,6 +19,7 @@ [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.export :refer [export-progress-widget]] [app.main.ui.formats :as fmt] [app.main.ui.hooks.resize :as r] [app.main.ui.icons :as i] @@ -30,11 +33,12 @@ [potok.core :as ptk] [rumext.alpha :as mf])) -;; --- Zoom Widget (def workspace-persistence-ref (l/derived :workspace-persistence st/state)) +;; --- Persistence state Widget + (mf/defc persistence-state-widget {::mf/wrap [mf/memo]} [] @@ -60,6 +64,8 @@ [:span.icon i/msg-warning] [:span.label (tr "workspace.header.save-error")]])])) +;; --- Zoom Widget + (mf/defc zoom-widget-workspace {::mf/wrap [mf/memo]} [{:keys [zoom @@ -150,6 +156,11 @@ (dom/prevent-default event) (reset! editing? true)) + on-export-shapes + (mf/use-callback + (fn [_] + (st/emit! (de/show-workspace-export-dialog)))) + on-export-file (mf/use-callback (mf/deps file team-id) @@ -178,21 +189,20 @@ (mf/deps file frames) (fn [_] (when (seq frames) - (let [filename (str (:name file) ".pdf") - frame-ids (mapv :id frames)] - (st/emit! (dm/info (tr "workspace.options.exporting-object") - {:timeout nil})) - (->> (rp/query! :export-frames - {:name (:name file) - :file-id (:id file) - :page-id page-id - :frame-ids frame-ids}) + (let [filename (dm/str (:name file) ".pdf") + xform (comp (map :id) + (map (fn [id] + {:file-id (:id file) + :page-id page-id + :frame-id id})))] + (st/emit! (msg/info (tr "workspace.options.exporting-object") {:timeout nil})) + (->> (rp/query! :export-frames (into [] xform frames)) (rx/subs (fn [body] (dom/trigger-download filename body)) (fn [_error] - (st/emit! (dm/error (tr "errors.unexpected-error")))) - (st/emitf dm/hide))))))) + (st/emit! (msg/error (tr "errors.unexpected-error")))) + (st/emitf msg/hide))))))) on-item-hover (mf/use-callback @@ -269,6 +279,9 @@ [:span (tr "dashboard.remove-shared")]] [:li {:on-click on-add-shared} [:span (tr "dashboard.add-shared")]]) + [:li.export-file {:on-click on-export-shapes} + [:span (tr "dashboard.export-shapes")] + [:span.shortcut (sc/get-tooltip :export-shapes)]] [:li.export-file {:on-click on-export-file} [:span (tr "dashboard.export-single")]] (when (seq frames) @@ -397,9 +410,9 @@ (mf/defc header [{:keys [file layout project page-id] :as props}] - (let [team-id (:team-id project) - zoom (mf/deref refs/selected-zoom) - params {:page-id page-id :file-id (:id file) :section "interactions"} + (let [team-id (:team-id project) + zoom (mf/deref refs/selected-zoom) + params {:page-id page-id :file-id (:id file) :section "interactions"} go-back (mf/use-callback @@ -429,6 +442,7 @@ [:div.right-area [:div.options-section [:& persistence-state-widget] + [:& export-progress-widget] [:button.document-history {:alt (tr "workspace.sidebar.history" (sc/get-tooltip :toggle-history)) :class (when (contains? layout :document-history) "selected") diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs index 62161131f..0628dd74a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs @@ -50,7 +50,9 @@ :bool [:& bool/options {:shape shape}] nil) [:& exports-menu - {:shape shape + {:ids [(:id shape)] + :values (select-keys shape [:exports]) + :shape shape :page-id page-id :file-id file-id}]]) @@ -82,7 +84,9 @@ :file-id file-id :shapes-with-children shapes-with-children}] :else [:& multiple/options {:shapes-with-children shapes-with-children - :shapes selected-shapes}])]] + :shapes selected-shapes + :page-id page-id + :file-id file-id}])]] [:& tab-element {:id :prototype :title (tr "workspace.options.prototype")} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs index 8e9fc5a25..0fe548cf9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs @@ -7,121 +7,138 @@ (ns app.main.ui.workspace.sidebar.options.menus.exports (:require [app.common.data :as d] - [app.main.data.messages :as dm] - [app.main.data.workspace :as udw] - [app.main.data.workspace.persistence :as dwp] - [app.main.repo :as rp] + [app.main.data.exports :as de] + [app.main.data.workspace.changes :as dch] + [app.main.data.workspace.state-helpers :as wsh] + [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.export] [app.main.ui.icons :as i] [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] - [beicon.core :as rx] + [app.util.i18n :refer [tr c]] [rumext.alpha :as mf])) -(defn request-export - [shape exports] - ;; Force a persist before exporting otherwise the exported shape could be outdated - (st/emit! ::dwp/force-persist) - (rp/query! - :export - {:page-id (:page-id shape) - :file-id (:file-id shape) - :object-id (:id shape) - :name (:name shape) - :exports exports})) - -(defn use-download-export - [shape page-id file-id exports] - (let [loading? (mf/use-state false) - - filename (cond-> (:name shape) - (and (= (count exports) 1) - (not (empty (:suffix (first exports))))) - (str (:suffix (first exports)))) - - on-download-callback - (mf/use-callback - (mf/deps filename shape exports) - (fn [event] - (dom/prevent-default event) - (swap! loading? not) - (->> (request-export (assoc shape :page-id page-id :file-id file-id) exports) - (rx/subs - (fn [body] - (dom/trigger-download filename body)) - (fn [_error] - (swap! loading? not) - (st/emit! (dm/error (tr "errors.unexpected-error")))) - (fn [] - (swap! loading? not))))))] - [on-download-callback @loading?])) +(def exports-attrs + "Shape attrs that corresponds to exports. Used in other namespaces." + [:exports]) (mf/defc exports-menu - [{:keys [shape page-id file-id] :as props}] - (let [exports (:exports shape []) + {::mf/wrap [#(mf/memo' % (mf/check-props ["ids" "values" "type" "page-id" "file-id"]))]} + [{:keys [ids type values page-id file-id] :as props}] + (let [exports (:exports values []) + + state (mf/deref refs/export) + in-progress? (:in-progress state) + + filename (when (seqable? exports) + (let [shapes (wsh/lookup-shapes @st/state ids) + sname (-> shapes first :name) + suffix (-> exports first :suffix)] + (cond-> sname + (and (= 1 (count exports)) (some? suffix)) + (str suffix)))) scale-enabled? (mf/use-callback - (fn [export] - (#{:png :jpeg} (:type export)))) + (fn [export] + (#{:png :jpeg} (:type export)))) - [on-download loading?] (use-download-export shape page-id file-id exports) + on-download + (mf/use-fn + (mf/deps ids page-id file-id exports) + (fn [event] + (dom/prevent-default event) + (if (= :multiple type) + (st/emit! (de/show-workspace-export-dialog {:selected ids})) + ;; In other all cases we only allowed to have a single + ;; shape-id because multiple shape-ids are handled + ;; separatelly by the export-modal. + (let [defaults {:page-id page-id + :file-id file-id + :name filename + :object-id (first ids)} + exports (mapv #(merge % defaults) exports)] + (if (= 1 (count exports)) + (st/emit! (de/request-simple-export {:export (first exports)})) + (st/emit! (de/request-multiple-export {:exports exports :filename filename}))))))) + + ;; TODO: maybe move to specific events for avoid to have this logic here? add-export (mf/use-callback - (mf/deps shape) + (mf/deps ids) (fn [] - (let [xspec {:type :png - :suffix "" - :scale 1}] - (st/emit! (udw/update-shape (:id shape) - {:exports (conj exports xspec)}))))) + (let [xspec {:type :png :suffix "" :scale 1}] + (st/emit! (dch/update-shapes ids + (fn [shape] + (assoc shape :exports (into [xspec] (:exports shape))))))))) + delete-export (mf/use-callback - (mf/deps shape) - (fn [index] - (let [[before after] (split-at index exports) - exports (d/concat-vec before (rest after))] - (st/emit! (udw/update-shape (:id shape) - {:exports exports}))))) + (mf/deps ids) + (fn [position] + (let [remove-fill-by-index (fn [values index] (->> (d/enumerate values) + (filterv (fn [[idx _]] (not= idx index))) + (mapv second))) + + remove (fn [shape] (update shape :exports remove-fill-by-index position))] + (st/emit! (dch/update-shapes ids remove))))) on-scale-change (mf/use-callback - (mf/deps shape) + (mf/deps ids) (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}))))) + value (d/parse-double value)] + (st/emit! (dch/update-shapes ids + (fn [shape] + (assoc-in shape [:exports index :scale] value))))))) on-suffix-change (mf/use-callback - (mf/deps shape) + (mf/deps ids) (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}))))) + value (dom/get-value target)] + (st/emit! (dch/update-shapes ids + (fn [shape] + (assoc-in shape [:exports index :suffix] value))))))) on-type-change (mf/use-callback - (mf/deps shape) + (mf/deps ids) (fn [index event] (let [target (dom/get-target event) value (dom/get-value target) - value (keyword value) - exports (assoc-in exports [index :type] value)] - (st/emit! (udw/update-shape (:id shape) - {:exports exports})))))] + value (keyword value)] + (st/emit! (dch/update-shapes ids + (fn [shape] + (assoc-in shape [:exports index :type] value))))))) + + on-remove-all + (mf/use-callback + (mf/deps ids) + (fn [] + (st/emit! (dch/update-shapes ids + (fn [shape] + (assoc shape :exports []))))))] [:div.element-set.exports-options [:div.element-set-title - [:span (tr "workspace.options.export")] - [:div.add-page {:on-click add-export} i/close]] - (when (seq exports) + [:span (tr (if (> (count ids) 1) "workspace.options.export-multiple" "workspace.options.export"))] + (when (not (= :multiple exports)) + [:div.add-page {:on-click add-export} i/close])] + + (cond + (= :multiple exports) + [:div.element-set-options-group + [:div.element-set-label (tr "settings.multiple")] + [:div.element-set-actions + [:div.element-set-actions-button {:on-click on-remove-all} + i/minus]]] + + (seq exports) [:div.element-set-content (for [[index export] (d/enumerate exports)] [:div.element-set-options-group @@ -146,14 +163,14 @@ [:option {:value "svg"} "SVG"] [:option {:value "pdf"} "PDF"]] [:div.delete-icon {:on-click (partial delete-export index)} - i/minus]]) - - [:div.btn-icon-dark.download-button - {:on-click (when-not loading? on-download) - :class (dom/classnames - :btn-disabled loading?) - :disabled loading?} - (if loading? - (tr "workspace.options.exporting-object") - (tr "workspace.options.export-object"))]])])) + i/minus]])]) + (when (or (= :multiple exports) (seq exports)) + [:div.btn-icon-dark.download-button + {:on-click (when-not in-progress? on-download) + :class (dom/classnames + :btn-disabled in-progress?) + :disabled in-progress?} + (if in-progress? + (tr "workspace.options.exporting-object") + (tr "workspace.options.export-object" (c (count ids))))])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs index 02900c8ed..7b4754e02 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs @@ -14,6 +14,7 @@ [app.main.ui.hooks :as hooks] [app.main.ui.workspace.sidebar.options.menus.blur :refer [blur-attrs blur-menu]] [app.main.ui.workspace.sidebar.options.menus.constraints :refer [constraint-attrs constraints-menu]] + [app.main.ui.workspace.sidebar.options.menus.exports :refer [exports-attrs exports-menu]] [app.main.ui.workspace.sidebar.options.menus.fill :refer [fill-attrs fill-menu]] [app.main.ui.workspace.sidebar.options.menus.layer :refer [layer-attrs layer-menu]] [app.main.ui.workspace.sidebar.options.menus.measures :refer [measure-attrs measures-menu]] @@ -36,7 +37,8 @@ :shadow :children :blur :children :stroke :shape - :text :children} + :text :children + :exports :shape} :group {:measure :shape @@ -46,7 +48,8 @@ :shadow :shape :blur :shape :stroke :children - :text :children} + :text :children + :exports :shape} :path {:measure :shape @@ -56,7 +59,8 @@ :shadow :shape :blur :shape :stroke :shape - :text :ignore} + :text :ignore + :exports :shape} :text {:measure :shape @@ -66,7 +70,8 @@ :shadow :shape :blur :shape :stroke :shape - :text :text} + :text :text + :exports :shape} :image {:measure :shape @@ -76,7 +81,8 @@ :shadow :shape :blur :shape :stroke :ignore - :text :ignore} + :text :ignore + :exports :shape} :rect {:measure :shape @@ -86,7 +92,8 @@ :shadow :shape :blur :shape :stroke :shape - :text :ignore} + :text :ignore + :exports :shape} :circle {:measure :shape @@ -96,7 +103,8 @@ :shadow :shape :blur :shape :stroke :shape - :text :ignore} + :text :ignore + :exports :shape} :svg-raw {:measure :shape @@ -106,7 +114,8 @@ :shadow :shape :blur :shape :stroke :shape - :text :ignore} + :text :ignore + :exports :shape} :bool {:measure :shape @@ -116,7 +125,8 @@ :shadow :shape :blur :shape :stroke :shape - :text :ignore}}) + :text :ignore + :exports :shape}}) (def group->attrs {:measure measure-attrs @@ -126,7 +136,8 @@ :shadow shadow-attrs :blur blur-attrs :stroke stroke-attrs - :text ot/attrs}) + :text ot/attrs + :exports exports-attrs}) (def shadow-keys [:style :color :offset-x :offset-y :blur :spread]) @@ -211,11 +222,13 @@ (dissoc :content))) (mf/defc options - {::mf/wrap [#(mf/memo' % (mf/check-props ["shapes" "shapes-with-children"]))] + {::mf/wrap [#(mf/memo' % (mf/check-props ["shapes" "shapes-with-children" "page-id" "file-id"]))] ::mf/wrap-props false} [props] (let [shapes (unchecked-get props "shapes") shapes-with-children (unchecked-get props "shapes-with-children") + page-id (unchecked-get props "page-id") + file-id (unchecked-get props "file-id") objects (->> shapes-with-children (group-by :id) (d/mapm (fn [_ v] (first v)))) show-caps (some #(and (= :path (:type %)) (gsh/open-path? %)) shapes) @@ -235,7 +248,8 @@ shadow-ids shadow-values blur-ids blur-values stroke-ids stroke-values - text-ids text-values] + text-ids text-values + exports-ids exports-values] (mf/use-memo (mf/deps objects-no-measures) (fn [] @@ -248,7 +262,8 @@ (get-attrs shapes objects-no-measures :shadow) (get-attrs shapes objects-no-measures :blur) (get-attrs shapes objects-no-measures :stroke) - (get-attrs shapes objects-no-measures :text)])))] + (get-attrs shapes objects-no-measures :text) + (get-attrs shapes objects-no-measures :exports)])))] [:div.options (when-not (empty? measure-ids) @@ -273,4 +288,7 @@ [:& blur-menu {:type type :ids blur-ids :values blur-values}]) (when-not (empty? text-ids) - [:& ot/text-menu {:type type :ids text-ids :values text-values}])])) + [:& ot/text-menu {:type type :ids text-ids :values text-values}]) + + (when-not (empty? exports-ids) + [:& exports-menu {:type type :ids exports-ids :values exports-values :page-id page-id :file-id file-id}])])) diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index 3ba2280fc..5c890ac89 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -131,7 +131,7 @@ (defn transit-data [data] (reify IBodyData - (-get-body-data [_] (t/encode-str data)) + (-get-body-data [_] (t/encode-str data {:type :json-verbose})) (-update-headers [_ headers] (assoc headers "content-type" "application/transit+json")))) diff --git a/frontend/src/app/util/websocket.cljs b/frontend/src/app/util/websocket.cljs new file mode 100644 index 000000000..bb6506f03 --- /dev/null +++ b/frontend/src/app/util/websocket.cljs @@ -0,0 +1,119 @@ +;; 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.websocket + "A interface to webworkers exposed functionality." + (:require + [app.common.transit :as t] + [beicon.core :as rx] + [goog.events :as ev]) + (:import + goog.net.WebSocket + goog.net.WebSocket.EventType)) + +(defprotocol IWebSocket + (-stream [_] "Retrieve the message stream") + (-send [_ message] "send a message") + (-close [_] "close websocket") + (-open? [_] "check if the channel is open")) + +(defn create + [uri] + (let [sb (rx/subject) + ws (WebSocket. #js {:autoReconnect true}) + data (atom {}) + lk1 (ev/listen ws EventType.MESSAGE + #(rx/push! sb {:type :message :payload (.-message %)})) + lk2 (ev/listen ws EventType.ERROR + #(rx/push! sb {:type :error :payload %})) + lk3 (ev/listen ws EventType.OPENED + #(rx/push! sb {:type :opened :payload %}))] + + (.open ws (str uri)) + (reify + IDeref + (-deref [_] (-deref data)) + + IReset + (-reset! [_ newval] + (-reset! data newval)) + + ISwap + (-swap! [_ f] + (-swap! data f)) + (-swap! [_ f x] + (-swap! data f x)) + (-swap! [_ f x y] + (-swap! data f x y)) + (-swap! [_ f x y more] + (-swap! data f x y more)) + + IWatchable + (-notify-watches [_ oldval newval] + (-notify-watches data oldval newval)) + + (-add-watch [_ key f] + (-add-watch data key f)) + + (-remove-watch [_ key] + (-remove-watch data key)) + + IHash + (-hash [_] (goog/getUid ws)) + + IWebSocket + (-stream [_] + (->> sb + (rx/map (fn [{:keys [type payload] :as message}] + (cond-> message + (= :message type) + (assoc :payload (t/decode-str payload))))))) + + (-send [_ msg] + (when (.isOpen ^js ws) + (.send ^js ws msg))) + + (-open? [_] + (.isOpen ^js ws)) + + (-close [_] + (rx/end! sb) + (ev/unlistenByKey lk1) + (ev/unlistenByKey lk2) + (ev/unlistenByKey lk3) + (.close ^js ws) + (.dispose ^js ws))))) + +(defn message-event? + ^boolean + [msg] + (= (:type msg) :message)) + +(defn error-event? + ^boolean + [msg] + (= (:type msg) :error)) + +(defn opened-event? + ^boolean + [msg] + (= (:type msg) :opened)) + +(defn send! + [ws msg] + (-send ws (t/encode-str msg))) + +(defn close! + [ws] + (-close ws)) + +(defn open? + [ws] + (-open? ws)) + +(defn get-rcv-stream + [ws] + (-stream ws)) diff --git a/frontend/src/app/util/websockets.cljs b/frontend/src/app/util/websockets.cljs deleted file mode 100644 index 14351085e..000000000 --- a/frontend/src/app/util/websockets.cljs +++ /dev/null @@ -1,57 +0,0 @@ -;; 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.websockets - "A interface to webworkers exposed functionality." - (:require - [app.common.transit :as t] - [beicon.core :as rx] - [goog.events :as ev]) - (:import - goog.net.WebSocket - goog.net.WebSocket.EventType)) - -(defprotocol IWebSocket - (-stream [_] "Retrieve the message stream") - (-send [_ message] "send a message") - (-close [_] "close websocket")) - -(defn open - [uri] - (let [sb (rx/subject) - ws (WebSocket. #js {:autoReconnect true}) - lk1 (ev/listen ws EventType.MESSAGE - #(rx/push! sb {:type :message :payload (.-message %)})) - lk2 (ev/listen ws EventType.ERROR - #(rx/push! sb {:type :error :payload %})) - lk3 (ev/listen ws EventType.OPENED - #(rx/push! sb {:type :opened :payload %}))] - (.open ws (str uri)) - (reify - cljs.core/IDeref - (-deref [_] ws) - - IWebSocket - (-stream [_] sb) - (-send [_ msg] - (when (.isOpen ^js ws) - (.send ^js ws msg))) - (-close [_] - (rx/end! sb) - (ev/unlistenByKey lk1) - (ev/unlistenByKey lk2) - (ev/unlistenByKey lk3) - (.close ^js ws) - (.dispose ^js ws))))) - - -(defn message? - [msg] - (= (:type msg) :message)) - -(defn send! - [ws msg] - (-send ws (t/encode-str msg))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 7e7dbb6fc..a06a30223 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -256,6 +256,10 @@ msgstr "" "Oh no! You have no files yet! If you want to try with some templates go " "to [Libraries & templates](https://penpot.app/libraries-templates.html)" +#: src/app/main/ui/workspace/header.cljs +msgid "dashboard.export-shapes" +msgstr "Exportar" + msgid "dashboard.export-frames" msgstr "Export artboards to PDF..." @@ -300,6 +304,26 @@ msgstr "Include shared library assets in file libraries" msgid "dashboard.export.title" msgstr "Export files" +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +msgid "dashboard.export-shapes.title" +msgstr "Export selection" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +msgid "dashboard.export-shapes.selected" +msgstr "%s de %s elements selected" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +msgid "dashboard.export-shapes.no-elements" +msgstr "There are no elements with export settings." + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +msgid "dashboard.export-shapes.how-to" +msgstr "You can add export settings to elements from the design properties (at the bottom of the right sidebar)." + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +msgid "dashboard.export-shapes.how-to-link" +msgstr "Info how to set exports at Penpot." + msgid "dashboard.fonts.deleted-placeholder" msgstr "Font deleted" @@ -2480,18 +2504,40 @@ msgstr "Design" msgid "workspace.options.export" msgstr "Export" +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs +msgid "workspace.options.export-multiple" +msgstr "Export selection" + #: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs msgid "workspace.options.export-object" -msgstr "Export" +msgid_plural "workspace.options.export-object" +msgstr[0] "Export 1 element" +msgstr[1] "Export %s elements" #: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs msgid "workspace.options.export.suffix" msgstr "Suffix" -#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs, src/app/main/ui/workspace/header.cljs msgid "workspace.options.exporting-object" msgstr "Exporting…" +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs, src/app/main/ui/workspace/header.cljs +msgid "workspace.options.exporting-object-slow" +msgstr "Export unexpectedly slow" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs, src/app/main/ui/workspace/header.cljs +msgid "workspace.options.exporting-object-error" +msgstr "Export failed" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs, src/app/main/ui/workspace/header.cljs +msgid "workspace.options.exporting-object-slow" +msgstr "Export unexpectedly slow" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs, src/app/main/ui/workspace/header.cljs +msgid "workspace.options.retry" +msgstr "Retry" + #: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs msgid "workspace.options.fill" msgstr "Fill" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 36ae34aa4..05437697a 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -260,6 +260,10 @@ msgstr "" "¡Oh, no! ¡Aún no tienes archivos! Si quieres probar con alguna plantilla ve a " "[Bibliotecas y plantillas](https://penpot.app/libraries-templates.html)" +#: src/app/main/ui/workspace/header.cljs +msgid "dashboard.export-shapes" +msgstr "Exportar" + msgid "dashboard.export-frames" msgstr "Exportar tableros a PDF..." @@ -304,6 +308,26 @@ msgstr "Incluir librerias compartidas dentro de las librerias del fichero" msgid "dashboard.export.title" msgstr "Exportar ficheros" +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +msgid "dashboard.export-shapes.title" +msgstr "Exportar selección" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +msgid "dashboard.export-shapes.selected" +msgstr "%s de %s elementos seleccionados" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +msgid "dashboard.export-shapes.no-elements" +msgstr "No hay elementos con configuraciones de exportación." + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +msgid "dashboard.export-shapes.how-to" +msgstr " Puedes añadir configuraciones de exportación a elementos desde las propiedades de diseño (al final del lateral derecho)." + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +msgid "dashboard.export-shapes.how-to-link" +msgstr "Información sobre cómo configurar exportaciones en Penpot." + msgid "dashboard.fonts.deleted-placeholder" msgstr "Fuente eliminada." @@ -2496,17 +2520,35 @@ msgstr "Diseño" msgid "workspace.options.export" msgstr "Exportar" +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs +msgid "workspace.options.export-multiple" +msgstr "Exportar selección" + #: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs msgid "workspace.options.export-object" -msgstr "Exportar" +msgid_plural "workspace.options.export-object" +msgstr[0] "Exportar 1 elemento" +msgstr[1] "Exportar %s elementos" #: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs msgid "workspace.options.export.suffix" msgstr "Sufijo" -#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs, src/app/main/ui/workspace/header.cljs msgid "workspace.options.exporting-object" -msgstr "Exportando" +msgstr "Exportando..." + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs, src/app/main/ui/workspace/header.cljs +msgid "workspace.options.exporting-object-slow" +msgstr "Exportación lenta" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs, src/app/main/ui/workspace/header.cljs +msgid "workspace.options.retry" +msgstr "Reintentar" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/handoff/exports.cljs, src/app/main/ui/workspace/header.cljs +msgid "workspace.options.exporting-object-error" +msgstr "Exportación fallida" #: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs msgid "workspace.options.fill"