0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-04-16 08:51:32 -05:00

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
promesa.core/let clojure.core/let
rumext.alpha/defc clojure.core/defn
app.db/with-atomic clojure.core/with-open}
:output
{: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 #
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
efficient for everyone. If you want a specific documentation for
generic guide that details how to contribute to Penpot in a way that
is efficient for everyone. If you want a specific documentation for
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 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 ##
If you want propose a change or bug fix with the Pull-Request system
firstly you should carefully read the **Contributor License Aggreement**
section and format your commits accordingly.
firstly you should carefully read the **DCO** section and format your
commits accordingly.
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
@ -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/
## Contributor License Agreement ##
## Developer's Certificate of Origin (DCO) ##
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
this project or the open source license(s) involved.
Then, all your patches should contain a sign-off at the end of the
patch/commit description body. It can be automatically added on adding
`-s` parameter to `git commit`.
Then, all your code patches (**documentation are excluded**) should
contain a sign-off at the end of the patch/commit description body. It
can be automatically added on adding `-s` parameter to `git commit`.
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]
[![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 #

View file

@ -18,6 +18,8 @@
org.slf4j/slf4j-api {:mvn/version "1.7.30"}
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_hotspot {:mvn/version "0.9.0"}

View file

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

View file

@ -30,14 +30,14 @@
for security reasons.
</mj-text>
<mj-text>Enjoy!</mj-text>
<mj-text>The UXBOX team.</mj-text>
<mj-text>The Penpot team.</mj-text>
</mj-column>
</mj-section>
<mj-section padding="24px 0 0 0">
<mj-column width="425px">
<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-column>
</mj-section>
@ -57,7 +57,7 @@
<mj-section padding="0 0 24px 0">
<mj-column>
<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-column>
</mj-section>

View file

@ -23,14 +23,14 @@
Accept invite
</mj-button>
<mj-text>Enjoy!</mj-text>
<mj-text>The UXBOX team.</mj-text>
<mj-text>The Penpot team.</mj-text>
</mj-column>
</mj-section>
<mj-section padding="24px 0 0 0">
<mj-column width="425px">
<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-column>
</mj-section>
@ -50,7 +50,7 @@
<mj-section padding="0 0 24px 0">
<mj-column>
<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-column>
</mj-section>

View file

@ -32,14 +32,14 @@
it. Your password won't be changed.
</mj-text>
<mj-text>Enjoy!</mj-text>
<mj-text>The UXBOX team.</mj-text>
<mj-text>The Penpot team.</mj-text>
</mj-column>
</mj-section>
<mj-section padding="24px 0 0 0">
<mj-column width="425px">
<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-column>
</mj-section>
@ -59,7 +59,7 @@
<mj-section padding="0 0 24px 0">
<mj-column>
<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-column>
</mj-section>

View file

@ -21,7 +21,7 @@
<mj-column>
<mj-text font-size="24px" font-weight="600">Hello {{name}}!</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
prototypes today!
</mj-text>
@ -29,14 +29,14 @@
Verify email
</mj-button>
<mj-text>Enjoy!</mj-text>
<mj-text>The UXBOX team.</mj-text>
<mj-text>The Penpot team.</mj-text>
</mj-column>
</mj-section>
<mj-section padding="24px 0 0 0">
<mj-column width="425px">
<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-column>
</mj-section>
@ -56,7 +56,7 @@
<mj-section padding="0 0 24px 0">
<mj-column>
<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-column>
</mj-section>

View file

@ -10,4 +10,4 @@ If you received this email by mistake, please consider changing your password
for security reasons.
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}}
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.
Enjoy!
The UXBOX team.
The Penpot team.

View file

@ -1,9 +1,9 @@
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!
{{ public-uri }}/#/auth/verify-token?token={{token}}
Enjoy!
The UXBOX team.
The Penpot team.

View file

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

View file

@ -43,6 +43,16 @@
;; --- 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 ::register
(s/keys :req-un [::name]))

View file

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

View file

