Merge branch 'release-1.2.0' into main

This commit is contained in:
Andrey Antukh 2021-02-15 13:29:36 +01:00
commit 136d00797c
90 changed files with 2369 additions and 875 deletions

View file

@ -1,5 +1,6 @@
{:lint-as {potok.core/reify clojure.core/reify {:lint-as {potok.core/reify clojure.core/reify
promesa.core/let clojure.core/let promesa.core/let clojure.core/let
rumext.alpha/defc clojure.core/defn
app.db/with-atomic clojure.core/with-open} app.db/with-atomic clojure.core/with-open}
:output :output
{:exclude-files ["data_readers.clj"]} {:exclude-files ["data_readers.clj"]}

63
CHANGES.md Normal file
View file

@ -0,0 +1,63 @@
# CHANGELOG #
## Next
### New features
### Bugs fixed
## 1.2.0-alpha
### New features
- Add horizontal/vertical flip
- Add images lock proportions by default [#541](https://github.com/penpot/penpot/discussions/541), [#609](https://github.com/penpot/penpot/issues/609)
- Add new blob storage format (Zstd+nippy)
- Add user feedback form
- Improve French translations
- Improve component testing
- Increase default deletion delay to 7 days
- Show a pixel grid when zoom greater than 800% [#519](https://github.com/penpot/penpot/discussions/519)
- Fix behavior of select all command when there are objects outside frames [Taiga #1209](https://tree.taiga.io/project/penpot/issue/1209)
### Bugs fixed
- Fix 404 when access shared link [#615](https://github.com/penpot/penpot/issues/615)
- Fix 500 when requestion password reset
- Fix Problems when transforming path shapes [Taiga #1170](https://tree.taiga.io/project/penpot/issue/1170)
- Fix apply a color to a text selection from color palette was not working [Taiga #1189](https://tree.taiga.io/project/penpot/issue/1189)
- Fix issues when moving shapes outside groups [Taiga #1138](https://tree.taiga.io/project/penpot/issue/1138)
- Fix ldap function called on login click
- Fix logo icon in viewer should go to dashboard [Taiga #1149](https://tree.taiga.io/project/penpot/issue/1149)
- Fix ordering when restoring deleted shapes in sync [Taiga #1163](https://tree.taiga.io/project/penpot/issue/1163)
- Fix problem when editing text immediately after creating [Taiga #1207](https://tree.taiga.io/project/penpot/issue/1207)
- Fix problem when pasting URL's copied from the browser url bar [Taiga #1187](https://tree.taiga.io/project/penpot/issue/1187)
- Fix problem with multiple selection and groups [Taiga #1128](https://tree.taiga.io/project/penpot/issue/1128)
- Fix problem with red handler indicator on resize [Taiga #1188](https://tree.taiga.io/project/penpot/issue/1188)
- Fix show correct error when google auth is disabled [Taiga #1119](https://tree.taiga.io/project/penpot/issue/1119)
- Fix text alignment in preview [#594](https://github.com/penpot/penpot/issues/594)
- Fix unexpected exception when uploading image [Taiga #1120](https://tree.taiga.io/project/penpot/issue/1120)
- Fix updates on collaborative editing not updating selection rectangles [Taiga #1127](https://tree.taiga.io/project/penpot/issue/1127)
- Make the team deletion deferred (in the same way other objects)
### Community contributions by (Thank you! :heart:)
- abtinmo [#538](https://github.com/penpot/penpot/pull/538)
- kdrag0n [#585](https://github.com/penpot/penpot/pull/585)
- nisrulz [#586](https://github.com/penpot/penpot/pull/586)
- tomer [#575](https://github.com/penpot/penpot/pull/575)
- violoncelloCH [#554](https://github.com/penpot/penpot/pull/554)
## 1.1.0-alpha
- Bugfixing and stabilization post-launch
- Some changes to the register flow
- Improved MacOS shortcuts and helpers
- Small changes to shape creation
## 1.0.0-alpha
Initial release

View file

@ -1,8 +1,8 @@
# Contributing Guide # # Contributing Guide #
Thank you for your interest in contributing to Penpot. This is a Thank you for your interest in contributing to Penpot. This is a
generic guide that details how to contribute to Penpot in a way that is generic guide that details how to contribute to Penpot in a way that
efficient for everyone. If you want a specific documentation for is efficient for everyone. If you want a specific documentation for
different parts of the platform, please refer to `docs/` directory. different parts of the platform, please refer to `docs/` directory.
@ -19,12 +19,20 @@ If you found a bug, please report it, as far as possible with:
- a browser and the browser version used - a browser and the browser version used
- a dev tools console exception stack trace (if it is available) - a dev tools console exception stack trace (if it is available)
If you found a bug that you consider better discuse in private (for
example: security bugs), consider first send an email to
`info@penpot.app`.
**We don't have formal bug bounty program for security reports; this
is an open source application and your contribution will be recognized
in the changelog.**
## Pull requests ## ## Pull requests ##
If you want propose a change or bug fix with the Pull-Request system If you want propose a change or bug fix with the Pull-Request system
firstly you should carefully read the **Contributor License Aggreement** firstly you should carefully read the **DCO** section and format your
section and format your commits accordingly. commits accordingly.
If you intend to fix a bug it's fine to submit a pull request right If you intend to fix a bug it's fine to submit a pull request right
away but we still recommend to file an issue detailing what you're away but we still recommend to file an issue detailing what you're
@ -127,7 +135,7 @@ This Code of Conduct is adapted from the Contributor Covenant, version
1.1.0, available from http://contributor-covenant.org/version/1/1/0/ 1.1.0, available from http://contributor-covenant.org/version/1/1/0/
## Contributor License Agreement ## ## Developer's Certificate of Origin (DCO) ##
By submitting code you are agree and can certify the below: By submitting code you are agree and can certify the below:
@ -157,9 +165,9 @@ By submitting code you are agree and can certify the below:
maintained indefinitely and may be redistributed consistent with maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved. this project or the open source license(s) involved.
Then, all your patches should contain a sign-off at the end of the Then, all your code patches (**documentation are excluded**) should
patch/commit description body. It can be automatically added on adding contain a sign-off at the end of the patch/commit description body. It
`-s` parameter to `git commit`. can be automatically added on adding `-s` parameter to `git commit`.
This is an example of the aspect of the line: This is an example of the aspect of the line:

View file

@ -4,7 +4,7 @@
[![License: MPL-2.0][uri_license_image]][uri_license] [![License: MPL-2.0][uri_license_image]][uri_license]
[![Gitter](https://badges.gitter.im/sereno-xyz/community.svg)](https://gitter.im/penpot/community) [![Gitter](https://badges.gitter.im/sereno-xyz/community.svg)](https://gitter.im/penpot/community)
[![Managed with Taiga.io](https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg)](https://tree.taiga.io/project/uxboxproject/ "Managed with Taiga.io") [![Managed with Taiga.io](https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg)](https://tree.taiga.io/project/penpot/ "Managed with Taiga.io")
# PENPOT # # PENPOT #

View file

@ -18,6 +18,8 @@
org.slf4j/slf4j-api {:mvn/version "1.7.30"} org.slf4j/slf4j-api {:mvn/version "1.7.30"}
org.graalvm.js/js {:mvn/version "20.3.0"} org.graalvm.js/js {:mvn/version "20.3.0"}
com.taoensso/nippy {:mvn/version "3.1.1"}
com.github.luben/zstd-jni {:mvn/version "1.4.8-3"}
io.prometheus/simpleclient {:mvn/version "0.9.0"} io.prometheus/simpleclient {:mvn/version "0.9.0"}
io.prometheus/simpleclient_hotspot {:mvn/version "0.9.0"} io.prometheus/simpleclient_hotspot {:mvn/version "0.9.0"}

View file

@ -14,6 +14,7 @@
[app.util.time :as dt] [app.util.time :as dt]
[app.util.transit :as t] [app.util.transit :as t]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[taoensso.nippy :as nippy]
[clojure.data.json :as json] [clojure.data.json :as json]
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.test :as test] [clojure.test :as test]

View file

@ -30,14 +30,14 @@
for security reasons. for security reasons.
</mj-text> </mj-text>
<mj-text>Enjoy!</mj-text> <mj-text>Enjoy!</mj-text>
<mj-text>The UXBOX team.</mj-text> <mj-text>The Penpot team.</mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section padding="24px 0 0 0"> <mj-section padding="24px 0 0 0">
<mj-column width="425px"> <mj-column width="425px">
<mj-text align="center" font-size="14px" color="#64666A"> <mj-text align="center" font-size="14px" color="#64666A">
UXBOX is the first Open Source prototyping platform that will be embraced by multidisciplinary teams. Penpot is the first Open Source prototyping platform that will be embraced by multidisciplinary teams.
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
@ -57,7 +57,7 @@
<mj-section padding="0 0 24px 0"> <mj-section padding="0 0 24px 0">
<mj-column> <mj-column>
<mj-text align="center" font-size="14px" color="#64666A" line-height="150%"> <mj-text align="center" font-size="14px" color="#64666A" line-height="150%">
UXBOX © 2020 | Made with &lt;3 and Open Source Penpot © 2020 | Made with &lt;3 and Open Source
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -23,14 +23,14 @@
Accept invite Accept invite
</mj-button> </mj-button>
<mj-text>Enjoy!</mj-text> <mj-text>Enjoy!</mj-text>
<mj-text>The UXBOX team.</mj-text> <mj-text>The Penpot team.</mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section padding="24px 0 0 0"> <mj-section padding="24px 0 0 0">
<mj-column width="425px"> <mj-column width="425px">
<mj-text align="center" font-size="14px" color="#64666A"> <mj-text align="center" font-size="14px" color="#64666A">
UXBOX is the first Open Source prototyping platform that will be embraced by multidisciplinary teams. Penpot is the first Open Source prototyping platform that will be embraced by multidisciplinary teams.
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
@ -50,7 +50,7 @@
<mj-section padding="0 0 24px 0"> <mj-section padding="0 0 24px 0">
<mj-column> <mj-column>
<mj-text align="center" font-size="14px" color="#64666A" line-height="150%"> <mj-text align="center" font-size="14px" color="#64666A" line-height="150%">
UXBOX © 2020 | Made with &lt;3 and Open Source Penpot © 2020 | Made with &lt;3 and Open Source
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -32,14 +32,14 @@
it. Your password won't be changed. it. Your password won't be changed.
</mj-text> </mj-text>
<mj-text>Enjoy!</mj-text> <mj-text>Enjoy!</mj-text>
<mj-text>The UXBOX team.</mj-text> <mj-text>The Penpot team.</mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section padding="24px 0 0 0"> <mj-section padding="24px 0 0 0">
<mj-column width="425px"> <mj-column width="425px">
<mj-text align="center" font-size="14px" color="#64666A"> <mj-text align="center" font-size="14px" color="#64666A">
UXBOX is the first Open Source prototyping platform that will be embraced by multidisciplinary teams. Penpot is the first Open Source prototyping platform that will be embraced by multidisciplinary teams.
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
@ -59,7 +59,7 @@
<mj-section padding="0 0 24px 0"> <mj-section padding="0 0 24px 0">
<mj-column> <mj-column>
<mj-text align="center" font-size="14px" color="#64666A" line-height="150%"> <mj-text align="center" font-size="14px" color="#64666A" line-height="150%">
UXBOX © 2020 | Made with &lt;3 and Open Source Penpot © 2020 | Made with &lt;3 and Open Source
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -21,7 +21,7 @@
<mj-column> <mj-column>
<mj-text font-size="24px" font-weight="600">Hello {{name}}!</mj-text> <mj-text font-size="24px" font-weight="600">Hello {{name}}!</mj-text>
<mj-text> <mj-text>
Thanks for signing up for your UXBOX account! Please verify your Thanks for signing up for your Penpot account! Please verify your
email using the link below adn get started building mockups and email using the link below adn get started building mockups and
prototypes today! prototypes today!
</mj-text> </mj-text>
@ -29,14 +29,14 @@
Verify email Verify email
</mj-button> </mj-button>
<mj-text>Enjoy!</mj-text> <mj-text>Enjoy!</mj-text>
<mj-text>The UXBOX team.</mj-text> <mj-text>The Penpot team.</mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
<mj-section padding="24px 0 0 0"> <mj-section padding="24px 0 0 0">
<mj-column width="425px"> <mj-column width="425px">
<mj-text align="center" font-size="14px" color="#64666A"> <mj-text align="center" font-size="14px" color="#64666A">
UXBOX is the first Open Source prototyping platform that will be embraced by multidisciplinary teams. Penpot is the first Open Source prototyping platform that will be embraced by multidisciplinary teams.
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>
@ -56,7 +56,7 @@
<mj-section padding="0 0 24px 0"> <mj-section padding="0 0 24px 0">
<mj-column> <mj-column>
<mj-text align="center" font-size="14px" color="#64666A" line-height="150%"> <mj-text align="center" font-size="14px" color="#64666A" line-height="150%">
UXBOX © 2020 | Made with &lt;3 and Open Source Penpot © 2020 | Made with &lt;3 and Open Source
</mj-text> </mj-text>
</mj-column> </mj-column>
</mj-section> </mj-section>

View file

@ -10,4 +10,4 @@ If you received this email by mistake, please consider changing your password
for security reasons. for security reasons.
Enjoy! Enjoy!
The UXBOX team. The Penpot team.

View file

@ -0,0 +1 @@
[FEEDBACK]: From {{ profile.email }}

View file

@ -0,0 +1,7 @@
Feedback from: {{profile.fullname}} <{{profile.email}}>
Profile ID: {{profile.id}}
Subject: {{subject}}
{{content}}

View file

@ -7,4 +7,4 @@ Accept invitation using this link:
{{ public-uri }}/#/auth/verify-token?token={{token}} {{ public-uri }}/#/auth/verify-token?token={{token}}
Enjoy! Enjoy!
The UXBOX team. The Penpot team.

View file

@ -9,4 +9,4 @@ If you received this email by mistake, you can safely ignore it. Your password
won't be changed. won't be changed.
Enjoy! Enjoy!
The UXBOX team. The Penpot team.

View file

@ -1,9 +1,9 @@
Hello {{name}}! Hello {{name}}!
Thanks for signing up for your UXBOX account! Please verify your email using the Thanks for signing up for your Penpot account! Please verify your email using the
link below adn get started building mockups and prototypes today! link below adn get started building mockups and prototypes today!
{{ public-uri }}/#/auth/verify-token?token={{token}} {{ public-uri }}/#/auth/verify-token?token={{token}}
Enjoy! Enjoy!
The UXBOX team. The Penpot team.

View file

@ -24,6 +24,8 @@
:database-username "penpot" :database-username "penpot"
:database-password "penpot" :database-password "penpot"
:default-blob-version 1
:asserts-enabled false :asserts-enabled false
:public-uri "http://localhost:3449" :public-uri "http://localhost:3449"
@ -38,6 +40,9 @@
:storage-s3-region :eu-central-1 :storage-s3-region :eu-central-1
:storage-s3-bucket "penpot-devenv-assets-pre" :storage-s3-bucket "penpot-devenv-assets-pre"
:feedback-destination "info@example.com"
:feedback-enabled false
:assets-path "/internal/assets/" :assets-path "/internal/assets/"
:rlimits-password 10 :rlimits-password 10
@ -79,6 +84,7 @@
(s/def ::database-uri ::us/string) (s/def ::database-uri ::us/string)
(s/def ::redis-uri ::us/string) (s/def ::redis-uri ::us/string)
(s/def ::storage-backend ::us/keyword) (s/def ::storage-backend ::us/keyword)
(s/def ::storage-fs-directory ::us/string) (s/def ::storage-fs-directory ::us/string)
(s/def ::assets-path ::us/string) (s/def ::assets-path ::us/string)
@ -89,7 +95,11 @@
(s/def ::media-directory ::us/string) (s/def ::media-directory ::us/string)
(s/def ::asserts-enabled ::us/boolean) (s/def ::asserts-enabled ::us/boolean)
(s/def ::feedback-enabled ::us/boolean)
(s/def ::feedback-destination ::us/string)
(s/def ::error-report-webhook ::us/string) (s/def ::error-report-webhook ::us/string)
(s/def ::smtp-enabled ::us/boolean) (s/def ::smtp-enabled ::us/boolean)
(s/def ::smtp-default-reply-to ::us/string) (s/def ::smtp-default-reply-to ::us/string)
(s/def ::smtp-default-from ::us/string) (s/def ::smtp-default-from ::us/string)
@ -142,13 +152,18 @@
(s/def ::initial-data-file ::us/string) (s/def ::initial-data-file ::us/string)
(s/def ::initial-data-project-name ::us/string) (s/def ::initial-data-project-name ::us/string)
(s/def ::default-blob-version ::us/integer)
(s/def ::config (s/def ::config
(s/keys :opt-un [::allow-demo-users (s/keys :opt-un [::allow-demo-users
::asserts-enabled ::asserts-enabled
::database-password ::database-password
::database-uri ::database-uri
::database-username ::database-username
::default-blob-version
::error-report-webhook ::error-report-webhook
::feedback-enabled
::feedback-destination
::github-client-id ::github-client-id
::github-client-secret ::github-client-secret
::gitlab-base-uri ::gitlab-base-uri
@ -230,5 +245,5 @@
(def config (read-config env)) (def config (read-config env))
(def test-config (read-test-config env)) (def test-config (read-test-config env))
(def default-deletion-delay (def deletion-delay
(dt/duration {:hours 48})) (dt/duration {:days 7}))

View file

@ -43,6 +43,16 @@
;; --- Emails ;; --- Emails
(s/def ::subject ::us/string)
(s/def ::content ::us/string)
(s/def ::feedback
(s/keys :req-un [::subject ::content]))
(def feedback
"A profile feedback email."
(emails/template-factory ::feedback default-context))
(s/def ::name ::us/string) (s/def ::name ::us/string)
(s/def ::register (s/def ::register
(s/keys :req-un [::name])) (s/keys :req-un [::name]))

View file

@ -35,6 +35,7 @@
(defn- get-access-token (defn- get-access-token
[cfg code] [cfg code]
(try
(let [params {:code code (let [params {:code code
:client_id (:client-id cfg) :client_id (:client-id cfg)
:client_secret (:client-secret cfg) :client_secret (:client-secret cfg)
@ -46,40 +47,28 @@
:body (uri/map->query-string params)} :body (uri/map->query-string params)}
res (http/send! req)] res (http/send! req)]
(when (not= 200 (:status res)) (when (= 200 (:status res))
(ex/raise :type :internal (-> (json/read-str (:body res))
:code :invalid-response-from-google (get "access_token"))))
:context {:status (:status res)
:body (:body res)}))
(try (catch Exception e
(let [data (json/read-str (:body res))] (log/error e "unexpected error on get-access-token")
(get data "access_token")) nil)))
(catch Throwable e
(log/error "unexpected error on parsing response body from google access token request" e)
nil))))
(defn- get-user-info (defn- get-user-info
[token] [token]
(try
(let [req {:uri "https://openidconnect.googleapis.com/v1/userinfo" (let [req {:uri "https://openidconnect.googleapis.com/v1/userinfo"
:headers {"Authorization" (str "Bearer " token)} :headers {"Authorization" (str "Bearer " token)}
:method :get} :method :get}
res (http/send! req)] res (http/send! req)]
(when (= 200 (:status res))
(when (not= 200 (:status res))
(ex/raise :type :internal
:code :invalid-response-from-google
:context {:status (:status res)
:body (:body res)}))
(try
(let [data (json/read-str (:body res))] (let [data (json/read-str (:body res))]
;; (clojure.pprint/pprint data)
{:email (get data "email") {:email (get data "email")
:fullname (get data "name")}) :fullname (get data "name")})))
(catch Throwable e (catch Exception e
(log/error "unexpected error on parsing response body from google access token request" e) (log/error e "unexpected exception on get-user-info")
nil)))) nil)))
(defn- auth (defn- auth
[{:keys [tokens] :as cfg} _req] [{:keys [tokens] :as cfg} _req]
@ -99,33 +88,39 @@
(defn- callback (defn- callback
[{:keys [tokens rpc session] :as cfg} request] [{:keys [tokens rpc session] :as cfg} request]
(try
(let [token (get-in request [:params :state]) (let [token (get-in request [:params :state])
_ (tokens :verify {:token token :iss :google-oauth}) _ (tokens :verify {:token token :iss :google-oauth})
info (some->> (get-in request [:params :code]) info (some->> (get-in request [:params :code])
(get-access-token cfg) (get-access-token cfg)
(get-user-info))] (get-user-info))
_ (when-not info
(when-not info (ex/raise :type :internal
(ex/raise :type :authentication :code :unable-to-auth))
:code :unable-to-authenticate-with-google)) method-fn (get-in rpc [:methods :mutation :login-or-register])
(let [method-fn (get-in rpc [:methods :mutation :login-or-register])
profile (method-fn {:email (:email info) profile (method-fn {:email (:email info)
:fullname (:fullname info)}) :fullname (:fullname info)})
uagent (get-in request [:headers "user-agent"]) uagent (get-in request [:headers "user-agent"])
token (tokens :generate {:iss :auth token (tokens :generate {:iss :auth
:exp (dt/in-future "15m") :exp (dt/in-future "15m")
:profile-id (:id profile)}) :profile-id (:id profile)})
uri (-> (uri/uri (:public-uri cfg)) uri (-> (uri/uri (:public-uri cfg))
(assoc :path "/#/auth/verify-token") (assoc :path "/#/auth/verify-token")
(assoc :query (uri/map->query-string {:token token}))) (assoc :query (uri/map->query-string {:token token})))
sid (session/create! session {:profile-id (:id profile) sid (session/create! session {:profile-id (:id profile)
:user-agent uagent})] :user-agent uagent})]
{:status 302 {:status 302
:headers {"location" (str uri)} :headers {"location" (str uri)}
:cookies (session/cookies session {:value sid}) :cookies (session/cookies session {:value sid})
:body ""}))) :body ""})
(catch Exception _e
(let [uri (-> (uri/uri (:public-uri cfg))
(assoc :path "/#/auth/login")
(assoc :query (uri/map->query-string {:error "unable-to-auth"})))]
{:status 302
:headers {"location" (str uri)}
:body ""}))))
(s/def ::client-id ::us/not-empty-string) (s/def ::client-id ::us/not-empty-string)
(s/def ::client-secret ::us/not-empty-string) (s/def ::client-secret ::us/not-empty-string)

View file

@ -175,7 +175,12 @@
(ex/raise :type :internal (ex/raise :type :internal
:code :rlimit-not-configured :code :rlimit-not-configured
:hint ":image rlimit not configured")) :hint ":image rlimit not configured"))
(rlm/execute rlimit (process params)))) (try
(rlm/execute rlimit (process params))
(catch org.im4java.core.InfoException e
(ex/raise :type :validation
:code :invalid-image
:cause e)))))
;; --- Utility functions ;; --- Utility functions

View file

@ -155,11 +155,12 @@
:dec (.. ^Gauge instance (labels labels) (dec))))))) :dec (.. ^Gauge instance (labels labels) (dec)))))))
(defn make-summary (defn make-summary
[{:keys [name help registry reg labels] :as props}] [{:keys [name help registry reg labels max-age] :or {max-age 3600} :as props}]
(let [registry (or registry reg) (let [registry (or registry reg)
instance (doto (Summary/build) instance (doto (Summary/build)
(.name name) (.name name)
(.help help) (.help help)
(.maxAgeSeconds max-age)
(.quantile 0.75 0.02) (.quantile 0.75 0.02)
(.quantile 0.99 0.001)) (.quantile 0.99 0.001))
_ (when (seq labels) _ (when (seq labels)

View file

@ -145,6 +145,9 @@
{:name "0044-add-storage-refcount" {:name "0044-add-storage-refcount"
:fn (mg/resource "app/migrations/sql/0044-add-storage-refcount.sql")} :fn (mg/resource "app/migrations/sql/0044-add-storage-refcount.sql")}
{:name "0045-add-index-to-file-change-table"
:fn (mg/resource "app/migrations/sql/0045-add-index-to-file-change-table.sql")}
]) ])

View file

@ -0,0 +1,2 @@
CREATE INDEX file_change__created_at_idx
ON file_change (created_at);

View file

@ -126,6 +126,7 @@
'app.rpc.mutations.projects 'app.rpc.mutations.projects
'app.rpc.mutations.viewer 'app.rpc.mutations.viewer
'app.rpc.mutations.teams 'app.rpc.mutations.teams
'app.rpc.mutations.feedback
'app.rpc.mutations.verify-token) 'app.rpc.mutations.verify-token)
(map (partial process-method cfg)) (map (partial process-method cfg))
(into {})))) (into {}))))

View file

@ -52,7 +52,7 @@
;; Schedule deletion of the demo profile ;; Schedule deletion of the demo profile
(tasks/submit! conn {:name "delete-profile" (tasks/submit! conn {:name "delete-profile"
:delay cfg/default-deletion-delay :delay cfg/deletion-delay
:props {:profile-id id}}) :props {:profile-id id}})
{:email email {:email email

View file

@ -0,0 +1,41 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2021 UXBOX Labs SL
(ns app.rpc.mutations.feedback
(:require
[app.common.exceptions :as ex]
[app.common.spec :as us]
[app.config :as cfg]
[app.db :as db]
[app.emails :as emails]
[app.rpc.queries.profile :as profile]
[app.util.services :as sv]
[clojure.spec.alpha :as s]))
(s/def ::subject ::us/string)
(s/def ::content ::us/string)
(s/def ::send-profile-feedback
(s/keys :req-un [::profile-id ::subject ::content]))
(sv/defmethod ::send-profile-feedback
[{:keys [pool] :as cfg} {:keys [profile-id subject content] :as params}]
(when-not (:feedback-enabled cfg/config)
(ex/raise :type :validation
:code :feedback-disabled
:hint "feedback module is disabled"))
(db/with-atomic [conn pool]
(let [profile (profile/retrieve-profile-data conn profile-id)]
(emails/send! conn emails/feedback
{:to (:feedback-destination cfg/config)
:profile profile
:subject subject
:content content})
nil)))

View file

@ -129,7 +129,7 @@
;; Schedule object deletion ;; Schedule object deletion
(tasks/submit! conn {:name "delete-object" (tasks/submit! conn {:name "delete-object"
:delay cfg/default-deletion-delay :delay cfg/deletion-delay
:props {:id id :type :file}}) :props {:id id :type :file}})
(mark-file-deleted conn params))) (mark-file-deleted conn params)))

View file

@ -66,9 +66,18 @@
[info] [info]
(= (:mtype info) "image/svg+xml")) (= (:mtype info) "image/svg+xml"))
(defn- fetch-url
[url]
(try
(http/get! url {:as :byte-array})
(catch Exception e
(ex/raise :type :validation
:code :unable-to-access-to-url
:cause e))))
(defn- download-media (defn- download-media
[{:keys [storage] :as cfg} url] [{:keys [storage] :as cfg} url]
(let [result (http/get! url {:as :byte-array}) (let [result (fetch-url url)
data (:body result) data (:body result)
mtype (get (:headers result) "content-type") mtype (get (:headers result) "content-type")
format (cm/mtype->format mtype)] format (cm/mtype->format mtype)]

View file

@ -472,7 +472,7 @@
;; Schedule a complete deletion of profile ;; Schedule a complete deletion of profile
(tasks/submit! conn {:name "delete-profile" (tasks/submit! conn {:name "delete-profile"
:delay (dt/duration {:hours 48}) :delay cfg/deletion-delay
:props {:profile-id profile-id}}) :props {:profile-id profile-id}})
(db/update! conn :profile (db/update! conn :profile

View file

@ -16,6 +16,7 @@
[app.rpc.queries.projects :as proj] [app.rpc.queries.projects :as proj]
[app.tasks :as tasks] [app.tasks :as tasks]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt]
[clojure.spec.alpha :as s])) [clojure.spec.alpha :as s]))
;; --- Helpers & Specs ;; --- Helpers & Specs
@ -113,8 +114,6 @@
;; --- Mutation: Delete Project ;; --- Mutation: Delete Project
(declare mark-project-deleted)
(s/def ::delete-project (s/def ::delete-project
(s/keys :req-un [::id ::profile-id])) (s/keys :req-un [::id ::profile-id]))
@ -125,18 +124,10 @@
;; Schedule object deletion ;; Schedule object deletion
(tasks/submit! conn {:name "delete-object" (tasks/submit! conn {:name "delete-object"
:delay cfg/default-deletion-delay :delay cfg/deletion-delay
:props {:id id :type :project}}) :props {:id id :type :project}})
(mark-project-deleted conn params))) (db/update! conn :project
{:deleted-at (dt/now)}
(def ^:private sql:mark-project-deleted {:id id})
"update project nil))
set deleted_at = clock_timestamp()
where id = ?
returning id")
(defn mark-project-deleted
[conn {:keys [id] :as params}]
(db/exec! conn [sql:mark-project-deleted id])
nil)

View file

@ -13,6 +13,7 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cfg]
[app.db :as db] [app.db :as db]
[app.emails :as emails] [app.emails :as emails]
[app.media :as media] [app.media :as media]
@ -20,6 +21,7 @@
[app.rpc.queries.profile :as profile] [app.rpc.queries.profile :as profile]
[app.rpc.queries.teams :as teams] [app.rpc.queries.teams :as teams]
[app.storage :as sto] [app.storage :as sto]
[app.tasks :as tasks]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
@ -133,7 +135,14 @@
(ex/raise :type :validation (ex/raise :type :validation
:code :only-owner-can-delete-team)) :code :only-owner-can-delete-team))
(db/delete! conn :team {:id id}) ;; Schedule object deletion
(tasks/submit! conn {:name "delete-object"
:delay cfg/deletion-delay
:props {:id id :type :team}})
(db/update! conn :team
{:deleted-at (dt/now)}
{:id id})
nil))) nil)))

View file

@ -7,8 +7,6 @@
;; ;;
;; Copyright (c) 2020 UXBOX Labs SL ;; Copyright (c) 2020 UXBOX Labs SL
;; TODO: session
(ns app.rpc.mutations.verify-token (ns app.rpc.mutations.verify-token
(:require (:require
[app.common.exceptions :as ex] [app.common.exceptions :as ex]

View file

@ -38,7 +38,7 @@
{:id id}))) {:id id})))
(defn get-file (defn get-file
[id] [system id]
(with-open [conn (db/open (:app.db/pool system))] (with-open [conn (db/open (:app.db/pool system))]
(let [file (db/get-by-id conn :file id)] (let [file (db/get-by-id conn :file id)]
(-> file (-> file
@ -72,3 +72,17 @@
(let [profile (prof/retrieve-profile-data-by-email conn user-email) (let [profile (prof/retrieve-profile-data-by-email conn user-email)
profile (merge profile (prof/retrieve-additional-data conn (:id profile)))] profile (merge profile (prof/retrieve-additional-data conn (:id profile)))]
(pid/create-profile-initial-data conn file profile))))) (pid/create-profile-initial-data conn file profile)))))
;; Migrate
(defn update-file-data-blob-format
[system]
(db/with-atomic [conn (:app.db/pool system)]
(doseq [id (->> (db/exec! conn ["select id from file;"]) (map :id))]
(let [{:keys [data]} (db/get-by-id conn :file id {:columns [:id :data]})]
(prn "Updating file:" id)
(db/update! conn :file
{:data (-> (blob/decode data)
(blob/encode {:version 2}))}
{:id id})))))

View file

@ -121,11 +121,16 @@
(defn parse (defn parse
[data] [data]
(try
(with-open [istream (IOUtils/toInputStream data "UTF-8")] (with-open [istream (IOUtils/toInputStream data "UTF-8")]
(xml/parse istream))) (xml/parse istream))
(catch org.xml.sax.SAXParseException _e
(ex/raise :type :validation
:code :invalid-svg-file))))
(defn process-request (defn process-request
[{:keys [svgc] :as cfg} body] [{:keys [svgc] :as cfg} body]
(let [data (slurp body) (let [data (slurp body)
data (svgc data)] data (svgc data)]
(parse data))) (parse data)))

View file

@ -42,11 +42,12 @@
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(handle-deletion conn props))) (handle-deletion conn props)))
(defmulti handle-deletion (fn [_ props] (:type props))) (defmulti handle-deletion
(fn [_ props] (:type props)))
(defmethod handle-deletion :default (defmethod handle-deletion :default
[_conn {:keys [type]}] [_conn {:keys [type]}]
(log/warn "no handler found for" type)) (log/warnf "no handler found for %s" type))
(defmethod handle-deletion :file (defmethod handle-deletion :file
[conn {:keys [id] :as props}] [conn {:keys [id] :as props}]
@ -57,3 +58,8 @@
[conn {:keys [id] :as props}] [conn {:keys [id] :as props}]
(let [sql "delete from project where id=? and deleted_at is not null"] (let [sql "delete from project where id=? and deleted_at is not null"]
(db/exec-one! conn [sql id]))) (db/exec-one! conn [sql id])))
(defmethod handle-deletion :team
[conn {:keys [id] :as props}]
(let [sql "delete from team where id=? and deleted_at is not null"]
(db/exec-one! conn [sql id])))

