0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-03-18 10:41:29 -05:00

🎉 Add assets exportation in bulk (multiple)

And adapt to the websocket changes on backend and
exporter.
This commit is contained in:
Alejandro Alonso 2022-03-01 13:42:55 +01:00 committed by Alonso Torres
parent f60d8c6c96
commit 0e0fb68c38
39 changed files with 1497 additions and 411 deletions

View file

@ -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")

View file

@ -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}})

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -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>

After

Width:  |  Height:  |  Size: 807 B

View file

@ -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;
}
}
}

View file

@ -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;

View file

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

View file

@ -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}))))))

View file

@ -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 [_ _ _]

View file

@ -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!))))

View file

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

View file

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

View file

@ -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

View file

@ -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))}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"}}]]]]])]))

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

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

View file

@ -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")

View file

@ -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")}

View file

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

View file

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

View file

@ -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"))))

View file

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

View file

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

View file

@ -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"

View file

@ -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"