@ -175,7 +175,12 @@
(ex/raise :type :internal
:code :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

View file

@ -155,11 +155,12 @@
:dec (.. ^Gauge instance (labels labels) (dec)))))))
(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)
instance (doto (Summary/build)
(.name name)
(.help help)
(.maxAgeSeconds max-age)
(.quantile 0.75 0.02)
(.quantile 0.99 0.001))
_ (when (seq labels)

View file

@ -145,6 +145,9 @@
{:name "0044-add-storage-refcount"
: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.viewer
'app.rpc.mutations.teams
'app.rpc.mutations.feedback
'app.rpc.mutations.verify-token)
(map (partial process-method cfg))
(into {}))))

View file

@ -52,7 +52,7 @@
;; Schedule deletion of the demo profile
(tasks/submit! conn {:name "delete-profile"
:delay cfg/default-deletion-delay
:delay cfg/deletion-delay
:props {:profile-id id}})
{: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
(tasks/submit! conn {:name "delete-object"
:delay cfg/default-deletion-delay
:delay cfg/deletion-delay
:props {:id id :type :file}})
(mark-file-deleted conn params)))

View file

@ -66,9 +66,18 @@
[info]
(= (: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
[{:keys [storage] :as cfg} url]
(let [result (http/get! url {:as :byte-array})
(let [result (fetch-url url)
data (:body result)
mtype (get (:headers result) "content-type")
format (cm/mtype->format mtype)]

View file

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

View file

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

View file

@ -13,6 +13,7 @@
[app.common.exceptions :as ex]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.config :as cfg]
[app.db :as db]
[app.emails :as emails]
[app.media :as media]
@ -20,6 +21,7 @@
[app.rpc.queries.profile :as profile]
[app.rpc.queries.teams :as teams]
[app.storage :as sto]
[app.tasks :as tasks]
[app.util.services :as sv]
[app.util.time :as dt]
[clojure.spec.alpha :as s]
@ -133,7 +135,14 @@
(ex/raise :type :validation
: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)))

View file

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

View file

@ -38,7 +38,7 @@
{:id id})))
(defn get-file
[id]
[system id]
(with-open [conn (db/open (:app.db/pool system))]
(let [file (db/get-by-id conn :file id)]
(-> file
@ -72,3 +72,17 @@
(let [profile (prof/retrieve-profile-data-by-email conn user-email)
profile (merge profile (prof/retrieve-additional-data conn (:id 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
[data]
(with-open [istream (IOUtils/toInputStream data "UTF-8")]
(xml/parse istream)))
(try
(with-open [istream (IOUtils/toInputStream data "UTF-8")]
(xml/parse istream))
(catch org.xml.sax.SAXParseException _e
(ex/raise :type :validation
:code :invalid-svg-file))))
(defn process-request
[{:keys [svgc] :as cfg} body]
(let [data (slurp body)
data (svgc data)]
(parse data)))

View file

@ -42,11 +42,12 @@
(db/with-atomic [conn pool]
(handle-deletion conn props)))
(defmulti handle-deletion (fn [_ props] (:type props)))
(defmulti handle-deletion
(fn [_ props] (:type props)))
(defmethod handle-deletion :default
[_conn {:keys [type]}]
(log/warn "no handler found for" type))
(log/warnf "no handler found for %s" type))
(defmethod handle-deletion :file
[conn {:keys [id] :as props}]
@ -57,3 +58,8 @@
[conn {:keys [id] :as props}]
(let [sql "delete from project where id=? and deleted_at is not null"]
(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
"A generic blob storage encoding. Mainly used for
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
java.io.ByteArrayInputStream
java.io.ByteArrayOutputStream
java.io.DataInputStream
java.io.DataOutputStream
com.github.luben.zstd.Zstd
net.jpountz.lz4.LZ4Factory
net.jpountz.lz4.LZ4FastDecompressor
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))
(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-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
"A function used for decode persisted blobs in the database."
[^bytes data]
(with-open [bais (ByteArrayInputStream. data)
dis (DataInputStream. bais)]
(let [version (.readShort dis)
ulen (.readInt dis)]
(case version
1 (decode-v1 data ulen)
2 (decode-v2 data ulen)
(throw (ex-info "unsupported version" {:version version}))))))
;; --- IMPL
(defn- encode-v1
[data]
(let [data (->bytes data)]
(with-open [bais (ByteArrayInputStream. data)
dis (DataInputStream. bais)]
(let [version (.readShort dis)
udata-len (.readInt dis)]
(case version
1 (decode-v1 data udata-len)
(throw (ex-info "unsupported version" {:version version})))))))
(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
[^bytes cdata ^long udata-len]
(let [^LZ4FastDecompressor dcp (.fastDecompressor ^LZ4Factory lz4-factory)
^bytes udata (byte-array udata-len)]
(.decompress dcp cdata 6 udata 0 udata-len)
[^bytes cdata ^long ulen]
(let [dcp (.fastDecompressor ^LZ4Factory lz4-factory)
udata (byte-array ulen)]
(.decompress ^LZ4FastDecompressor dcp cdata 6 ^bytes udata 0 ulen)
(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
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.spec :as us]
[app.util.template :as tmpl]
@ -196,15 +197,17 @@
text (render-email-template-part :txt id context)
html (render-email-template-part :html id context)]
(when (or (not subj)
(not text)
(not html))
(and (not text)
(not html)))
(ex/raise :type :internal
:code :missing-email-templates))
{:subject subj
:body [{:type "text/plain"
:content text}
{:type "text/html"
:content html}]}))
:body (d/concat
[{:type "text/plain"
:content text}]
(when html
[{:type "text/html"
:content html}]))}))
(s/def ::priority #{:high :low})
(s/def ::to (s/or :sigle ::us/email

View file

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

View file

@ -360,7 +360,7 @@
(t/is (= [rect-a-id rect-e-id rect-d-id]
(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
:page-id page-id
:parent-id group-a-id
@ -368,9 +368,9 @@
res (cp/process-changes data changes)]
(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])))
(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"
(let [changes [{:type :mov-objects
@ -727,11 +727,11 @@
;; 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])))
(t/is (= nil
(get-in res [:pages-index page-id :objects group-1-id])))
(t/is (not= nil
(get-in res [:pages-index page-id :objects group-1-id])))
))

View file

@ -134,3 +134,9 @@
(th-eq m1f m2f))))
(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
(declare assign-proportions-path)
(declare assign-proportions-rect)
(defn assign-proportions
[{:keys [type] :as shape}]
(case type
:path (assign-proportions-path shape)
(assign-proportions-rect shape)))
(defn- assign-proportions-rect
[{:keys [width height] :as shape}]
(assoc shape :proportion (/ width height)))
[shape]
(let [{:keys [width height]} (:selrect shape)]
(assoc shape :proportion (/ width height))))
;; --- 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
[{:keys [metadata] :as shape}]
(let [{:keys [width height]} metadata]
(assoc shape
: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
[shape]
(assoc shape
:proportion 1
: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)
(gpt/point 0 0))]
(inverse-transform-matrix shape shape-center)))
([shape center]
([{:keys [flip-x flip-y] :as shape} center]
(let []
(-> (gmt/matrix)
(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/translate (gpt/negate center))))))
@ -203,29 +206,7 @@
(gmt/rotate (- rotation-angle)))]
[stretch-matrix stretch-matrix-inverse]))
(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
(defn apply-transform
"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"
[shape transform]
@ -246,13 +227,21 @@
(:height points-temp-dim))
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 $
(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-inverse #(gmt/multiply matrix-inverse (or % (gmt/matrix))))
(assoc $ :points (into [] points))
@ -260,37 +249,6 @@
(update $ :rotation #(mod (+ (or % 0)
(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]
(let [rx (get-in modifiers [:resize-vector :x])
ry (get-in modifiers [:resize-vector :y])]
@ -305,12 +263,13 @@
(-> shape
(set-flip (:modifiers shape))
(apply-transform transform)
(transform-gradients (:modifiers shape))
(dissoc :modifiers)))
shape)))
(defn update-group-selrect [group children]
(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 (->> children (mapcat :points))
@ -330,5 +289,10 @@
(-> group
(assoc :selrect new-selrect)
(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]
(< (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?
(us/verify ::spec/changes items))
(->> items
(reduce #(or (process-change %1 %2) %1) data))))
(let [pages (into #{} (map :page-id) items)
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
[data {:keys [page-id option value]}]
@ -94,7 +106,6 @@
(let [update-fn (fn [objects]
(if-let [obj (get objects id)]
(let [result (reduce process-operation obj operations)]
#?(:clj (us/verify ::spec/shape result))
(assoc objects id result))
objects))]
(if page-id
@ -142,16 +153,25 @@
(map :id)
(distinct))
shapes)))
(set-mask-selrect [group children]
(let [mask (first children)]
(-> group
(merge (select-keys mask [:selrect :points]))
(assoc :x (-> mask :selrect :x)
:y (-> mask :selrect :y)
:width (-> mask :selrect :width)
:height (-> mask :selrect :height)))))
(update-group [group objects]
(let [children (->> group :shapes (map #(get objects %)))]
(if (:masked-group? group)
(let [mask (first children)]
(-> group
(merge (select-keys mask [:selrect :points]))
(assoc :x (-> mask :selrect :x)
:y (-> mask :selrect :y)
:width (-> mask :selrect :width)
:height (-> mask :selrect :height))))
(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))))]
(if page-id
@ -206,23 +226,17 @@
pid prev-parent-id
objects objects]
(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
true
(update-in [pid :shapes] strip-id sid)
(cond-> objects
true
(update-in [pid :shapes] strip-id sid)
(and (:shape-ref obj)
(= (:type obj) :group)
(not ignore-touched))
(->
(update-in [pid :touched]
cph/set-touched-group :shapes-group)
(d/dissoc-in [pid :remote-synced?])))))))))
(and (:shape-ref obj)
(= (:type obj) :group)
(not ignore-touched))
(->
(update-in [pid :touched]
cph/set-touched-group :shapes-group)
(d/dissoc-in [pid :remote-synced?]))))))))
(update-parent-id [objects id]
(assoc-in objects [id :parent-id] parent-id))