View file

@ -10,61 +10,93 @@
(ns app.util.blob (ns app.util.blob
"A generic blob storage encoding. Mainly used for "A generic blob storage encoding. Mainly used for
page data, page options and txlog payload storage." page data, page options and txlog payload storage."
(:require [app.util.transit :as t]) (:require
[app.config :as cfg]
[app.util.transit :as t]
[taoensso.nippy :as n])
(:import (:import
java.io.ByteArrayInputStream java.io.ByteArrayInputStream
java.io.ByteArrayOutputStream java.io.ByteArrayOutputStream
java.io.DataInputStream java.io.DataInputStream
java.io.DataOutputStream java.io.DataOutputStream
com.github.luben.zstd.Zstd
net.jpountz.lz4.LZ4Factory net.jpountz.lz4.LZ4Factory
net.jpountz.lz4.LZ4FastDecompressor net.jpountz.lz4.LZ4FastDecompressor
net.jpountz.lz4.LZ4Compressor)) net.jpountz.lz4.LZ4Compressor))
(defprotocol IDataToBytes
(->bytes [data] "convert data to bytes"))
(extend-protocol IDataToBytes
(Class/forName "[B")
(->bytes [data] data)
String
(->bytes [data] (.getBytes ^String data "UTF-8")))
(def lz4-factory (LZ4Factory/fastestInstance)) (def lz4-factory (LZ4Factory/fastestInstance))
(defn encode
[data]
(let [data (t/encode data {:type :json})
data-len (alength ^bytes data)
cp (.fastCompressor ^LZ4Factory lz4-factory)
max-len (.maxCompressedLength cp data-len)
cdata (byte-array max-len)
clen (.compress ^LZ4Compressor cp ^bytes data 0 data-len cdata 0 max-len)]
(with-open [^ByteArrayOutputStream baos (ByteArrayOutputStream. (+ (alength cdata) 2 4))
^DataOutputStream dos (DataOutputStream. baos)]
(.writeShort dos (short 1)) ;; version number
(.writeInt dos (int data-len))
(.write dos ^bytes cdata (int 0) clen)
(.toByteArray baos))))
(declare decode-v1) (declare decode-v1)
(declare decode-v2)
(declare encode-v1)
(declare encode-v2)
(def default-version
(:default-blob-version cfg/config 1))
(defn encode
([data] (encode data nil))
([data {:keys [version] :or {version default-version}}]
(case version
1 (encode-v1 data)
2 (encode-v2 data)
(throw (ex-info "unsupported version" {:version version})))))
(defn decode (defn decode
"A function used for decode persisted blobs in the database." "A function used for decode persisted blobs in the database."
[data] [^bytes data]
(let [data (->bytes data)]
(with-open [bais (ByteArrayInputStream. data) (with-open [bais (ByteArrayInputStream. data)
dis (DataInputStream. bais)] dis (DataInputStream. bais)]
(let [version (.readShort dis) (let [version (.readShort dis)
udata-len (.readInt dis)] ulen (.readInt dis)]
(case version (case version
1 (decode-v1 data udata-len) 1 (decode-v1 data ulen)
(throw (ex-info "unsupported version" {:version version}))))))) 2 (decode-v2 data ulen)
(throw (ex-info "unsupported version" {:version version}))))))
;; --- IMPL
(defn- encode-v1
[data]
(let [data (t/encode data {:type :json})
dlen (alength ^bytes data)
cp (.fastCompressor ^LZ4Factory lz4-factory)
mlen (.maxCompressedLength cp dlen)
cdata (byte-array mlen)
clen (.compress ^LZ4Compressor cp ^bytes data 0 dlen cdata 0 mlen)]
(with-open [^ByteArrayOutputStream baos (ByteArrayOutputStream. (+ (alength cdata) 2 4))
^DataOutputStream dos (DataOutputStream. baos)]
(.writeShort dos (short 1)) ;; version number
(.writeInt dos (int dlen))
(.write dos ^bytes cdata (int 0) clen)
(.toByteArray baos))))
(defn- decode-v1 (defn- decode-v1
[^bytes cdata ^long udata-len] [^bytes cdata ^long ulen]
(let [^LZ4FastDecompressor dcp (.fastDecompressor ^LZ4Factory lz4-factory) (let [dcp (.fastDecompressor ^LZ4Factory lz4-factory)
^bytes udata (byte-array udata-len)] udata (byte-array ulen)]
(.decompress dcp cdata 6 udata 0 udata-len) (.decompress ^LZ4FastDecompressor dcp cdata 6 ^bytes udata 0 ulen)
(t/decode udata {:type :json}))) (t/decode udata {:type :json})))
(defn- encode-v2
[data]
(let [data (n/fast-freeze data)
dlen (alength data)
mlen (Zstd/compressBound dlen)
cdata (byte-array mlen)
clen (Zstd/compressByteArray ^bytes cdata 0 mlen
^bytes data 0 dlen
8)]
(with-open [^ByteArrayOutputStream baos (ByteArrayOutputStream. (+ (alength cdata) 2 4))
^DataOutputStream dos (DataOutputStream. baos)]
(.writeShort dos (short 2)) ;; version number
(.writeInt dos (int dlen))
(.write dos ^bytes cdata (int 0) clen)
(.toByteArray baos))))
(defn- decode-v2
[^bytes cdata ^long ulen]
(let [udata (byte-array ulen)]
(Zstd/decompressByteArray ^bytes udata 0 ulen
^bytes cdata 6 (- (alength cdata) 6))
(n/fast-thaw udata)))

View file

@ -9,6 +9,7 @@
(ns app.util.emails (ns app.util.emails
(:require (:require
[app.common.data :as d]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.spec :as us] [app.common.spec :as us]
[app.util.template :as tmpl] [app.util.template :as tmpl]
@ -196,15 +197,17 @@
text (render-email-template-part :txt id context) text (render-email-template-part :txt id context)
html (render-email-template-part :html id context)] html (render-email-template-part :html id context)]
(when (or (not subj) (when (or (not subj)
(not text) (and (not text)
(not html)) (not html)))
(ex/raise :type :internal (ex/raise :type :internal
:code :missing-email-templates)) :code :missing-email-templates))
{:subject subj {:subject subj
:body [{:type "text/plain" :body (d/concat
:content text} [{:type "text/plain"
{:type "text/html" :content text}]
:content html}]})) (when html
[{:type "text/html"
:content html}]))}))
(s/def ::priority #{:high :low}) (s/def ::priority #{:high :low})
(s/def ::to (s/or :sigle ::us/email (s/def ::to (s/or :sigle ::us/email

View file

@ -12,6 +12,7 @@
[app.common.geom.matrix :as gmt] [app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.math :refer [close?]]
[app.common.pages :refer [make-minimal-shape]] [app.common.pages :refer [make-minimal-shape]]
[clojure.test :as t])) [clojure.test :as t]))
@ -32,7 +33,9 @@
:points points))) :points points)))
(defn add-rect-data [shape] (defn add-rect-data [shape]
(let [selrect (gsh/rect->selrect shape) (let [shape (-> shape
(assoc :width 20 :height 20))
selrect (gsh/rect->selrect shape)
points (gsh/rect->points selrect)] points (gsh/rect->points selrect)]
(assoc shape (assoc shape
:selrect selrect :selrect selrect
@ -64,16 +67,16 @@
shape-after (gsh/transform-shape shape-before)] shape-after (gsh/transform-shape shape-before)]
(t/is (not= shape-before shape-after)) (t/is (not= shape-before shape-after))
(t/is (== (get-in shape-before [:selrect :x]) (t/is (close? (get-in shape-before [:selrect :x])
(- 10 (get-in shape-after [:selrect :x])))) (- 10 (get-in shape-after [:selrect :x]))))
(t/is (== (get-in shape-before [:selrect :y]) (t/is (close? (get-in shape-before [:selrect :y])
(+ 10 (get-in shape-after [:selrect :y])))) (+ 10 (get-in shape-after [:selrect :y]))))
(t/is (== (get-in shape-before [:selrect :width]) (t/is (close? (get-in shape-before [:selrect :width])
(get-in shape-after [:selrect :width]))) (get-in shape-after [:selrect :width])))
(t/is (== (get-in shape-before [:selrect :height]) (t/is (close? (get-in shape-before [:selrect :height])
(get-in shape-after [:selrect :height]))))) (get-in shape-after [:selrect :height])))))
:rect :path)) :rect :path))
@ -84,7 +87,7 @@
shape-before (create-test-shape type {:modifiers modifiers}) shape-before (create-test-shape type {:modifiers modifiers})
shape-after (gsh/transform-shape shape-before)] shape-after (gsh/transform-shape shape-before)]
(t/are [prop] (t/are [prop]
(t/is (== (get-in shape-before [:selrect prop]) (t/is (close? (get-in shape-before [:selrect prop])
(get-in shape-after [:selrect prop]))) (get-in shape-after [:selrect prop])))
:x :y :width :height :x1 :y1 :x2 :y2)) :x :y :width :height :x1 :y1 :x2 :y2))
:rect :path)) :rect :path))
@ -98,16 +101,16 @@
shape-after (gsh/transform-shape shape-before)] shape-after (gsh/transform-shape shape-before)]
(t/is (not= shape-before shape-after)) (t/is (not= shape-before shape-after))
(t/is (== (get-in shape-before [:selrect :x]) (t/is (close? (get-in shape-before [:selrect :x])
(get-in shape-after [:selrect :x]))) (get-in shape-after [:selrect :x])))
(t/is (== (get-in shape-before [:selrect :y]) (t/is (close? (get-in shape-before [:selrect :y])
(get-in shape-after [:selrect :y]))) (get-in shape-after [:selrect :y])))
(t/is (== (* 2 (get-in shape-before [:selrect :width])) (t/is (close? (* 2 (get-in shape-before [:selrect :width]))
(get-in shape-after [:selrect :width]))) (get-in shape-after [:selrect :width])))
(t/is (== (* 2 (get-in shape-before [:selrect :height])) (t/is (close? (* 2 (get-in shape-before [:selrect :height]))
(get-in shape-after [:selrect :height])))) (get-in shape-after [:selrect :height]))))
:rect :path)) :rect :path))
@ -119,7 +122,7 @@
shape-before (create-test-shape type {:modifiers modifiers}) shape-before (create-test-shape type {:modifiers modifiers})
shape-after (gsh/transform-shape shape-before)] shape-after (gsh/transform-shape shape-before)]
(t/are [prop] (t/are [prop]
(t/is (== (get-in shape-before [:selrect prop]) (t/is (close? (get-in shape-before [:selrect prop])
(get-in shape-after [:selrect prop]))) (get-in shape-after [:selrect prop])))
:x :y :width :height :x1 :y1 :x2 :y2)) :x :y :width :height :x1 :y1 :x2 :y2))
:rect :path)) :rect :path))
@ -145,13 +148,23 @@
(let [modifiers {:rotation 30} (let [modifiers {:rotation 30}
shape-before (create-test-shape type {:modifiers modifiers}) shape-before (create-test-shape type {:modifiers modifiers})
shape-after (gsh/transform-shape shape-before)] shape-after (gsh/transform-shape shape-before)]
(t/is (not= shape-before shape-after)) (t/is (not= shape-before shape-after))
(t/is (not (== (get-in shape-before [:selrect :x]) ;; Selrect won't change with a rotation, but points will
(get-in shape-after [:selrect :x])))) (t/is (close? (get-in shape-before [:selrect :x])
(get-in shape-after [:selrect :x])))
(t/is (not (== (get-in shape-before [:selrect :y]) (t/is (close? (get-in shape-before [:selrect :y])
(get-in shape-after [:selrect :y]))))) (get-in shape-after [:selrect :y])))
(t/is (= (count (:points shape-before)) (count (:points shape-after))))
(for [idx (range 0 (count (:point shape-before)))]
(do (t/is (not (close? (get-in shape-before [:points idx :x])
(get-in shape-after [:points idx :x]))))
(t/is (not (close? (get-in shape-before [:points idx :y])
(get-in shape-after [:points idx :y])))))))
:rect :path)) :rect :path))
(t/testing "Transform shape with rotation = 0 should leave equal selrect" (t/testing "Transform shape with rotation = 0 should leave equal selrect"
@ -160,7 +173,7 @@
shape-before (create-test-shape type {:modifiers modifiers}) shape-before (create-test-shape type {:modifiers modifiers})
shape-after (gsh/transform-shape shape-before)] shape-after (gsh/transform-shape shape-before)]
(t/are [prop] (t/are [prop]
(t/is (== (get-in shape-before [:selrect prop]) (t/is (close? (get-in shape-before [:selrect prop])
(get-in shape-after [:selrect prop]))) (get-in shape-after [:selrect prop])))
:x :y :width :height :x1 :y1 :x2 :y2)) :x :y :width :height :x1 :y1 :x2 :y2))
:rect :path)) :rect :path))

View file