View file

@ -224,7 +224,9 @@
(defn select-toplevel-shapes
([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 %)
root (lookup uuid/zero)
root-children (:shapes root)
@ -241,7 +243,7 @@
(or (not= :frame typ) include-frames?)
(d/concat [obj])
(= :frame typ)
(and (= :frame typ) include-frame-children?)
(d/concat (map lookup 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
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 "$@";

View file

@ -7,16 +7,16 @@ The simplest approach is using docker and docker-compose.
## 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
repositores with:
repository with:
```bash
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/).
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
```
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
take effect.
@ -58,5 +58,5 @@ docker-compose -p penpot -f docker-compose.yaml up
The docker compose file contains the essential configuration for
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).

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -67,6 +67,7 @@
(def default-language "en")
(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 google-client-id (obj/get global "penpotGoogleClientID" nil))
(def gitlab-client-id (obj/get global "penpotGitlabClientID" nil))

View file

@ -417,3 +417,12 @@
(update [_ state]
(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)
(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
[ids parent-id to-index]
(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
ids (filter #(not (cp/is-parent? objects parent-id %)) ids)
parents (loop [res #{parent-id}
ids (seq ids)]
(if (nil? ids)
(vec res)
(recur
(conj res (cp/get-parent (first ids) objects))
(next ids))))
parents (reduce (fn [result id]
(conj result (cp/get-parent id objects)))
#{parent-id} ids)
groups-to-delete
(loop [current-id (first parents)
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
(reduce (fn [group-ids id]
@ -849,6 +1035,10 @@
#{}
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]
(reduce (fn [[shapes-to-detach shapes-to-deroot shapes-to-reroot] id]
(let [shape (get objects id)
@ -876,131 +1066,18 @@
[[] [] []]
ids)
rchanges (d/concat
[{:type :mov-objects
:parent-id parent-id
:page-id page-id
:index to-index
:shapes (vec (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 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})
[rchanges uchanges] (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)]
(rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})
(dwc/expand-collapse parent-id))))))
(defn relocate-selected-shapes
@ -1404,11 +1481,16 @@
ptk/WatchEvent
(watch [_ state stream]
(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)
text-data (wapi/extract-text paste-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
(seq image-data)
(rx/from (map paste-image image-data))
@ -1418,7 +1500,9 @@
(rx/filter #(= :copied-shapes (:type %)))
(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))
:else
@ -1722,6 +1806,8 @@
(d/export dwt/set-modifiers)
(d/export dwt/apply-modifiers)
(d/export dwt/update-dimensions)
(d/export dwt/flip-horizontal-selected)
(d/export dwt/flip-vertical-selected)
;; Persistence

View file

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

View file

@ -90,12 +90,15 @@
path)))
(defn- points->components [shape content]
(let [rotation (:rotation shape 0)
(let [transform (:transform shape)
transform-inverse (:transform-inverse 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
points (-> (gsh/content->selrect content-rotated)
points (-> (gsh/content->selrect base-content)
(gsh/rect->points)
(gsh/transform-points center (:transform shape (gmt/matrix))))

View file

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

View file

@ -319,7 +319,7 @@
(defn instantiate-component
"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]
(us/assert ::us/uuid file-id)
(us/assert ::us/uuid component-id)

View file

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

View file

@ -200,13 +200,16 @@
(ptk/reify ::handle-file-change
ptk/WatchEvent
(watch [_ state stream]
(let [page-ids (into #{} (comp (map :page-id)
(filter identity))
changes)]
(let [changes-by-pages (group-by :page-id changes)
process-page-changes
(fn [[page-id changes]]
(dwc/update-indices page-id changes))]
(rx/merge
(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/keys :req-un [::type

View file

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

View file

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

View file

@ -108,6 +108,14 @@
:command (ds/c-mod "k")
: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")
:command "shift+0"
:fn #(st/emit! dw/reset-zoom)}

View file

@ -249,7 +249,7 @@
(assoc :overflow-text true)
(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))
(-> (assoc :modifiers modifier-width)

View file

@ -82,8 +82,6 @@
{:keys [rotation]} shape
shapev (-> (gpt/point width height))
rotation (if (= :path (:type shape)) 0 rotation)
;; Vector modifiers depending on the handler
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.
(normalize-proportion-lock [[point shift?]]
(let [proportion-lock? (:proportion-lock shape)]
[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)))
]
[point (or proportion-lock? shift?)]))]
(reify
ptk/UpdateEvent
(update [_ state]
@ -142,8 +132,7 @@
ptk/WatchEvent
(watch [_ state stream]
(let [current-pointer @ms/mouse-position
initial-position (merge current-pointer initial)
(let [initial-position @ms/mouse-position
stoper (rx/filter ms/mouse-up? stream)
layout (:workspace-layout state)
page-id (:current-page-id state)
@ -541,3 +530,37 @@
objects (dwc/lookup-page-objects state page-id)
ids (d/concat [] ids (mapcat #(cp/get-children % objects) 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
(l/derived :hover workspace-local))
(def editors
(l/derived :editors workspace-local))
(def workspace-layout
(l/derived :workspace-layout st/state))

View file

@ -59,17 +59,18 @@
(def routes
[["/auth"
["/login" :auth-login]
["/register" :auth-register]
["/login" :auth-login]
["/register" :auth-register]
["/register/success" :auth-register-success]
["/recovery/request" :auth-recovery-request]
["/recovery" :auth-recovery]
["/verify-token" :auth-verify-token]]
["/recovery" :auth-recovery]
["/verify-token" :auth-verify-token]]
["/settings"
["/profile" :settings-profile]
["/profile" :settings-profile]
["/password" :settings-password]
["/options" :settings-options]]
["/feedback" :settings-feedback]
["/options" :settings-options]]
["/view/:file-id/:page-id"
{:name :viewer
@ -89,11 +90,11 @@
["/render-object/:file-id/:page-id/:object-id" :render-object]
["/dashboard/team/:team-id"
["/members" :dashboard-team-members]
["/settings" :dashboard-team-settings]
["/projects" :dashboard-projects]
["/search" :dashboard-search]
["/libraries" :dashboard-libraries]
["/members" :dashboard-team-members]
["/settings" :dashboard-team-settings]
["/projects" :dashboard-projects]
["/search" :dashboard-search]
["/libraries" :dashboard-libraries]
["/projects/:project-id" :dashboard-files]]
["/workspace/:project-id/:file-id" :workspace]])
@ -121,7 +122,8 @@
(:settings-profile
:settings-password
:settings-options)
:settings-options
:settings-feedback)
[:& settings/settings {:route route}]
:debug-icons-preview

View file

@ -37,7 +37,9 @@
(dom/prevent-default event)
(->> (rp/mutation! :login-with-google {})
(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
[event]
@ -111,7 +113,7 @@
(when cfg/login-with-ldap
[:& fm/submit-button
{:label (tr "auth.login-with-ldap-submit")
:on-click on-submit}])]]))
:on-click on-submit-ldap}])]]))
(mf/defc login-page
[]

View file

@ -15,7 +15,7 @@
[app.main.ui.icons :as i]
[app.util.object :as obj]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [t]]
[app.util.i18n :as i18n :refer [t tr]]
["react" :as react]
[app.util.dom :as dom]))
@ -28,7 +28,6 @@
type' (mf/use-state type)
focus? (mf/use-state false)
locale (mf/deref i18n/locale)
touched? (get-in @form [:touched name])
error (get-in @form [:errors name])
@ -94,7 +93,59 @@
help-icon'])
(cond
(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)
[:span.hint hint])]]))

View file

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

View file

@ -5,30 +5,31 @@
;; 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
;; Copyright (c) 2020-2021 UXBOX Labs SL
(ns app.main.ui.settings
(:require
[app.main.refs :as refs]
[app.main.store :as st]
[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.profile :refer [profile-page]]
[app.main.ui.settings.sidebar :refer [sidebar]]
[app.main.ui.settings.change-email]
[app.main.ui.settings.delete-account]
[app.util.i18n :as i18n :refer [t]]
[app.util.i18n :as i18n :refer [tr]]
[rumext.alpha :as mf]))
(mf/defc header
{::mf/wrap [mf/memo]}
[{:keys [locale] :as props}]
[]
(let [logout (constantly nil)]
[:header.dashboard-header
[:div.dashboard-title
[:h1 (t locale "dashboard.your-account-title")]]
[:h1 (tr "dashboard.your-account-title")]]
[:a.btn-secondary.btn-small {:on-click logout}
(t locale "labels.logout")]]))
(tr "labels.logout")]]))
(mf/defc settings
[{:keys [route] :as props}]
@ -41,12 +42,15 @@
:section section}]
[:div.dashboard-content
[:& header {:locale locale}]
[:& header]
[:section.dashboard-container
(case section
:settings-profile
[:& profile-page {:locale locale}]
:settings-feedback
[:& feedback-page]
:settings-password
[:& 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
(:require
[app.common.spec :as us]
[app.main.data.auth :as da]
[app.main.data.messages :as dm]
[app.main.refs :as refs]
[app.config :as cfg]
[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.icons :as i]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.object :as obj]
[app.util.i18n :as i18n :refer [tr]]
[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]))
(mf/defc sidebar-content
[{:keys [locale profile section] :as props}]
[{:keys [profile section] :as props}]
(let [profile? (= section :settings-profile)
password? (= section :settings-password)
options? (= section :settings-options)
feedback? (= section :settings-feedback)
go-dashboard
(mf/use-callback
@ -45,6 +34,11 @@
(mf/deps 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
(mf/use-callback
(mf/deps profile)
@ -59,7 +53,7 @@
[:div.sidebar-content-section
[:div.back-to-dashboard {:on-click go-dashboard}
[:span.icon i/arrow-down]
[:span.text (t locale "labels.dashboard")]]]
[:span.text (tr "labels.dashboard")]]]
[:hr]
[:div.sidebar-content-section
@ -67,25 +61,30 @@
[:li {:class (when profile? "current")
:on-click go-settings-profile}
i/user
[:span.element-title (t locale "labels.profile")]]
[:span.element-title (tr "labels.profile")]]
[:li {:class (when password? "current")
:on-click go-settings-password}
i/lock
[:span.element-title (t locale "labels.password")]]
[:span.element-title (tr "labels.password")]]
[:li {:class (when options? "current")
:on-click go-settings-options}
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/wrap [mf/memo]}
[{:keys [profile locale section]}]
[:div.dashboard-sidebar.settings
[:div.sidebar-inside
[:& sidebar-content {:locale locale
:profile profile
[:& sidebar-content {:profile profile
:section section}]
[:& profile-section {:profile profile
:locale locale}]]])

View file

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

View file

@ -20,28 +20,36 @@
([props] (generate-root-styles (clj->js (obj/get props "node")) props))
([data props]
(let [valign (obj/get data "vertical-align" "top")
talign (obj/get data "text-align" "flex-start")
shape (obj/get props "shape")
base #js {:height (or (:height shape) "100%")
:width (or (:width shape) "100%")
:display "flex"}]
:width (or (:width shape) "100%")}]
(cond-> base
(= valign "top") (obj/set! "alignItems" "flex-start")
(= valign "center") (obj/set! "alignItems" "center")
(= valign "bottom") (obj/set! "alignItems" "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")))))
(= valign "top") (obj/set! "justifyContent" "flex-start")
(= valign "center") (obj/set! "justifyContent" "center")
(= valign "bottom") (obj/set! "justifyContent" "flex-end")
))))
(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]
;; The position absolute is used so the paragraph is "outside"
;; the normal layout and can grow outside its parent
;; We use this element to measure the size of the text
(let [base #js {:display "inline-block"}]
;; This element will control the auto-width/auto-height size for the
;; shape. The properties try to adjust to the shape and "overflow" if
;; the shape is not big enough.
;; 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)))
(defn generate-paragraph-styles

View file

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

View file

@ -72,6 +72,8 @@
do-remove-group (st/emitf dw/ungroup-selected)
do-mask-group (st/emitf dw/mask-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-detach-component (st/emitf (dwl/detach-component id))
do-reset-component (st/emitf (dwl/reset-component id))
@ -133,7 +135,18 @@
:on-click do-create-group}]
[:& menu-entry {:title (t locale "workspace.shape.menu.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))
[:*

View file

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

View file

@ -5,7 +5,7 @@
;; 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
;; Copyright (c) 2020-2021 UXBOX Labs SL
(ns app.main.ui.workspace.header
(:require
@ -227,9 +227,10 @@
[:li {:on-click on-add-shared}
[:span (tr "dashboard.add-shared")]])
[:li.feedback {:on-click #(.open js/window "https://github.com/penpot/penpot/discussions" "_blank")}
[:span (tr "labels.feedback")]
[:span.primary-badge "ALPHA"]]
(when cfg/feedback-enabled
[:li.feedback {:on-click (st/emitf (rt/nav :settings-feedback))}
[:span (tr "labels.give-feedback")]
[:span.primary-badge "ALPHA"]])
]]]))
;; --- Header Component

View file

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

View file

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

View file

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

View file

@ -231,6 +231,27 @@
:shape (gsh/transform-shape shape)
: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/wrap [mf/memo]
::mf/wrap-props false}
@ -779,6 +800,10 @@
(when show-grids?
[:& frame-grid {:zoom zoom}])
(when (>= zoom 8)
[:& pixel-grid {:vbox vbox
:zoom zoom}])
(when show-snap-points?
[:& snap-points {:layout layout
:transform transform

View file

@ -57,7 +57,6 @@
form))
(defn- wrap-update-fn
[f {:keys [spec validators]}]
(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,12 +52,39 @@
(t/is (= (:component-file shape)
(: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
[state root-inst-id]
(let [page (thp/current-page state)
root-inst (cph/get-shape page root-inst-id)
file (dwlh/get-local-file state)
file (dwlh/get-local-file state)
component (cph/get-component
(:component-id root-inst)
(:id file)
@ -88,3 +115,25 @@
[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))))))