@ -360,7 +360,7 @@
(t/is (= [rect-a-id rect-e-id rect-d-id] (t/is (= [rect-a-id rect-e-id rect-d-id]
(get-in objects [group-b-id :shapes])))))) (get-in objects [group-b-id :shapes]))))))
(t/testing "Move elements and delete the empty group" (t/testing "Move all elements from a group"
(let [changes [{:type :mov-objects (let [changes [{:type :mov-objects
:page-id page-id :page-id page-id
:parent-id group-a-id :parent-id group-a-id
@ -368,9 +368,9 @@
res (cp/process-changes data changes)] res (cp/process-changes data changes)]
(let [objects (get-in res [:pages-index page-id :objects])] (let [objects (get-in res [:pages-index page-id :objects])]
(t/is (= [group-a-id rect-e-id] (t/is (= [group-a-id group-b-id rect-e-id]
(get-in objects [frame-a-id :shapes]))) (get-in objects [frame-a-id :shapes])))
(t/is (nil? (get-in objects [group-b-id])))))) (t/is (empty? (get-in objects [group-b-id :shapes]))))))
(t/testing "Move elements to a group with different frame" (t/testing "Move elements to a group with different frame"
(let [changes [{:type :mov-objects (let [changes [{:type :mov-objects
@ -727,10 +727,10 @@
;; After ;; After
(t/is (= [shape-2-id shape-1-id shape-3-id shape-4-id] (t/is (= [shape-2-id shape-1-id shape-3-id shape-4-id group-1-id]
(get-in res [:pages-index page-id :objects cp/root :shapes]))) (get-in res [:pages-index page-id :objects cp/root :shapes])))
(t/is (= nil (t/is (not= nil
(get-in res [:pages-index page-id :objects group-1-id]))) (get-in res [:pages-index page-id :objects group-1-id])))
)) ))

View file

@ -134,3 +134,9 @@
(th-eq m1f m2f)))) (th-eq m1f m2f))))
(defmethod pp/simple-dispatch Matrix [obj] (pr obj)) (defmethod pp/simple-dispatch Matrix [obj] (pr obj))
(defn transform-in [pt mtx]
(-> (matrix)
(translate pt)
(multiply mtx)
(translate (gpt/negate pt))))

View file

@ -11,42 +11,36 @@
;; --- Proportions ;; --- Proportions
(declare assign-proportions-path)
(declare assign-proportions-rect)
(defn assign-proportions (defn assign-proportions
[{:keys [type] :as shape}] [shape]
(case type (let [{:keys [width height]} (:selrect shape)]
:path (assign-proportions-path shape) (assoc shape :proportion (/ width height))))
(assign-proportions-rect shape)))
(defn- assign-proportions-rect
[{:keys [width height] :as shape}]
(assoc shape :proportion (/ width height)))
;; --- Setup Proportions ;; --- Setup Proportions
(declare setup-proportions-const)
(declare setup-proportions-image)
(defn setup-proportions
[shape]
(case (:type shape)
:icon (setup-proportions-image shape)
:image (setup-proportions-image shape)
:text shape
(setup-proportions-const shape)))
(defn setup-proportions-image (defn setup-proportions-image
[{:keys [metadata] :as shape}] [{:keys [metadata] :as shape}]
(let [{:keys [width height]} metadata] (let [{:keys [width height]} metadata]
(assoc shape (assoc shape
:proportion (/ width height) :proportion (/ width height)
:proportion-lock false))) :proportion-lock true)))
(defn setup-proportions-svg
[{:keys [width height] :as shape}]
(assoc shape
:proportion (/ width height)
:proportion-lock true))
(defn setup-proportions-const (defn setup-proportions-const
[shape] [shape]
(assoc shape (assoc shape
:proportion 1 :proportion 1
:proportion-lock false)) :proportion-lock false))
(defn setup-proportions
[shape]
(case (:type shape)
:svg-raw (setup-proportions-svg shape)
:image (setup-proportions-image shape)
:text shape
(setup-proportions-const shape)))

View file

@ -43,10 +43,13 @@
(let [shape-center (or (gco/center-shape shape) (let [shape-center (or (gco/center-shape shape)
(gpt/point 0 0))] (gpt/point 0 0))]
(inverse-transform-matrix shape shape-center))) (inverse-transform-matrix shape shape-center)))
([shape center] ([{:keys [flip-x flip-y] :as shape} center]
(let [] (let []
(-> (gmt/matrix) (-> (gmt/matrix)
(gmt/translate center) (gmt/translate center)
(cond->
flip-x (gmt/scale (gpt/point -1 1))
flip-y (gmt/scale (gpt/point 1 -1)))
(gmt/multiply (:transform-inverse shape (gmt/matrix))) (gmt/multiply (:transform-inverse shape (gmt/matrix)))
(gmt/translate (gpt/negate center)))))) (gmt/translate (gpt/negate center))))))
@ -203,29 +206,7 @@
(gmt/rotate (- rotation-angle)))] (gmt/rotate (- rotation-angle)))]
[stretch-matrix stretch-matrix-inverse])) [stretch-matrix stretch-matrix-inverse]))
(defn apply-transform
(defn apply-transform-path
[shape transform]
(let [content (gpa/transform-content (:content shape) transform)
;; Calculate the new selrect by "unrotate" the shape
rotation (modif-rotation shape)
center (gpt/transform (gco/center-shape shape) transform)
content-rotated (gpa/transform-content content (gmt/rotate-matrix (- rotation) center))
selrect (gpa/content->selrect content-rotated)
;; Transform the points
points (-> (:points shape)
(transform-points transform))]
(assoc shape
:content content
:points points
:selrect selrect
:transform (gmt/rotate-matrix rotation)
:transform-inverse (gmt/rotate-matrix (- rotation))
:rotation rotation)))
(defn apply-transform-rect
"Given a new set of points transformed, set up the rectangle so it keeps "Given a new set of points transformed, set up the rectangle so it keeps
its properties. We adjust de x,y,width,height and create a custom transform" its properties. We adjust de x,y,width,height and create a custom transform"
[shape transform] [shape transform]
@ -246,13 +227,21 @@
(:height points-temp-dim)) (:height points-temp-dim))
rect-points (gpr/rect->points rect-shape) rect-points (gpr/rect->points rect-shape)
[matrix matrix-inverse] (calculate-adjust-matrix points-temp rect-points (:flip-x shape) (:flip-y shape))] [matrix matrix-inverse] (calculate-adjust-matrix points-temp rect-points (:flip-x shape) (:flip-y shape))
shape (cond
(= :path (:type shape))
(-> shape
(update :content #(gpa/transform-content % transform)))
:else
(-> shape
(merge rect-shape)
(update :x #(mth/precision % 0))
(update :y #(mth/precision % 0))
(update :width #(mth/precision % 0))
(update :height #(mth/precision % 0))))]
(as-> shape $ (as-> shape $
(merge $ rect-shape)
(update $ :x #(mth/precision % 0))
(update $ :y #(mth/precision % 0))
(update $ :width #(mth/precision % 0))
(update $ :height #(mth/precision % 0))
(update $ :transform #(gmt/multiply (or % (gmt/matrix)) matrix)) (update $ :transform #(gmt/multiply (or % (gmt/matrix)) matrix))
(update $ :transform-inverse #(gmt/multiply matrix-inverse (or % (gmt/matrix)))) (update $ :transform-inverse #(gmt/multiply matrix-inverse (or % (gmt/matrix))))
(assoc $ :points (into [] points)) (assoc $ :points (into [] points))
@ -260,37 +249,6 @@
(update $ :rotation #(mod (+ (or % 0) (update $ :rotation #(mod (+ (or % 0)
(or (get-in $ [:modifiers :rotation]) 0)) 360))))) (or (get-in $ [:modifiers :rotation]) 0)) 360)))))
(defn apply-transform [shape transform]
(let [apply-transform-fn
(case (:type shape)
:path apply-transform-path
apply-transform-rect)]
(apply-transform-fn shape transform)))
(defn transform-gradients [shape modifiers]
(let [angle (d/check-num (get modifiers :rotation))
;; Gradients are represented with unit vectors so its center is 0.5, 0.5
center (gpt/point 0.5 0.5)
transform (gmt/rotate-matrix angle center)
transform-gradient
(fn [{:keys [start-x start-y end-x end-y] :as gradient}]
(let [start-point (gpt/point start-x start-y)
end-point (gpt/point end-x end-y)
{start-x :x start-y :y} (gpt/transform start-point transform)
{end-x :x end-y :y} (gpt/transform end-point transform)]
(assoc gradient
:start-x start-x
:start-y start-y
:end-x end-x
:end-y end-y)))]
(cond-> shape
(:fill-color-gradient shape)
(update :fill-color-gradient transform-gradient)
(:stroke-color-gradient shape)
(update :stroke-color-gradient transform-gradient))))
(defn set-flip [shape modifiers] (defn set-flip [shape modifiers]
(let [rx (get-in modifiers [:resize-vector :x]) (let [rx (get-in modifiers [:resize-vector :x])
ry (get-in modifiers [:resize-vector :y])] ry (get-in modifiers [:resize-vector :y])]
@ -305,12 +263,13 @@
(-> shape (-> shape
(set-flip (:modifiers shape)) (set-flip (:modifiers shape))
(apply-transform transform) (apply-transform transform)
(transform-gradients (:modifiers shape))
(dissoc :modifiers))) (dissoc :modifiers)))
shape))) shape)))
(defn update-group-selrect [group children] (defn update-group-selrect [group children]
(let [shape-center (gco/center-shape group) (let [shape-center (gco/center-shape group)
transform (:transform group (gmt/matrix))
transform-inverse (:transform-inverse group (gmt/matrix))
;; Points for every shape inside the group ;; Points for every shape inside the group
points (->> children (mapcat :points)) points (->> children (mapcat :points))
@ -330,5 +289,10 @@
(-> group (-> group
(assoc :selrect new-selrect) (assoc :selrect new-selrect)
(assoc :points new-points) (assoc :points new-points)
(apply-transform-rect (gmt/matrix)))))
;; We're regenerating the selrect from its children so we
;; need to remove the flip flags
(assoc :flip-x false)
(assoc :flip-y false)
(apply-transform (gmt/matrix)))))

View file

@ -142,3 +142,10 @@
(defn almost-zero? [num] (defn almost-zero? [num]
(< (abs num) 1e-8)) (< (abs num) 1e-8))
(defonce float-equal-precision 0.001)
(defn close?
"Equality for float numbers. Check if the difference is within a range"
[num1 num2]
(<= (abs (- num1 num2)) float-equal-precision))

View file

@ -36,8 +36,20 @@
(when verify? (when verify?
(us/verify ::spec/changes items)) (us/verify ::spec/changes items))
(->> items (let [pages (into #{} (map :page-id) items)
(reduce #(or (process-change %1 %2) %1) data)))) result (->> items
(reduce #(or (process-change %1 %2) %1) data))]
;; Validate result shapes (only on the backend)
#?(:clj
(doseq [page-id pages]
(let [page (get-in result [:pages-index page-id])]
(doseq [[id shape] (:objects page)]
(if-not (= shape (get-in data [:pages-index page-id :objects id]))
;; If object has change verify is correct
(us/verify ::spec/shape shape))))))
result)))
(defmethod process-change :set-option (defmethod process-change :set-option
[data {:keys [page-id option value]}] [data {:keys [page-id option value]}]
@ -94,7 +106,6 @@
(let [update-fn (fn [objects] (let [update-fn (fn [objects]
(if-let [obj (get objects id)] (if-let [obj (get objects id)]
(let [result (reduce process-operation obj operations)] (let [result (reduce process-operation obj operations)]
#?(:clj (us/verify ::spec/shape result))
(assoc objects id result)) (assoc objects id result))
objects))] objects))]
(if page-id (if page-id
@ -142,16 +153,25 @@
(map :id) (map :id)
(distinct)) (distinct))
shapes))) shapes)))
(update-group [group objects] (set-mask-selrect [group children]
(let [children (->> group :shapes (map #(get objects %)))]
(if (:masked-group? group)
(let [mask (first children)] (let [mask (first children)]
(-> group (-> group
(merge (select-keys mask [:selrect :points])) (merge (select-keys mask [:selrect :points]))
(assoc :x (-> mask :selrect :x) (assoc :x (-> mask :selrect :x)
:y (-> mask :selrect :y) :y (-> mask :selrect :y)
:width (-> mask :selrect :width) :width (-> mask :selrect :width)
:height (-> mask :selrect :height)))) :height (-> mask :selrect :height)))))
(update-group [group objects]
(let [children (->> group :shapes (map #(get objects %)))]
(cond
;; If the group is empty we don't make any changes. Should be removed by a later process
(empty? children)
group
(:masked-group? group)
(set-mask-selrect group children)
:else
(gsh/update-group-selrect group children))))] (gsh/update-group-selrect group children))))]
(if page-id (if page-id
@ -206,12 +226,6 @@
pid prev-parent-id pid prev-parent-id
objects objects] objects objects]
(let [obj (get objects pid)] (let [obj (get objects pid)]
(if (and (= 1 (count (:shapes obj)))
(= sid (first (:shapes obj)))
(= :group (:type obj)))
(recur pid
(:parent-id obj)
(dissoc objects pid))
(cond-> objects (cond-> objects
true true
(update-in [pid :shapes] strip-id sid) (update-in [pid :shapes] strip-id sid)
@ -222,7 +236,7 @@
(-> (->
(update-in [pid :touched] (update-in [pid :touched]
cph/set-touched-group :shapes-group) cph/set-touched-group :shapes-group)
(d/dissoc-in [pid :remote-synced?]))))))))) (d/dissoc-in [pid :remote-synced?]))))))))
(update-parent-id [objects id] (update-parent-id [objects id]
(assoc-in objects [id :parent-id] parent-id)) (assoc-in objects [id :parent-id] parent-id))

View file

@ -224,7 +224,9 @@
(defn select-toplevel-shapes (defn select-toplevel-shapes
([objects] (select-toplevel-shapes objects nil)) ([objects] (select-toplevel-shapes objects nil))
([objects {:keys [include-frames?] :or {include-frames? false}}] ([objects {:keys [include-frames? include-frame-children?]
:or {include-frames? false
include-frame-children? true}}]
(let [lookup #(get objects %) (let [lookup #(get objects %)
root (lookup uuid/zero) root (lookup uuid/zero)
root-children (:shapes root) root-children (:shapes root)
@ -241,7 +243,7 @@
(or (not= :frame typ) include-frames?) (or (not= :frame typ) include-frames?)
(d/concat [obj]) (d/concat [obj])
(= :frame typ) (and (= :frame typ) include-frame-children?)
(d/concat (map lookup children))))))] (d/concat (map lookup children))))))]
(reduce lookup-shapes [] root-children)))) (reduce lookup-shapes [] root-children))))

View file

@ -0,0 +1,9 @@
// Frontend configuration
//var penpotPublicURI = "https://penpot.example.com";
//var penpotDemoWarning = <true|false>;
//var penpotAllowDemoUsers = <true|false>;
//var penpotGoogleClientID = "<google-client-id-here>";
//var penpotGitlabClientID = "<gitlab-client-id-here>";
//var penpotGithubClientID = "<github-client-id-here>";
//var penpotLoginWithLDAP = <true|false>;

View file

@ -1,3 +1,90 @@
#!/usr/bin/env bash #!/usr/bin/env bash
log() {
echo "[$(date +%Y-%m-%dT%H:%M:%S%:z)] $*"
}
#########################################
## App Frontend config
#########################################
update_public_uri() {
if [ -n "$PENPOT_PUBLIC_URI" ]; then
log "Updating Public URI: $PENPOT_PUBLIC_URI"
sed -i \
-e "s|^//var penpotPublicURI = \".*\";|var penpotPublicURI = \"$PENPOT_PUBLIC_URI\";|g" \
"$1"
fi
}
update_demo_warning() {
if [ -n "$PENPOT_DEMO_WARNING" ]; then
log "Updating Demo Warning: $PENPOT_DEMO_WARNING"
sed -i \
-e "s|^//var penpotDemoWarning = .*;|var penpotDemoWarning = $PENPOT_DEMO_WARNING;|g" \
"$1"
fi
}
update_allow_demo_users() {
if [ -n "$PENPOT_ALLOW_DEMO_USERS" ]; then
log "Updating Allow Demo Users: $PENPOT_ALLOW_DEMO_USERS"
sed -i \
-e "s|^//var penpotAllowDemoUsers = .*;|var penpotAllowDemoUsers = $PENPOT_ALLOW_DEMO_USERS;|g" \
"$1"
fi
}
update_google_client_id() {
if [ -n "$PENPOT_GOOGLE_CLIENT_ID" ]; then
log "Updating Google Client Id: $PENPOT_GOOGLE_CLIENT_ID"
sed -i \
-e "s|^//var penpotGoogleClientID = \".*\";|var penpotGoogleClientID = \"$PENPOT_GOOGLE_CLIENT_ID\";|g" \
"$1"
fi
}
update_gitlab_client_id() {
if [ -n "$PENPOT_GITLAB_CLIENT_ID" ]; then
log "Updating GitLab Client Id: $PENPOT_GITLAB_CLIENT_ID"
sed -i \
-e "s|^//var penpotGitlabClientID = \".*\";|var penpotGitlabClientID = \"$PENPOT_GITLAB_CLIENT_ID\";|g" \
"$1"
fi
}
update_github_client_id() {
if [ -n "$PENPOT_GITHUB_CLIENT_ID" ]; then
log "Updating GitHub Client Id: $PENPOT_GITHUB_CLIENT_ID"
sed -i \
-e "s|^//var penpotGithubClientID = \".*\";|var penpotGithubClientID = \"$PENPOT_GITHUB_CLIENT_ID\";|g" \
"$1"
fi
}
update_login_with_ldap() {
if [ -n "$PENPOT_LOGIN_WITH_LDAP" ]; then
log "Updating Login with LDAP: $PENPOT_LOGIN_WITH_LDAP"
sed -i \
-e "s|^//var penpotLoginWithLDAP = .*;|var penpotLoginWithLDAP = $PENPOT_LOGIN_WITH_LDAP;|g" \
"$1"
fi
}
update_public_uri /var/www/app/js/config.js
update_demo_warning /var/www/app/js/config.js
update_allow_demo_users /var/www/app/js/config.js
update_google_client_id /var/www/app/js/config.js
update_gitlab_client_id /var/www/app/js/config.js
update_github_client_id /var/www/app/js/config.js
update_login_with_ldap /var/www/app/js/config.js
exec "$@"; exec "$@";

View file

@ -7,16 +7,16 @@ The simplest approach is using docker and docker-compose.
## Install Docker ## ## Install Docker ##
Skip this section if you alreasdy have docker installed, up and running. Skip this section if you already have docker installed, up and running.
You can install docker and its dependencies from your distribution You can install docker and its dependencies from your distribution
repositores with: repository with:
```bash ```bash
sudo apt-get install docker docker-compose sudo apt-get install docker docker-compose
``` ```
Or follow installation instructions from docker.com; (for debian Or follow installation instructions from docker.com; (for Debian
https://docs.docker.com/engine/install/debian/). https://docs.docker.com/engine/install/debian/).
Ensure that the docker is started and optionally enable it to start Ensure that the docker is started and optionally enable it to start
@ -33,7 +33,7 @@ And finally, add your user to the docker group:
sudo usermod -aG docker $USER sudo usermod -aG docker $USER
``` ```
This will make use the docker without `sudo` command all the time. This will make use of the docker without `sudo` command all the time.
NOTE: probably you will need to re-login again to make this change NOTE: probably you will need to re-login again to make this change
take effect. take effect.
@ -58,5 +58,5 @@ docker-compose -p penpot -f docker-compose.yaml up
The docker compose file contains the essential configuration for The docker compose file contains the essential configuration for
getting the application running, and many essential configurations getting the application running, and many essential configurations
already explained in comments. All other configuration options are already explained in the comments. All other configuration options are
explained in [configuration guide](./05-Configuration-Guide.md). explained in [configuration guide](./05-Configuration-Guide.md).

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,7 @@
min-width: 25px; min-width: 25px;
padding: 0 1rem; padding: 0 1rem;
transition: all .4s; transition: all .4s;
text-decoration: none !important;
svg { svg {
height: 15px; height: 15px;
width: 15px; width: 15px;

View file

@ -38,6 +38,10 @@
width: 18%; width: 18%;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
.grid-item-th {
text-align: initial;
}
&.placeholder { &.placeholder {
min-width: 115px; min-width: 115px;
max-width: 115px; max-width: 115px;

View file

@ -102,6 +102,14 @@ textarea {
text-decoration: underline; text-decoration: underline;
} }
p {
color: $color-gray-60;
}
hr {
border-color: $color-gray-20;
}
.links { .links {
display: flex; display: flex;
font-size: $fs14; font-size: $fs14;
@ -131,7 +139,8 @@ textarea {
flex-direction: column; flex-direction: column;
position: relative; position: relative;
input { input,
textarea {
background-color: $color-white; background-color: $color-white;
border-radius: 2px; border-radius: 2px;
border: 1px solid $color-gray-20; border: 1px solid $color-gray-20;
@ -143,6 +152,13 @@ textarea {
width: 100%; width: 100%;
} }
textarea {
height: auto;
font-size: $fs14;
font-family: "worksans", sans-serif;
padding-top: 20px;
}
// Makes the background for autocomplete white // Makes the background for autocomplete white
input:-webkit-autofill, input:-webkit-autofill,
input:-webkit-autofill:hover, input:-webkit-autofill:hover,

View file

@ -67,6 +67,7 @@
(def default-language "en") (def default-language "en")
(def demo-warning (obj/get global "penpotDemoWarning" false)) (def demo-warning (obj/get global "penpotDemoWarning" false))
(def feedback-enabled (obj/get global "penpotFeedbackEnabled" false))
(def allow-demo-users (obj/get global "penpotAllowDemoUsers" true)) (def allow-demo-users (obj/get global "penpotAllowDemoUsers" true))
(def google-client-id (obj/get global "penpotGoogleClientID" nil)) (def google-client-id (obj/get global "penpotGoogleClientID" nil))
(def gitlab-client-id (obj/get global "penpotGitlabClientID" nil)) (def gitlab-client-id (obj/get global "penpotGitlabClientID" nil))

View file

@ -417,3 +417,12 @@
(update [_ state] (update [_ state]
(assoc-in state [:viewer-local :hover] (when hover? id))))) (assoc-in state [:viewer-local :hover] (when hover? id)))))
(defn go-to-dashboard
([] (go-to-dashboard nil))
([{:keys [team-id]}]
(ptk/reify ::go-to-dashboard
ptk/WatchEvent
(watch [_ state stream]
(let [team-id (or team-id (get-in state [:viewer-data :project :team-id]))]
(rx/of (rt/nav :dashboard-projects {:team-id team-id})))))))

View file

@ -808,6 +808,168 @@
;; --- Change Shape Order (D&D Ordering) ;; --- Change Shape Order (D&D Ordering)
(defn relocate-shapes-changes [objects parents parent-id page-id to-index ids groups-to-delete groups-to-unmask shapes-to-detach shapes-to-reroot shapes-to-deroot]
(let [;; Changes to the shapes that are being move
r-mov-change
[{:type :mov-objects
:parent-id parent-id
:page-id page-id
:index to-index
:shapes (vec (reverse ids))}]
u-mov-change
(map (fn [id]
(let [obj (get objects id)]
{:type :mov-objects
:parent-id (:parent-id obj)
:page-id page-id
:index (cp/position-on-parent id objects)
:shapes [id]}))
(reverse ids))
;; Changes deleting empty groups
r-del-change
(map (fn [group-id]
{:type :del-obj
:page-id page-id
:id group-id})
groups-to-delete)
u-del-change
(d/concat
[]
;; Create the groups
(map (fn [group-id]
(let [group (get objects group-id)]
{:type :add-obj
:page-id page-id
:parent-id parent-id
:frame-id (:frame-id group)
:id group-id
:obj (-> group
(assoc :shapes []))}))
groups-to-delete)
;; Creates the hierarchy
(map (fn [group-id]
(let [group (get objects group-id)]
{:type :mov-objects
:page-id page-id
:parent-id (:id group)
:shapes (:shapes group)}))
groups-to-delete))
;; Changes removing the masks from the groups without mask shape
r-mask-change
(map (fn [group-id]
{:type :mod-obj
:page-id page-id
:id group-id
:operations [{:type :set
:attr :masked-group?
:val false}]})
groups-to-unmask)
u-mask-change
(map (fn [group-id]
(let [group (get objects group-id)]
{:type :mod-obj
:page-id page-id
:id group-id
:operations [{:type :set
:attr :masked-group?
:val (:masked-group? group)}]}))
groups-to-unmask)
;; Changes to the components metadata
detach-keys [:component-id :component-file :component-root? :remote-synced? :shape-ref :touched]
r-detach-change
(map (fn [id]
{:type :mod-obj
:page-id page-id
:id id
:operations (mapv #(hash-map :type :set :attr % :val nil) detach-keys)})
shapes-to-detach)
u-detach-change
(map (fn [id]
(let [obj (get objects id)]
{:type :mod-obj
:page-id page-id
:id id
:operations (mapv #(hash-map :type :set :attr % :val (get obj %)) detach-keys)}))
shapes-to-detach)
r-deroot-change
(map (fn [id]
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set
:attr :component-root?
:val nil}]})
shapes-to-deroot)
u-deroot-change
(map (fn [id]
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set
:attr :component-root?
:val true}]})
shapes-to-deroot)
r-reroot-change
(map (fn [id]
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set
:attr :component-root?
:val true}]})
shapes-to-reroot)
u-reroot-change
(map (fn [id]
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set
:attr :component-root?
:val nil}]})
shapes-to-reroot)
r-reg-change
[{:type :reg-objects
:page-id page-id
:shapes (vec parents)}]
u-reg-change
[{:type :reg-objects
:page-id page-id
:shapes (vec parents)}]
rchanges (d/concat []
r-mov-change
r-del-change
r-mask-change
r-detach-change
r-deroot-change
r-reroot-change
r-reg-change)
uchanges (d/concat []
u-del-change
u-reroot-change
u-deroot-change
u-detach-change
u-mask-change
u-mov-change
u-reg-change)]
[rchanges uchanges]))
(defn relocate-shapes (defn relocate-shapes
[ids parent-id to-index] [ids parent-id to-index]
(us/verify (s/coll-of ::us/uuid) ids) (us/verify (s/coll-of ::us/uuid) ids)
@ -826,13 +988,37 @@
;; If we try to move a parent into a child we remove it ;; If we try to move a parent into a child we remove it
ids (filter #(not (cp/is-parent? objects parent-id %)) ids) ids (filter #(not (cp/is-parent? objects parent-id %)) ids)
parents (loop [res #{parent-id} parents (reduce (fn [result id]
ids (seq ids)] (conj result (cp/get-parent id objects)))
(if (nil? ids) #{parent-id} ids)
(vec res)
(recur groups-to-delete
(conj res (cp/get-parent (first ids) objects)) (loop [current-id (first parents)
(next ids)))) to-check (rest parents)
removed-id? (set ids)
result #{}]
(if-not current-id
;; Base case, no next element
result
(let [group (get objects current-id)]
(if (and (not= uuid/zero current-id)
(not= current-id parent-id)
(empty? (remove removed-id? (:shapes group))))
;; Adds group to the remove and check its parent
(let [to-check (d/concat [] to-check [(cp/get-parent current-id objects)]) ]
(recur (first to-check)
(rest to-check)
(conj removed-id? current-id)
(conj result current-id)))
;; otherwise recur
(recur (first to-check)
(rest to-check)
removed-id?
result)))))
groups-to-unmask groups-to-unmask
(reduce (fn [group-ids id] (reduce (fn [group-ids id]
@ -849,6 +1035,10 @@
#{} #{}
ids) ids)
;; Sets the correct components metadata for the moved shapes
;; `shapes-to-detach` Detach from a component instance a shape that was inside a component and is moved outside
;; `shapes-to-deroot` Removes the root flag from a component instance moved inside another component
;; `shapes-to-reroot` Adds a root flag when a nested component instance is moved outside
[shapes-to-detach shapes-to-deroot shapes-to-reroot] [shapes-to-detach shapes-to-deroot shapes-to-reroot]
(reduce (fn [[shapes-to-detach shapes-to-deroot shapes-to-reroot] id] (reduce (fn [[shapes-to-detach shapes-to-deroot shapes-to-reroot] id]
(let [shape (get objects id) (let [shape (get objects id)
@ -876,131 +1066,18 @@
[[] [] []] [[] [] []]
ids) ids)
rchanges (d/concat [rchanges uchanges] (relocate-shapes-changes objects
[{:type :mov-objects parents
:parent-id parent-id parent-id
:page-id page-id page-id
:index to-index to-index
:shapes (vec (reverse ids))} ids
{:type :reg-objects groups-to-delete
:page-id page-id groups-to-unmask
:shapes parents}] shapes-to-detach
(map (fn [group-id] shapes-to-reroot
{:type :mod-obj shapes-to-deroot)]
:page-id page-id (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})
:id group-id
:operations [{:type :set
:attr :masked-group?
:val false}]})
groups-to-unmask)
(map (fn [id]
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set
:attr :component-id
:val nil}
{:type :set
:attr :component-file
:val nil}
{:type :set
:attr :component-root?
:val nil}
{:type :set
:attr :remote-synced?
:val nil}
{:type :set
:attr :shape-ref
:val nil}
{:type :set
:attr :touched
:val nil}]})
shapes-to-detach)
(map (fn [id]
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set
:attr :component-root?
:val nil}]})
shapes-to-deroot)
(map (fn [id]
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set
:attr :component-root?
:val true}]})
shapes-to-reroot))
uchanges (d/concat
(reduce (fn [res id]
(let [obj (get objects id)]
(conj res
{:type :mov-objects
:parent-id (:parent-id obj)
:page-id page-id
:index (cp/position-on-parent id objects)
:shapes [id]})))
[] (reverse ids))
[{:type :reg-objects
:page-id page-id
:shapes parents}]
(map (fn [group-id]
{:type :mod-obj
:page-id page-id
:id group-id
:operations [{:type :set
:attr :masked-group?
:val true}]})
groups-to-unmask)
(map (fn [id]
(let [obj (get objects id)]
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set
:attr :component-id
:val (:component-id obj)}
{:type :set
:attr :component-file
:val (:component-file obj)}
{:type :set
:attr :component-root?
:val (:component-root? obj)}
{:type :set
:attr :remote-synced?
:val (:remote-synced? obj)}
{:type :set
:attr :shape-ref
:val (:shape-ref obj)}
{:type :set
:attr :touched
:val (:touched obj)}]}))
shapes-to-detach)
(map (fn [id]
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set
:attr :component-root?
:val true}]})
shapes-to-deroot)
(map (fn [id]
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set
:attr :component-root?
:val nil}]})
shapes-to-reroot))]
;; (println "================ rchanges")
;; (cljs.pprint/pprint rchanges)
;; (println "================ uchanges")
;; (cljs.pprint/pprint uchanges)
(rx/of (dwc/commit-changes rchanges uchanges
{:commit-local? true})
(dwc/expand-collapse parent-id)))))) (dwc/expand-collapse parent-id))))))
(defn relocate-selected-shapes (defn relocate-selected-shapes
@ -1404,11 +1481,16 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ state stream] (watch [_ state stream]
(try (try
(let [paste-data (wapi/read-from-paste-event event) (let [objects (dwc/lookup-page-objects state)
paste-data (wapi/read-from-paste-event event)
image-data (wapi/extract-images paste-data) image-data (wapi/extract-images paste-data)
text-data (wapi/extract-text paste-data) text-data (wapi/extract-text paste-data)
decoded-data (and (t/transit? text-data) decoded-data (and (t/transit? text-data)
(t/decode text-data))] (t/decode text-data))
edit-id (get-in state [:workspace-local :edition])
is-editing-text? (and edit-id (= :text (get-in objects [edit-id :type])))]
(cond (cond
(seq image-data) (seq image-data)
(rx/from (map paste-image image-data)) (rx/from (map paste-image image-data))
@ -1418,7 +1500,9 @@
(rx/filter #(= :copied-shapes (:type %))) (rx/filter #(= :copied-shapes (:type %)))
(rx/map #(paste-shape % in-viewport?))) (rx/map #(paste-shape % in-viewport?)))
(string? text-data) ;; Some paste events can be fired while we're editing a text
;; we forbid that scenario so the default behaviour is executed
(and (string? text-data) (not is-editing-text?))
(rx/of (paste-text text-data)) (rx/of (paste-text text-data))
:else :else
@ -1722,6 +1806,8 @@
(d/export dwt/set-modifiers) (d/export dwt/set-modifiers)
(d/export dwt/apply-modifiers) (d/export dwt/apply-modifiers)
(d/export dwt/update-dimensions) (d/export dwt/update-dimensions)
(d/export dwt/flip-horizontal-selected)
(d/export dwt/flip-vertical-selected)
;; Persistence ;; Persistence

View file

@ -198,7 +198,7 @@
(defn retrieve-used-names (defn retrieve-used-names
[objects] [objects]
(into #{} (map :name) (vals objects))) (into #{} (comp (map :name) (remove nil?)) (vals objects)))
(defn generate-unique-name (defn generate-unique-name

View file

@ -90,12 +90,15 @@
path))) path)))
(defn- points->components [shape content] (defn- points->components [shape content]
(let [rotation (:rotation shape 0) (let [transform (:transform shape)
transform-inverse (:transform-inverse shape)
center (gsh/center-shape shape) center (gsh/center-shape shape)
content-rotated (gsh/transform-content content (gmt/rotate-matrix (- rotation) center)) base-content (gsh/transform-content
content
(gmt/transform-in center transform-inverse))
;; Calculates the new selrect with points given the old center ;; Calculates the new selrect with points given the old center
points (-> (gsh/content->selrect content-rotated) points (-> (gsh/content->selrect base-content)
(gsh/rect->points) (gsh/rect->points)
(gsh/transform-points center (:transform shape (gmt/matrix)))) (gsh/transform-points center (:transform shape (gmt/matrix))))

View file

@ -22,7 +22,7 @@
(defonce ^:private default-square-params (defonce ^:private default-square-params
{:size 16 {:size 16
:color {:color "#59B9E2" :color {:color "#59B9E2"
:opacity 0.2}}) :opacity 0.4}})
(defonce ^:private default-layout-params (defonce ^:private default-layout-params
{:size 12 {:size 12

View file

@ -319,7 +319,7 @@
(defn instantiate-component (defn instantiate-component
"Create a new shape in the current page, from the component with the given id "Create a new shape in the current page, from the component with the given id
in the given file library / current file library." in the given file library. Then selects the newly created instance."
[file-id component-id position] [file-id component-id position]
(us/assert ::us/uuid file-id) (us/assert ::us/uuid file-id)
(us/assert ::us/uuid component-id) (us/assert ::us/uuid component-id)

View file

@ -626,6 +626,8 @@
(contains? (:touched shape-inst) (contains? (:touched shape-inst)
:shapes-group)) :shapes-group))
(add-shape-to-instance child-master (add-shape-to-instance child-master
(d/index-of children-master
child-master)
component component
container container
root-inst root-inst
@ -649,11 +651,11 @@
reset? reset?
initial-root?))) initial-root?)))
moved (fn [shape-inst shape-master] moved (fn [child-inst child-master]
(move-shape (move-shape
shape-inst child-inst
(d/index-of children-inst shape-inst) (d/index-of children-inst child-inst)
(d/index-of children-master shape-master) (d/index-of children-master child-master)
container container
omit-touched?)) omit-touched?))
@ -742,6 +744,8 @@
only-inst (fn [child-inst] only-inst (fn [child-inst]
(add-shape-to-master child-inst (add-shape-to-master child-inst
(d/index-of children-inst
child-inst)
component component
container container
root-inst root-inst
@ -768,11 +772,11 @@
root-master) root-master)
initial-root?))) initial-root?)))
moved (fn [shape-inst shape-master] moved (fn [child-inst child-master]
(move-shape (move-shape
shape-master child-master
(d/index-of children-master shape-master) (d/index-of children-master child-master)
(d/index-of children-inst shape-inst) (d/index-of children-inst child-inst)
component-container component-container
false)) false))
@ -863,7 +867,7 @@
(concat-changes (moved-cb child-inst' child-master)))))))))))) (concat-changes (moved-cb child-inst' child-master))))))))))))
(defn- add-shape-to-instance (defn- add-shape-to-instance
[component-shape component container root-instance root-master omit-touched? set-remote-synced?] [component-shape index component container root-instance root-master omit-touched? set-remote-synced?]
(log/info :msg (str "ADD [P] " (:name component-shape))) (log/info :msg (str "ADD [P] " (:name component-shape)))
(let [component-parent-shape (cp/get-shape component (:parent-id component-shape)) (let [component-parent-shape (cp/get-shape component (:parent-id component-shape))
parent-shape (d/seek #(cp/is-master-of component-parent-shape %) parent-shape (d/seek #(cp/is-master-of component-parent-shape %)
@ -904,6 +908,7 @@
(as-> {:type :add-obj (as-> {:type :add-obj
:id (:id shape') :id (:id shape')
:parent-id (:parent-id shape') :parent-id (:parent-id shape')
:index index
:ignore-touched true :ignore-touched true
:obj shape'} $ :obj shape'} $
(cond-> $ (cond-> $
@ -929,7 +934,7 @@
[rchanges uchanges]))) [rchanges uchanges])))
(defn- add-shape-to-master (defn- add-shape-to-master
[shape component page root-instance root-master] [shape index component page root-instance root-master]
(log/info :msg (str "ADD [C] " (:name shape))) (log/info :msg (str "ADD [C] " (:name shape)))
(let [parent-shape (cp/get-shape page (:parent-id shape)) (let [parent-shape (cp/get-shape page (:parent-id shape))
component-parent-shape (d/seek #(cp/is-master-of % parent-shape) component-parent-shape (d/seek #(cp/is-master-of % parent-shape)
@ -963,6 +968,7 @@
:id (:id shape') :id (:id shape')
:component-id (:id component) :component-id (:id component)
:parent-id (:parent-id shape') :parent-id (:parent-id shape')
:index index
:ignore-touched true :ignore-touched true
:obj shape'}) :obj shape'})
new-shapes) new-shapes)

View file

@ -200,13 +200,16 @@
(ptk/reify ::handle-file-change (ptk/reify ::handle-file-change
ptk/WatchEvent ptk/WatchEvent
(watch [_ state stream] (watch [_ state stream]
(let [page-ids (into #{} (comp (map :page-id) (let [changes-by-pages (group-by :page-id changes)
(filter identity)) process-page-changes
changes)] (fn [[page-id changes]]
(dwc/update-indices page-id changes))]
(rx/merge (rx/merge
(rx/of (dwp/shapes-changes-persisted file-id msg)) (rx/of (dwp/shapes-changes-persisted file-id msg))
(when (seq page-ids)
(rx/from (map dwc/update-indices page-ids changes)))))))) (when-not (empty? changes-by-pages)
(rx/from (map process-page-changes changes-by-pages))))))))
(s/def ::library-change-event (s/def ::library-change-event
(s/keys :req-un [::type (s/keys :req-un [::type

View file

@ -417,13 +417,22 @@
(defn- handle-upload-error [on-error stream] (defn- handle-upload-error [on-error stream]
(->> stream (->> stream
(rx/catch (rx/catch
(fn on-error [error] (fn on-error* [error]
(if (ex/ex-info? error) (if (ex/ex-info? error)
(on-error (ex-data error)) (on-error* (ex-data error))
(cond (cond
(= (:code error) :invalid-svg-file)
(rx/of (dm/error (tr "errors.media-type-not-allowed")))
(= (:code error) :media-type-not-allowed) (= (:code error) :media-type-not-allowed)
(rx/of (dm/error (tr "errors.media-type-not-allowed"))) (rx/of (dm/error (tr "errors.media-type-not-allowed")))
(= (:code error) :ubable-to-access-to-url)
(rx/of (dm/error (tr "errors.media-type-not-allowed")))
(= (:code error) :invalid-image)
(rx/of (dm/error (tr "errors.media-type-not-allowed")))
(= (:code error) :media-too-large) (= (:code error) :media-too-large)
(rx/of (dm/error (tr "errors.media-too-large"))) (rx/of (dm/error (tr "errors.media-too-large")))

View file

@ -164,11 +164,10 @@
(not= (:id common-frame-id) uuid/zero)) (not= (:id common-frame-id) uuid/zero))
(-> (get objects common-frame-id) (-> (get objects common-frame-id)
:shapes) :shapes)
(let [frames (cp/select-frames objects)] (->> (cp/select-toplevel-shapes objects
(->> (if (seq frames) {:include-frames? true
frames :include-frame-children? false})
(cp/select-toplevel-shapes objects)) (map :id))))
(map :id)))))
is-not-blocked (fn [shape-id] (not (get-in state [:workspace-data is-not-blocked (fn [shape-id] (not (get-in state [:workspace-data
:pages-index page-id :pages-index page-id

View file

@ -108,6 +108,14 @@
:command (ds/c-mod "k") :command (ds/c-mod "k")
:fn #(st/emit! dwl/add-component)} :fn #(st/emit! dwl/add-component)}
:flip-vertical {:tooltip (ds/shift "V")
:command "shift+v"
:fn #(st/emit! (dw/flip-vertical-selected))}
:flip-horizontal {:tooltip (ds/shift "V")
:command "shift+h"
:fn #(st/emit! (dw/flip-horizontal-selected))}
:reset-zoom {:tooltip (ds/shift "0") :reset-zoom {:tooltip (ds/shift "0")
:command "shift+0" :command "shift+0"
:fn #(st/emit! dw/reset-zoom)} :fn #(st/emit! dw/reset-zoom)}

View file

@ -249,7 +249,7 @@
(assoc :overflow-text true) (assoc :overflow-text true)
(and (= :fixed grow-type) overflow-text (<= new-height shape-height)) (and (= :fixed grow-type) overflow-text (<= new-height shape-height))
(assoc :overflow-text true) (assoc :overflow-text false)
(and (not-changed? shape-width new-width) (= grow-type :auto-width)) (and (not-changed? shape-width new-width) (= grow-type :auto-width))
(-> (assoc :modifiers modifier-width) (-> (assoc :modifiers modifier-width)

View file

@ -82,8 +82,6 @@
{:keys [rotation]} shape {:keys [rotation]} shape
shapev (-> (gpt/point width height)) shapev (-> (gpt/point width height))
rotation (if (= :path (:type shape)) 0 rotation)
;; Vector modifiers depending on the handler ;; Vector modifiers depending on the handler
handler-modif (let [[x y] (handler-modifiers handler)] (gpt/point x y)) handler-modif (let [[x y] (handler-modifiers handler)] (gpt/point x y))
@ -125,15 +123,7 @@
;; lock flag that can be activated on element options. ;; lock flag that can be activated on element options.
(normalize-proportion-lock [[point shift?]] (normalize-proportion-lock [[point shift?]]
(let [proportion-lock? (:proportion-lock shape)] (let [proportion-lock? (:proportion-lock shape)]
[point (or proportion-lock? shift?)])) [point (or proportion-lock? shift?)]))]
;; Applies alginment to point if it is currently
;; activated on the current workspace
;; (apply-grid-alignment [point]
;; (if @refs/selected-alignment
;; (uwrk/align-point point)
;; (rx/of point)))
]
(reify (reify
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
@ -142,8 +132,7 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ state stream] (watch [_ state stream]
(let [current-pointer @ms/mouse-position (let [initial-position @ms/mouse-position
initial-position (merge current-pointer initial)
stoper (rx/filter ms/mouse-up? stream) stoper (rx/filter ms/mouse-up? stream)
layout (:workspace-layout state) layout (:workspace-layout state)
page-id (:current-page-id state) page-id (:current-page-id state)
@ -541,3 +530,37 @@
objects (dwc/lookup-page-objects state page-id) objects (dwc/lookup-page-objects state page-id)
ids (d/concat [] ids (mapcat #(cp/get-children % objects) ids))] ids (d/concat [] ids (mapcat #(cp/get-children % objects) ids))]
(rx/of (apply-modifiers ids)))))) (rx/of (apply-modifiers ids))))))
(defn flip-horizontal-selected []
(ptk/reify ::flip-horizontal-selected
ptk/WatchEvent
(watch [_ state stream]
(let [objects (dwc/lookup-page-objects state)
selected (get-in state [:workspace-local :selected])
shapes (map #(get objects %) selected)
selrect (gsh/selection-rect (->> shapes (map gsh/transform-shape)))
origin (gpt/point (:x selrect) (+ (:y selrect) (/ (:height selrect) 2)))]
(rx/of (set-modifiers selected
{:resize-vector (gpt/point -1.0 1.0)
:resize-origin origin
:displacement (gmt/translate-matrix (gpt/point (- (:width selrect)) 0))}
false)
(apply-modifiers selected))))))
(defn flip-vertical-selected []
(ptk/reify ::flip-vertical-selected
ptk/WatchEvent
(watch [_ state stream]
(let [objects (dwc/lookup-page-objects state)
selected (get-in state [:workspace-local :selected])
shapes (map #(get objects %) selected)
selrect (gsh/selection-rect (->> shapes (map gsh/transform-shape)))
origin (gpt/point (+ (:x selrect) (/ (:width selrect) 2)) (:y selrect))]
(rx/of (set-modifiers selected
{:resize-vector (gpt/point 1.0 -1.0)
:resize-origin origin
:displacement (gmt/translate-matrix (gpt/point 0 (- (:height selrect))))}
false)
(apply-modifiers selected))))))

View file

@ -96,6 +96,9 @@
(def current-hover (def current-hover
(l/derived :hover workspace-local)) (l/derived :hover workspace-local))
(def editors
(l/derived :editors workspace-local))
(def workspace-layout (def workspace-layout
(l/derived :workspace-layout st/state)) (l/derived :workspace-layout st/state))

View file

@ -69,6 +69,7 @@
["/settings" ["/settings"
["/profile" :settings-profile] ["/profile" :settings-profile]
["/password" :settings-password] ["/password" :settings-password]
["/feedback" :settings-feedback]
["/options" :settings-options]] ["/options" :settings-options]]
["/view/:file-id/:page-id" ["/view/:file-id/:page-id"
@ -121,7 +122,8 @@
(:settings-profile (:settings-profile
:settings-password :settings-password
:settings-options) :settings-options
:settings-feedback)
[:& settings/settings {:route route}] [:& settings/settings {:route route}]
:debug-icons-preview :debug-icons-preview

View file

@ -37,7 +37,9 @@
(dom/prevent-default event) (dom/prevent-default event)
(->> (rp/mutation! :login-with-google {}) (->> (rp/mutation! :login-with-google {})
(rx/subs (fn [{:keys [redirect-uri] :as rsp}] (rx/subs (fn [{:keys [redirect-uri] :as rsp}]
(.replace js/location redirect-uri))))) (.replace js/location redirect-uri))
(fn [{:keys [type] :as error}]
(st/emit! (dm/error (tr "errors.google-auth-not-enabled")))))))
(defn- login-with-gitlab (defn- login-with-gitlab
[event] [event]
@ -111,7 +113,7 @@
(when cfg/login-with-ldap (when cfg/login-with-ldap
[:& fm/submit-button [:& fm/submit-button
{:label (tr "auth.login-with-ldap-submit") {:label (tr "auth.login-with-ldap-submit")
:on-click on-submit}])]])) :on-click on-submit-ldap}])]]))
(mf/defc login-page (mf/defc login-page
[] []

View file

@ -15,7 +15,7 @@
[app.main.ui.icons :as i] [app.main.ui.icons :as i]
[app.util.object :as obj] [app.util.object :as obj]
[app.util.forms :as fm] [app.util.forms :as fm]
[app.util.i18n :as i18n :refer [t]] [app.util.i18n :as i18n :refer [t tr]]
["react" :as react] ["react" :as react]
[app.util.dom :as dom])) [app.util.dom :as dom]))
@ -28,7 +28,6 @@
type' (mf/use-state type) type' (mf/use-state type)
focus? (mf/use-state false) focus? (mf/use-state false)
locale (mf/deref i18n/locale)
touched? (get-in @form [:touched name]) touched? (get-in @form [:touched name])
error (get-in @form [:errors name]) error (get-in @form [:errors name])
@ -94,7 +93,59 @@
help-icon']) help-icon'])
(cond (cond
(and touched? (:message error)) (and touched? (:message error))
[:span.error (t locale (:message error))] [:span.error (tr (:message error))]
(string? hint)
[:span.hint hint])]]))
(mf/defc textarea
[{:keys [label disabled name form hint trim] :as props}]
(let [form (or form (mf/use-ctx form-ctx))
type' (mf/use-state type)
focus? (mf/use-state false)
touched? (get-in @form [:touched name])
error (get-in @form [:errors name])
value (get-in @form [:data name] "")
klass (dom/classnames
:focus @focus?
:valid (and touched? (not error))
:invalid (and touched? error)
:disabled disabled
;; :empty (str/empty? value)
)
on-focus #(reset! focus? true)
on-change (fm/on-input-change form name trim)
on-blur
(fn [event]
(reset! focus? false)
(when-not (get-in @form [:touched name])
(swap! form assoc-in [:touched name] true)))
props (-> props
(dissoc :help-icon :form :trim)
(assoc :value value
:on-focus on-focus
:on-blur on-blur
;; :placeholder label
:on-change on-change
:type @type')
(obj/clj->props))]
[:div.custom-input
{:class klass}
[:*
[:label label]
[:> :textarea props]
(cond
(and touched? (:message error))
[:span.error (tr (:message error))]
(string? hint) (string? hint)
[:span.hint hint])]])) [:span.hint hint])]]))

View file

@ -466,10 +466,12 @@
[:li {:on-click (partial on-click (da/logout))} [:li {:on-click (partial on-click (da/logout))}
[:span.icon i/exit] [:span.icon i/exit]
[:span.text (t locale "labels.logout")]] [:span.text (t locale "labels.logout")]]
[:li.feedback {:on-click #(.open js/window "https://github.com/penpot/penpot/discussions" "_blank")}
(when cfg/feedback-enabled
[:li.feedback {:on-click (partial on-click :settings-feedback)}
[:span.icon i/msg-info] [:span.icon i/msg-info]
[:span.text (t locale "labels.feedback")] [:span.text (t locale "labels.give-feedback")]
[:span.primary-badge "ALPHA"]]]]] [:span.primary-badge "ALPHA"]])]]]
(when (and team profile) (when (and team profile)
[:& comments-section {:profile profile [:& comments-section {:profile profile

View file

@ -5,30 +5,31 @@
;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0. ;; defined by the Mozilla Public License, v. 2.0.
;; ;;
;; Copyright (c) 2020 UXBOX Labs SL ;; Copyright (c) 2020-2021 UXBOX Labs SL
(ns app.main.ui.settings (ns app.main.ui.settings
(:require (:require
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.settings.options :refer [options-page]] [app.main.ui.settings.options :refer [options-page]]
[app.main.ui.settings.feedback :refer [feedback-page]]
[app.main.ui.settings.password :refer [password-page]] [app.main.ui.settings.password :refer [password-page]]
[app.main.ui.settings.profile :refer [profile-page]] [app.main.ui.settings.profile :refer [profile-page]]
[app.main.ui.settings.sidebar :refer [sidebar]] [app.main.ui.settings.sidebar :refer [sidebar]]
[app.main.ui.settings.change-email] [app.main.ui.settings.change-email]
[app.main.ui.settings.delete-account] [app.main.ui.settings.delete-account]
[app.util.i18n :as i18n :refer [t]] [app.util.i18n :as i18n :refer [tr]]
[rumext.alpha :as mf])) [rumext.alpha :as mf]))
(mf/defc header (mf/defc header
{::mf/wrap [mf/memo]} {::mf/wrap [mf/memo]}
[{:keys [locale] :as props}] []
(let [logout (constantly nil)] (let [logout (constantly nil)]
[:header.dashboard-header [:header.dashboard-header
[:div.dashboard-title [:div.dashboard-title
[:h1 (t locale "dashboard.your-account-title")]] [:h1 (tr "dashboard.your-account-title")]]
[:a.btn-secondary.btn-small {:on-click logout} [:a.btn-secondary.btn-small {:on-click logout}
(t locale "labels.logout")]])) (tr "labels.logout")]]))
(mf/defc settings (mf/defc settings
[{:keys [route] :as props}] [{:keys [route] :as props}]
@ -41,12 +42,15 @@
:section section}] :section section}]
[:div.dashboard-content [:div.dashboard-content
[:& header {:locale locale}] [:& header]
[:section.dashboard-container [:section.dashboard-container
(case section (case section
:settings-profile :settings-profile
[:& profile-page {:locale locale}] [:& profile-page {:locale locale}]
:settings-feedback
[:& feedback-page]
:settings-password :settings-password
[:& password-page {:locale locale}] [:& password-page {:locale locale}]

View file

@ -0,0 +1,120 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.settings.feedback
"Feedback form."
(:require
[app.common.spec :as us]
[app.main.data.messages :as dm]
[app.main.data.users :as du]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[beicon.core :as rx]
[app.main.repo :as rp]
[cljs.spec.alpha :as s]
[rumext.alpha :as mf]))
(s/def ::content ::us/not-empty-string)
(s/def ::subject ::us/not-empty-string)
(s/def ::feedback-form
(s/keys :req-un [::subject ::content]))
(defn- on-error
[form error]
(st/emit! (dm/error (tr "errors.generic"))))
(defn- on-success
[form]
(st/emit! (dm/success (tr "notifications.profile-saved"))))
(mf/defc options-form
[]
(let [profile (mf/deref refs/profile)
form (fm/use-form :spec ::feedback-form)
loading (mf/use-state false)
on-succes
(mf/use-callback
(mf/deps profile)
(fn [event]
(st/emit! (dm/success (tr "labels.feedback-sent")))
(swap! form assoc :data {} :touched {} :errors {})))
on-error
(mf/use-callback
(mf/deps profile)
(fn [{:keys [code] :as error}]
(reset! loading false)
(if (= code :feedbck-disabled)
(st/emit! (dm/error (tr "labels.feedback-disabled")))
(st/emit! (dm/error (tr "errors.generic"))))))
on-submit
(mf/use-callback
(mf/deps profile)
(fn [form event]
(reset! loading true)
(let [data (:clean-data @form)]
(prn "on-submit" data)
(->> (rp/mutation! :send-profile-feedback data)
(rx/subs on-succes on-error #(reset! loading false))))))]
[:& fm/form {:class "feedback-form"
:on-submit on-submit
:form form}
;; --- Feedback section
[:h2 (tr "feedback.title")]
[:p (tr "feedback.subtitle")]
[:div.fields-row
[:& fm/input {:label (tr "feedback.subject")
:name :subject}]]
[:div.fields-row
[:& fm/textarea
{:label (tr "feedback.description")
:name :content
:rows 5}]]
[:& fm/submit-button
{:label (if @loading (tr "labels.sending") (tr "labels.send"))
:disabled @loading}]
[:hr]
[:h2 (tr "feedback.discussions-title")]
[:p (tr "feedback.discussions-subtitle1")]
[:p (tr "feedback.discussions-subtitle2")]
[:a.btn-secondary.btn-large
{:href "https://github.com/penpot/penpot/discussions" :target "_blank"}
(tr "feedback.discussions-go-to")]
[:hr]
[:h2 "Gitter"]
[:p (tr "feedback.chat-subtitle")]
[:a.btn-secondary.btn-large
{:href "https://gitter.im/penpot/community" :target "_blank"}
(tr "feedback.chat-start")]
]))
(mf/defc feedback-page
[]
[:div.dashboard-settings
[:div.form-container
[:& options-form]]])

View file

@ -9,31 +9,20 @@
(ns app.main.ui.settings.sidebar (ns app.main.ui.settings.sidebar
(:require (:require
[app.common.spec :as us] [app.config :as cfg]
[app.main.data.auth :as da]
[app.main.data.messages :as dm]
[app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.forms :as fm]
[app.main.ui.dashboard.sidebar :refer [profile-section]] [app.main.ui.dashboard.sidebar :refer [profile-section]]
[app.main.ui.icons :as i] [app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [t tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.object :as obj]
[app.util.router :as rt] [app.util.router :as rt]
[app.util.time :as dt]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[goog.functions :as f]
[okulary.core :as l]
[rumext.alpha :as mf])) [rumext.alpha :as mf]))
(mf/defc sidebar-content (mf/defc sidebar-content
[{:keys [locale profile section] :as props}] [{:keys [profile section] :as props}]
(let [profile? (= section :settings-profile) (let [profile? (= section :settings-profile)
password? (= section :settings-password) password? (= section :settings-password)
options? (= section :settings-options) options? (= section :settings-options)
feedback? (= section :settings-feedback)
go-dashboard go-dashboard
(mf/use-callback (mf/use-callback
@ -45,6 +34,11 @@
(mf/deps profile) (mf/deps profile)
(st/emitf (rt/nav :settings-profile))) (st/emitf (rt/nav :settings-profile)))
go-settings-feedback
(mf/use-callback
(mf/deps profile)
(st/emitf (rt/nav :settings-feedback)))
go-settings-password go-settings-password
(mf/use-callback (mf/use-callback
(mf/deps profile) (mf/deps profile)
@ -59,7 +53,7 @@
[:div.sidebar-content-section [:div.sidebar-content-section
[:div.back-to-dashboard {:on-click go-dashboard} [:div.back-to-dashboard {:on-click go-dashboard}
[:span.icon i/arrow-down] [:span.icon i/arrow-down]
[:span.text (t locale "labels.dashboard")]]] [:span.text (tr "labels.dashboard")]]]
[:hr] [:hr]
[:div.sidebar-content-section [:div.sidebar-content-section
@ -67,25 +61,30 @@
[:li {:class (when profile? "current") [:li {:class (when profile? "current")
:on-click go-settings-profile} :on-click go-settings-profile}
i/user i/user
[:span.element-title (t locale "labels.profile")]] [:span.element-title (tr "labels.profile")]]
[:li {:class (when password? "current") [:li {:class (when password? "current")
:on-click go-settings-password} :on-click go-settings-password}
i/lock i/lock
[:span.element-title (t locale "labels.password")]] [:span.element-title (tr "labels.password")]]
[:li {:class (when options? "current") [:li {:class (when options? "current")
:on-click go-settings-options} :on-click go-settings-options}
i/tree i/tree
[:span.element-title (t locale "labels.settings")]]]]])) [:span.element-title (tr "labels.settings")]]
(when cfg/feedback-enabled
[:li {:class (when feedback? "current")
:on-click go-settings-feedback}
i/msg-info
[:span.element-title (tr "labels.give-feedback")]])]]]))
(mf/defc sidebar (mf/defc sidebar
{::mf/wrap [mf/memo]} {::mf/wrap [mf/memo]}
[{:keys [profile locale section]}] [{:keys [profile locale section]}]
[:div.dashboard-sidebar.settings [:div.dashboard-sidebar.settings
[:div.sidebar-inside [:div.sidebar-inside
[:& sidebar-content {:locale locale [:& sidebar-content {:profile profile
:profile profile
:section section}] :section section}]
[:& profile-section {:profile profile [:& profile-section {:profile profile
:locale locale}]]]) :locale locale}]]])

View file

@ -20,15 +20,13 @@
(mf/defc linear-gradient [{:keys [id gradient shape]}] (mf/defc linear-gradient [{:keys [id gradient shape]}]
(let [{:keys [x y width height]} (:selrect shape) (let [{:keys [x y width height]} (:selrect shape)
transform (case (:type shape) transform (when (= :path (:type shape)) (gsh/transform-matrix shape nil (gpt/point 0.5 0.5)))]
:path (gmt/matrix)
(gsh/inverse-transform-matrix shape (gpt/point 0.5 0.5)))]
[:linearGradient {:id id [:linearGradient {:id id
:x1 (:start-x gradient) :x1 (:start-x gradient)
:y1 (:start-y gradient) :y1 (:start-y gradient)
:x2 (:end-x gradient) :x2 (:end-x gradient)
:y2 (:end-y gradient) :y2 (:end-y gradient)
:gradient-transform transform} :gradientTransform transform}
(for [{:keys [offset color opacity]} (:stops gradient)] (for [{:keys [offset color opacity]} (:stops gradient)]
[:stop {:key (str id "-stop-" offset) [:stop {:key (str id "-stop-" offset)
:offset (or offset 0) :offset (or offset 0)
@ -37,9 +35,8 @@
(mf/defc radial-gradient [{:keys [id gradient shape]}] (mf/defc radial-gradient [{:keys [id gradient shape]}]
(let [{:keys [x y width height]} (:selrect shape) (let [{:keys [x y width height]} (:selrect shape)
transform (case (:type shape) center (gsh/center-shape shape)
:path (gmt/matrix) transform (when (= :path (:type shape)) (gsh/transform-matrix shape))]
(gsh/inverse-transform-matrix shape))]
(let [[x y] (if (= (:type shape) :frame) [0 0] [x y]) (let [[x y] (if (= (:type shape) :frame) [0 0] [x y])
translate-vec (gpt/point (+ x (* width (:start-x gradient))) translate-vec (gpt/point (+ x (* width (:start-x gradient)))
(+ y (* height (:start-y gradient)))) (+ y (* height (:start-y gradient))))

View file

@ -20,28 +20,36 @@
([props] (generate-root-styles (clj->js (obj/get props "node")) props)) ([props] (generate-root-styles (clj->js (obj/get props "node")) props))
([data props] ([data props]
(let [valign (obj/get data "vertical-align" "top") (let [valign (obj/get data "vertical-align" "top")
talign (obj/get data "text-align" "flex-start")
shape (obj/get props "shape") shape (obj/get props "shape")
base #js {:height (or (:height shape) "100%") base #js {:height (or (:height shape) "100%")
:width (or (:width shape) "100%") :width (or (:width shape) "100%")}]
:display "flex"}]
(cond-> base (cond-> base
(= valign "top") (obj/set! "alignItems" "flex-start") (= valign "top") (obj/set! "justifyContent" "flex-start")
(= valign "center") (obj/set! "alignItems" "center") (= valign "center") (obj/set! "justifyContent" "center")
(= valign "bottom") (obj/set! "alignItems" "flex-end") (= valign "bottom") (obj/set! "justifyContent" "flex-end")
))))
(= talign "left") (obj/set! "justifyContent" "flex-start")
(= talign "center") (obj/set! "justifyContent" "center")
(= talign "right") (obj/set! "justifyContent" "flex-end")
(= talign "justify") (obj/set! "justifyContent" "stretch")))))
(defn generate-paragraph-set-styles (defn generate-paragraph-set-styles
([props] (generate-paragraph-set-styles nil props)) ([props] (generate-paragraph-set-styles (clj->js (obj/get props "node")) props))
([data props] ([data props]
;; The position absolute is used so the paragraph is "outside" ;; This element will control the auto-width/auto-height size for the
;; the normal layout and can grow outside its parent ;; shape. The properties try to adjust to the shape and "overflow" if
;; We use this element to measure the size of the text ;; the shape is not big enough.
(let [base #js {:display "inline-block"}] ;; We `inherit` the property `justify-content` so it's set by the root where
;; the property it's known.
;; `inline-flex` is similar to flex but `overflows` outside the bounds of the
;; parent
(let [shape (obj/get props "shape")
grow-type (:grow-type shape)
auto-width? (= grow-type :auto-width)
auto-height? (= grow-type :auto-height)
base #js {:display "inline-flex"
:flexDirection "column"
:justifyContent "inherit"
:minHeight (when-not (or auto-width? auto-height?) "100%")
:minWidth (when-not auto-width? "100%")
:verticalAlign "top"}]
base))) base)))
(defn generate-paragraph-styles (defn generate-paragraph-styles

View file

@ -196,14 +196,9 @@
on-goback on-goback
(mf/use-callback (mf/use-callback
(mf/deps project-id file-id page-id anonymous?) (mf/deps project)
(fn [] (st/emitf (dv/go-to-dashboard project)))
(if anonymous?
(st/emit! (rt/nav :login))
(st/emit! (rt/nav :workspace
{:project-id project-id
:file-id file-id}
{:page-id page-id})))))
on-edit on-edit
(mf/use-callback (mf/use-callback
(mf/deps project-id file-id page-id) (mf/deps project-id file-id page-id)

View file

@ -72,6 +72,8 @@
do-remove-group (st/emitf dw/ungroup-selected) do-remove-group (st/emitf dw/ungroup-selected)
do-mask-group (st/emitf dw/mask-group) do-mask-group (st/emitf dw/mask-group)
do-unmask-group (st/emitf dw/unmask-group) do-unmask-group (st/emitf dw/unmask-group)
do-flip-vertical (st/emitf (dw/flip-vertical-selected))
do-flip-horizontal (st/emitf (dw/flip-horizontal-selected))
do-add-component (st/emitf dwl/add-component) do-add-component (st/emitf dwl/add-component)
do-detach-component (st/emitf (dwl/detach-component id)) do-detach-component (st/emitf (dwl/detach-component id))
do-reset-component (st/emitf (dwl/reset-component id)) do-reset-component (st/emitf (dwl/reset-component id))
@ -133,7 +135,18 @@
:on-click do-create-group}] :on-click do-create-group}]
[:& menu-entry {:title (t locale "workspace.shape.menu.mask") [:& menu-entry {:title (t locale "workspace.shape.menu.mask")
:shortcut (sc/get-tooltip :mask) :shortcut (sc/get-tooltip :mask)
:on-click do-mask-group}]]) :on-click do-mask-group}]
[:& menu-separator]])
(when (>= (count selected) 1)
[:*
[:& menu-entry {:title (t locale "workspace.shape.menu.flip-vertical")
:shortcut (sc/get-tooltip :flip-vertical)
:on-click do-flip-vertical}]
[:& menu-entry {:title (t locale "workspace.shape.menu.flip-horizontal")
:shortcut (sc/get-tooltip :flip-horizontal)
:on-click do-flip-horizontal}]
[:& menu-separator]])
(when (and (= (count selected) 1) (= (:type shape) :group)) (when (and (= (count selected) 1) (= (:type shape) :group))
[:* [:*

View file

@ -15,6 +15,7 @@
[beicon.core :as rx] [beicon.core :as rx]
[okulary.core :as l] [okulary.core :as l]
[app.common.math :as mth] [app.common.math :as mth]
[app.common.geom.shapes :as gsh]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.geom.matrix :as gmt] [app.common.geom.matrix :as gmt]
[app.util.dom :as dom] [app.util.dom :as dom]
@ -238,16 +239,22 @@
gradient (mf/deref current-gradient-ref) gradient (mf/deref current-gradient-ref)
editing-spot (mf/deref editing-spot-ref) editing-spot (mf/deref editing-spot-ref)
transform (gsh/transform-matrix shape)
transform-inverse (gsh/inverse-transform-matrix shape)
{:keys [x y width height] :as sr} (:selrect shape) {:keys [x y width height] :as sr} (:selrect shape)
[{start-color :color start-opacity :opacity} [{start-color :color start-opacity :opacity}
{end-color :color end-opacity :opacity}] (:stops gradient) {end-color :color end-opacity :opacity}] (:stops gradient)
from-p (gpt/point (+ x (* width (:start-x gradient))) from-p (-> (gpt/point (+ x (* width (:start-x gradient)))
(+ y (* height (:start-y gradient)))) (+ y (* height (:start-y gradient))))
to-p (gpt/point (+ x (* width (:end-x gradient))) (gpt/transform transform))
to-p (-> (gpt/point (+ x (* width (:end-x gradient)))
(+ y (* height (:end-y gradient)))) (+ y (* height (:end-y gradient))))
(gpt/transform transform))
gradient-vec (gpt/to-vec from-p to-p) gradient-vec (gpt/to-vec from-p to-p)
gradient-length (gpt/length gradient-vec) gradient-length (gpt/length gradient-vec)
@ -263,14 +270,16 @@
(st/emit! (dc/update-gradient changes))) (st/emit! (dc/update-gradient changes)))
on-change-start (fn [point] on-change-start (fn [point]
(let [start-x (/ (- (:x point) x) width) (let [point (gpt/transform point transform-inverse)
start-x (/ (- (:x point) x) width)
start-y (/ (- (:y point) y) height) start-y (/ (- (:y point) y) height)
start-x (mth/precision start-x 2) start-x (mth/precision start-x 2)
start-y (mth/precision start-y 2)] start-y (mth/precision start-y 2)]
(change! {:start-x start-x :start-y start-y}))) (change! {:start-x start-x :start-y start-y})))
on-change-finish (fn [point] on-change-finish (fn [point]
(let [end-x (/ (- (:x point) x) width) (let [point (gpt/transform point transform-inverse)
end-x (/ (- (:x point) x) width)
end-y (/ (- (:y point) y) height) end-y (/ (- (:y point) y) height)
end-x (mth/precision end-x 2) end-x (mth/precision end-x 2)

View file

@ -5,7 +5,7 @@
;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0. ;; defined by the Mozilla Public License, v. 2.0.
;; ;;
;; Copyright (c) 2020 UXBOX Labs SL ;; Copyright (c) 2020-2021 UXBOX Labs SL
(ns app.main.ui.workspace.header (ns app.main.ui.workspace.header
(:require (:require
@ -227,9 +227,10 @@
[:li {:on-click on-add-shared} [:li {:on-click on-add-shared}
[:span (tr "dashboard.add-shared")]]) [:span (tr "dashboard.add-shared")]])
[:li.feedback {:on-click #(.open js/window "https://github.com/penpot/penpot/discussions" "_blank")} (when cfg/feedback-enabled
[:span (tr "labels.feedback")] [:li.feedback {:on-click (st/emitf (rt/nav :settings-feedback))}
[:span.primary-badge "ALPHA"]] [:span (tr "labels.give-feedback")]
[:span.primary-badge "ALPHA"]])
]]])) ]]]))
;; --- Header Component ;; --- Header Component

View file

@ -173,13 +173,15 @@
options (dom/get-element-by-class "element-options") options (dom/get-element-by-class "element-options")
assets (dom/get-element-by-class "assets-bar") assets (dom/get-element-by-class "assets-bar")
cpicker (dom/get-element-by-class "colorpicker-tooltip") cpicker (dom/get-element-by-class "colorpicker-tooltip")
palette (dom/get-element-by-class "color-palette")
self (mf/ref-val self-ref) self (mf/ref-val self-ref)
selecting? (mf/ref-val selecting-ref)] selecting? (mf/ref-val selecting-ref)]
(when-not (or (and options (.contains options target)) (when-not (or (and options (.contains options target))
(and assets (.contains assets target)) (and assets (.contains assets target))
(and self (.contains self target)) (and self (.contains self target))
(and cpicker (.contains cpicker target))) (and cpicker (.contains cpicker target))
(and palette (.contains palette target)))
(do (do
(if selecting? (if selecting?

View file

@ -152,8 +152,9 @@
(-> values (-> values
(merge-attrs (select-keys shape attrs)) (merge-attrs (select-keys shape attrs))
(merge-attrs (ut/get-text-attrs-multi content attrs)))] (merge-attrs (ut/get-text-attrs-multi content attrs)))]
:children (let [children (->> (:shapes shape []) (map #(get objects %)))] :children (let [children (->> (:shapes shape []) (map #(get objects %)))
(get-attrs children objects attr-type)) [new-ids new-values] (get-attrs children objects attr-type)]
[(d/concat ids new-ids) (merge-attrs values new-values)])
[])] [])]
result))] result))]
(reduce extract-attrs [[] []] shapes))) (reduce extract-attrs [[] []] shapes)))

View file

@ -285,8 +285,8 @@
(let [ids [(:id shape)] (let [ids [(:id shape)]
type (:type shape) type (:type shape)
local (deref refs/workspace-local) editors (mf/deref refs/editors)
editor (get-in local [:editors (:id shape)]) editor (get editors (:id shape))
measure-values (select-keys shape measure-attrs) measure-values (select-keys shape measure-attrs)

View file

@ -231,6 +231,27 @@
:shape (gsh/transform-shape shape) :shape (gsh/transform-shape shape)
:color color}])]))) :color color}])])))
(mf/defc pixel-grid
[{:keys [vbox zoom]}]
[:g.pixel-grid
[:defs
[:pattern {:id "pixel-grid"
:viewBox "0 0 1 1"
:width 1
:height 1
:pattern-units "userSpaceOnUse"}
[:path {:d "M 1 0 L 0 0 0 1"
:style {:fill "none"
:stroke "#59B9E2"
:stroke-opacity "0.2"
:stroke-width (str (/ 1 zoom))}}]]]
[:rect {:x (:x vbox)
:y (:y vbox)
:width (:width vbox)
:height (:height vbox)
:fill (str "url(#pixel-grid)")
:style {:pointer-events "none"}}]])
(mf/defc frames (mf/defc frames
{::mf/wrap [mf/memo] {::mf/wrap [mf/memo]
::mf/wrap-props false} ::mf/wrap-props false}
@ -779,6 +800,10 @@
(when show-grids? (when show-grids?
[:& frame-grid {:zoom zoom}]) [:& frame-grid {:zoom zoom}])
(when (>= zoom 8)
[:& pixel-grid {:vbox vbox
:zoom zoom}])
(when show-snap-points? (when show-snap-points?
[:& snap-points {:layout layout [:& snap-points {:layout layout
:transform transform :transform transform

View file

@ -57,7 +57,6 @@
form)) form))
(defn- wrap-update-fn (defn- wrap-update-fn
[f {:keys [spec validators]}] [f {:keys [spec validators]}]
(fn [& args] (fn [& args]

View file

@ -0,0 +1,372 @@
(ns app.test-components-basic
(:require [cljs.test :as t :include-macros true]
[cljs.pprint :refer [pprint]]
[clojure.stacktrace :as stk]
[beicon.core :as rx]
[linked.core :as lks]
[app.test-helpers.events :as the]
[app.test-helpers.pages :as thp]
[app.test-helpers.libraries :as thl]
[app.common.geom.point :as gpt]
[app.common.data :as d]
[app.common.pages.helpers :as cph]
[app.main.data.workspace :as dw]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.libraries-helpers :as dwlh]))
(t/use-fixtures :each
{:before thp/reset-idmap!})
(t/deftest test-add-component-from-single-shape
(t/async done
(try
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"}))]
(->> state
(the/do-update (dw/select-shape (thp/id :shape1)))
(the/do-watch-update dwl/add-component)
(rx/do
(fn [new-state]
(let [shape1 (thp/get-shape new-state :shape1)
[[group shape1] [c-group c-shape1] component]
(thl/resolve-instance-and-master
new-state
(:parent-id shape1))
file (dwlh/get-local-file new-state)]
(t/is (= (:name shape1) "Rect 1"))
(t/is (= (:name group) "Component-1"))
(t/is (= (:name component) "Component-1"))
(t/is (= (:name c-shape1) "Rect 1"))
(t/is (= (:name c-group) "Component-1"))
(thl/is-from-file group file))))
(rx/subs
done
#(do
(println (.-stack %))
(done)))))
(catch :default e
(println (.-stack e))
(done)))))
(t/deftest test-add-component-from-several-shapes
(t/async done
(try
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"})
(thp/sample-shape :shape2 :rect
{:name "Rect 2"}))]
(->> state
(the/do-update (dw/select-shapes (lks/set
(thp/id :shape1)
(thp/id :shape2))))
(the/do-watch-update dwl/add-component)
(rx/do
(fn [new-state]
(let [shape1 (thp/get-shape new-state :shape1)
[[group shape1 shape2]
[c-group c-shape1 c-shape2]
component]
(thl/resolve-instance-and-master
new-state
(:parent-id shape1))
file (dwlh/get-local-file new-state)]
;; NOTE: the group name depends on having executed
;; the previous test.
(t/is (= (:name group) "Component-2"))
(t/is (= (:name shape1) "Rect 1"))
(t/is (= (:name shape2) "Rect 2"))
(t/is (= (:name component) "Component-2"))
(t/is (= (:name c-group) "Component-2"))
(t/is (= (:name c-shape1) "Rect 1"))
(t/is (= (:name c-shape2) "Rect 2"))
(thl/is-from-file group file))))
(rx/subs
done
#(do
(println (.-stack %))
(done)))))
(catch :default e
(println (.-stack e))
(done)))))
(t/deftest test-add-component-from-group
(t/async done
(try
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"})
(thp/sample-shape :shape2 :rect
{:name "Rect 2"})
(thp/group-shapes :group1
[(thp/id :shape1)
(thp/id :shape2)]))]
(->> state
(the/do-update (dw/select-shape (thp/id :group1)))
(the/do-watch-update dwl/add-component)
(rx/do
(fn [new-state]
(let [[[group shape1 shape2]
[c-group c-shape1 c-shape2]
component]
(thl/resolve-instance-and-master
new-state
(thp/id :group1))
file (dwlh/get-local-file new-state)]
(t/is (= (:name shape1) "Rect 1"))
(t/is (= (:name shape2) "Rect 2"))
(t/is (= (:name group) "Group-3"))
(t/is (= (:name component) "Group-3"))
(t/is (= (:name c-shape1) "Rect 1"))
(t/is (= (:name c-shape2) "Rect 2"))
(t/is (= (:name c-group) "Group-3"))
(thl/is-from-file group file))))
(rx/subs
done
#(do
(println (.-stack %))
(done)))))
(catch :default e
(println (.-stack e))
(done)))))
(t/deftest test-rename-component
(t/async done
(try
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"})
(thp/make-component :instance1
[(thp/id :shape1)]))
instance1 (thp/get-shape state :instance1)]
(->> state
(the/do-watch-update (dwl/rename-component
(:component-id instance1)
"Renamed component"))
(rx/do
(fn [new-state]
(let [file (dwlh/get-local-file new-state)
component (cph/get-component
(:component-id instance1)
(:component-file instance1)
file
{})]
(t/is (= (:name component)
"Renamed component")))))
(rx/subs
done
#(do
(println (.-stack %))
(done)))))
(catch :default e
(println (.-stack e))
(done)))))
(t/deftest test-duplicate-component
(t/async done
(try
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"})
(thp/make-component :instance1
[(thp/id :shape1)]))
instance1 (thp/get-shape state :instance1)
component-id (:component-id instance1)]
(->> state
(the/do-watch-update (dwl/duplicate-component
{:id component-id}))
(rx/do
(fn [new-state]
(let [new-component-id (->> (get-in new-state
[:workspace-data
:components])
(keys)
(filter #(not= % component-id))
(first))
[[instance1 shape1]
[c-instance1 c-shape1]
component1]
(thl/resolve-instance-and-master
new-state
(:id instance1))
[[c-component2 c-shape2]
component2]
(thl/resolve-component
new-state
new-component-id)]
(t/is (= (:name component2)
"Component-6")))))
(rx/subs
done
#(do
(println (.-stack %))
(done)))))
(catch :default e
(println (.-stack e))
(done)))))
(t/deftest test-delete-component
(t/async done
(try
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"})
(thp/make-component :instance1
[(thp/id :shape1)]))
instance1 (thp/get-shape state :instance1)
component-id (:component-id instance1)]
(->> state
(the/do-watch-update (dwl/delete-component
{:id component-id}))
(rx/do
(fn [new-state]
(let [[instance1 shape1]
(thl/resolve-instance
new-state
(:id instance1))
file (dwlh/get-local-file new-state)
component (cph/get-component
(:component-id instance1)
(:component-file instance1)
file
{})]
(t/is (nil? component)))))
(rx/subs
done
#(do
(println (.-stack %))
(done)))))
(catch :default e
(println (.-stack e))
(done)))))
(t/deftest test-instantiate-component
(t/async done
(try
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"})
(thp/make-component :instance1
[(thp/id :shape1)]))
file (dwlh/get-local-file state)
instance1 (thp/get-shape state :instance1)
component-id (:component-id instance1)]
(->> state
(the/do-watch-update (dwl/instantiate-component
(:id file)
(:component-id instance1)
(gpt/point 100 100)))
(rx/do
(fn [new-state]
(let [new-instance-id (-> (get-in new-state
[:workspace-local :selected])
first)
[[instance2 shape2]
[c-instance2 c-shape2]
component]
(thl/resolve-instance-and-master
new-state
new-instance-id)]
(t/is (not= (:id instance1) (:id instance2)))
(t/is (= (:id component) component-id))
(t/is (= (:name instance2) "Component-8"))
(t/is (= (:name shape2) "Rect 1"))
(t/is (= (:name c-instance2) "Component-7"))
(t/is (= (:name c-shape2) "Rect 1")))))
(rx/subs
done
#(do
(println (.-stack %))
(done)))))
(catch :default e
(println (.-stack e))
(done)))))
(t/deftest test-detach-component
(t/async done
(try
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"})
(thp/make-component :instance1
[(thp/id :shape1)]))
instance1 (thp/get-shape state :instance1)
component-id (:component-id instance1)]
(->> state
(the/do-watch-update (dwl/detach-component
(:id instance1)))
(rx/do
(fn [new-state]
(let [[instance1 shape1]
(thl/resolve-noninstance
new-state
(:id instance1))]
(t/is (= (:name "Rect 1"))))))
(rx/subs
done
#(do
(println (.-stack %))
(done)))))
(catch :default e
(println (.-stack e))
(done)))))

View file

@ -0,0 +1,123 @@
(ns app.test-components-sync
(:require [cljs.test :as t :include-macros true]
[cljs.pprint :refer [pprint]]
[clojure.stacktrace :as stk]
[beicon.core :as rx]
[linked.core :as lks]
[app.test-helpers.events :as the]
[app.test-helpers.pages :as thp]
[app.test-helpers.libraries :as thl]
[app.common.geom.point :as gpt]
[app.common.data :as d]
[app.common.pages.helpers :as cph]
[app.main.data.workspace.common :as dwc]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.libraries-helpers :as dwlh]))
(t/use-fixtures :each
{:before thp/reset-idmap!})
(t/deftest test-touched
(t/async done
(try
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"
:fill-color "#ffffff"
:fill-opacity 1})
(thp/make-component :instance1
[(thp/id :shape1)]))
shape1 (thp/get-shape state :shape1)
instance1 (thp/get-shape state :instance1)
update-shape (fn [shape]
(merge shape {:fill-color "#fabada"
:fill-opacity 0.5}))]
(->> state
(the/do-watch-update (dwc/update-shapes [(:id shape1)]
update-shape))
(rx/do
(fn [new-state]
(let [shape1 (thp/get-shape new-state :shape1)
[[group shape1] [c-group c-shape1] component]
(thl/resolve-instance-and-master
new-state
(:id instance1))
file (dwlh/get-local-file new-state)]
(t/is (= (:fill-color shape1) "#fabada"))
(t/is (= (:fill-opacity shape1) 0.5))
(t/is (= (:touched shape1) #{:fill-group}))
(t/is (= (:fill-color c-shape1) "#ffffff"))
(t/is (= (:fill-opacity c-shape1) 1))
(t/is (= (:touched c-shape1) nil)))))
(rx/subs
done
#(do
(println (.-stack %))
(done)))))
(catch :default e
(println (.-stack e))
(done)))))
(t/deftest test-reset-changes
(t/async done
(try
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"
:fill-color "#ffffff"
:fill-opacity 1})
(thp/make-component :instance1
[(thp/id :shape1)]))
shape1 (thp/get-shape state :shape1)
instance1 (thp/get-shape state :instance1)
update-shape (fn [shape]
(merge shape {:fill-color "#fabada"
:fill-opacity 0.5}))]
(->> state
(the/do-watch-update (dwc/update-shapes [(:id shape1)]
update-shape))
(rx/mapcat #(the/do-watch-update
(dwl/reset-component (:id instance1)) %))
(rx/do
(fn [new-state]
(let [shape1 (thp/get-shape new-state :shape1)
[[group shape1] [c-group c-shape1] component]
(thl/resolve-instance-and-master
new-state
(:id instance1))
file (dwlh/get-local-file new-state)]
(t/is (= (:fill-color shape1) "#ffffff"))
(t/is (= (:fill-opacity shape1) 1))
(t/is (= (:touched shape1) nil))
(t/is (= (:fill-color c-shape1) "#ffffff"))
(t/is (= (:fill-opacity c-shape1) 1))
(t/is (= (:touched c-shape1) nil)))))
(rx/subs
done
#(do
(println (.-stack %))
(done)))))
(catch :default e
(println (.-stack e))
(done)))))

View file

@ -52,6 +52,33 @@
(t/is (= (:component-file shape) (t/is (= (:component-file shape)
(:id file)))) (:id file))))
(defn resolve-instance
[state root-inst-id]
(let [page (thp/current-page state)
root-inst (cph/get-shape page root-inst-id)
shapes-inst (cph/get-object-with-children
root-inst-id
(:objects page))]
;; Validate that the instance tree is well constructed
(t/is (is-instance-root (first shapes-inst)))
(run! is-instance-child (rest shapes-inst))
shapes-inst))
(defn resolve-noninstance
[state root-inst-id]
(let [page (thp/current-page state)
root-inst (cph/get-shape page root-inst-id)
shapes-inst (cph/get-object-with-children
root-inst-id
(:objects page))]
;; Validate that the tree is not an instance
(run! is-noninstance shapes-inst)
shapes-inst))
(defn resolve-instance-and-master (defn resolve-instance-and-master
[state root-inst-id] [state root-inst-id]
(let [page (thp/current-page state) (let [page (thp/current-page state)
@ -88,3 +115,25 @@
[shapes-inst shapes-master component])) [shapes-inst shapes-master component]))
(defn resolve-component
[state component-id]
(let [page (thp/current-page state)
file (dwlh/get-local-file state)
component (cph/get-component
component-id
(:id file)
file
nil)
root-master (cph/get-component-root
component)
shapes-master (cph/get-object-with-children
(:id root-master)
(:objects component))]
;; Validate that the component tree is well constructed
(run! is-noninstance shapes-master)
[shapes-master component]))

View file

@ -1,206 +0,0 @@
(ns app.test-library-sync
(:require [cljs.test :as t :include-macros true]
[cljs.pprint :refer [pprint]]
[beicon.core :as rx]
[linked.core :as lks]
[app.test-helpers.events :as the]
[app.test-helpers.pages :as thp]
[app.test-helpers.libraries :as thl]
[app.common.data :as d]
[app.common.pages.helpers :as cph]
[app.main.data.workspace :as dw]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.libraries-helpers :as dwlh]))
(t/use-fixtures :each
{:before thp/reset-idmap!})
(t/deftest test-create-page
(t/testing "create page"
(let [state (-> thp/initial-state
(thp/sample-page))
page (thp/current-page state)]
(t/is (= (:name page) "page1")))))
(t/deftest test-create-shape
(t/testing "create shape"
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"}))
shape (thp/get-shape state :shape1)]
(t/is (= (:name shape) "Rect 1")))))
(t/deftest synctest
(t/testing "synctest"
(let [state {:workspace-local {:color-for-rename "something"}}
new-state (->> state
(the/do-update
dwl/clear-color-for-rename))]
(t/is (= (get-in new-state [:workspace-local :color-for-rename])
nil)))))
(t/deftest asynctest
(t/testing "asynctest"
(t/async done
(let [state {}
color {:color "#ffffff"}]
(->> state
(the/do-watch-update
(dwl/add-recent-color color))
(rx/map
(fn [new-state]
(t/is (= (get-in new-state [:workspace-file
:data
:recent-colors])
[color]))
(t/is (= (get-in new-state [:workspace-data
:recent-colors])
[color]))))
(rx/subs done))))))
(t/deftest test-add-component-from-single-shape
(t/testing "Add a component from a single shape"
(t/async done
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"}))]
(->> state
(the/do-update (dw/select-shape (thp/id :shape1)))
(the/do-watch-update dwl/add-component)
(rx/map
(fn [new-state]
(let [shape1 (thp/get-shape new-state :shape1)
[[group shape1] [c-group c-shape1] component]
(thl/resolve-instance-and-master
new-state
(:parent-id shape1))
file (dwlh/get-local-file new-state)]
(t/is (= (:name shape1) "Rect 1"))
(t/is (= (:name group) "Component-1"))
(t/is (= (:name component) "Component-1"))
(t/is (= (:name c-shape1) "Rect 1"))
(t/is (= (:name c-group) "Component-1"))
(thl/is-from-file group file))))
(rx/subs done))))))
(t/deftest test-add-component-from-several-shapes
(t/testing "Add a component from several shapes"
(t/async done
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"})
(thp/sample-shape :shape2 :rect
{:name "Rect 2"}))]
(->> state
(the/do-update (dw/select-shapes (lks/set
(thp/id :shape1)
(thp/id :shape2))))
(the/do-watch-update dwl/add-component)
(rx/map
(fn [new-state]
(let [shape1 (thp/get-shape new-state :shape1)
[[group shape1 shape2]
[c-group c-shape1 c-shape2]
component]
(thl/resolve-instance-and-master
new-state
(:parent-id shape1))
file (dwlh/get-local-file new-state)]
;; NOTE: the group name depends on having executed
;; the previous test.
(t/is (= (:name group) "Component-2"))
(t/is (= (:name shape1) "Rect 1"))
(t/is (= (:name shape2) "Rect 2"))
(t/is (= (:name component) "Component-2"))
(t/is (= (:name c-group) "Component-2"))
(t/is (= (:name c-shape1) "Rect 1"))
(t/is (= (:name c-shape2) "Rect 2"))
(thl/is-from-file group file))))
(rx/subs done))))))
(t/deftest test-add-component-from-group
(t/testing "Add a component from a group"
(t/async done
(let [
state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"})
(thp/sample-shape :shape2 :rect
{:name "Rect 2"})
(thp/group-shapes :group1
[(thp/id :shape1)
(thp/id :shape2)]))]
(->> state
(the/do-update (dw/select-shape (thp/id :group1)))
(the/do-watch-update dwl/add-component)
(rx/map
(fn [new-state]
(let [[[group shape1 shape2]
[c-group c-shape1 c-shape2]
component]
(thl/resolve-instance-and-master
new-state
(thp/id :group1))
file (dwlh/get-local-file new-state)]
(t/is (= (:name shape1) "Rect 1"))
(t/is (= (:name shape2) "Rect 2"))
(t/is (= (:name group) "Group-3"))
(t/is (= (:name component) "Group-3"))
(t/is (= (:name c-shape1) "Rect 1"))
(t/is (= (:name c-shape2) "Rect 2"))
(t/is (= (:name c-group) "Group-3"))
(thl/is-from-file group file))))
(rx/subs done))))))
(t/deftest test-rename-component
(t/testing "Rename a component"
(t/async done
(let [
state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"})
(thp/make-component :instance1
[(thp/id :shape1)]))
instance1 (thp/get-shape state :instance1)]
(->> state
(the/do-watch-update (dwl/rename-component
(:component-id instance1)
"Renamed component"))
(rx/map
(fn [new-state]
(let [file (dwlh/get-local-file new-state)
component (cph/get-component
(:component-id instance1)
(:component-file instance1)
file
{})]
(t/is (= (:name component)
"Renamed component")))))
(rx/subs done))))))

View file

@ -0,0 +1,61 @@
(ns app.test-shapes
(:require [cljs.test :as t :include-macros true]
[cljs.pprint :refer [pprint]]
[clojure.stacktrace :as stk]
[beicon.core :as rx]
[linked.core :as lks]
[app.test-helpers.events :as the]
[app.test-helpers.pages :as thp]
[app.test-helpers.libraries :as thl]
[app.common.geom.point :as gpt]
[app.common.data :as d]
[app.common.pages.helpers :as cph]
[app.main.data.workspace.libraries :as dwl]))
(t/use-fixtures :each
{:before thp/reset-idmap!})
(t/deftest test-create-page
(t/testing "create page"
(let [state (-> thp/initial-state
(thp/sample-page))
page (thp/current-page state)]
(t/is (= (:name page) "page1")))))
(t/deftest test-create-shape
(t/testing "create shape"
(let [state (-> thp/initial-state
(thp/sample-page)
(thp/sample-shape :shape1 :rect
{:name "Rect 1"}))
shape (thp/get-shape state :shape1)]
(t/is (= (:name shape) "Rect 1")))))
(t/deftest synctest
(t/testing "synctest"
(let [state {:workspace-local {:color-for-rename "something"}}
new-state (->> state
(the/do-update
dwl/clear-color-for-rename))]
(t/is (= (get-in new-state [:workspace-local :color-for-rename])
nil)))))
(t/deftest asynctest
(t/testing "asynctest"
(t/async done
(let [state {}
color {:color "#ffffff"}]
(->> state
(the/do-watch-update
(dwl/add-recent-color color))
(rx/map
(fn [new-state]
(t/is (= (get-in new-state [:workspace-file
:data
:recent-colors])
[color]))
(t/is (= (get-in new-state [:workspace-data
:recent-colors])
[color]))))
(rx/subs done done))))))