Compare commits

...

91 Commits

Author SHA1 Message Date
Belén Albeza
fe6c9f24d3 🐛 Fix edit grid unit dropdown being clipped 2024-10-21 10:15:57 +02:00
Andrey Antukh
97a880c946 Merge pull request #5179 from penpot/alotor-bugfixing-5
Alotor bugfixing 5
2024-10-18 14:00:53 +02:00
alonso.torres
df66955594 🐛 Fix problem with shadows and frames in Safari 2024-10-18 11:52:41 +02:00
alonso.torres
07f055bd49 🐛 Fix problem when duplicating board with guide 2024-10-18 11:52:41 +02:00
alonso.torres
22d5b125bd 🐛 Fix problem with layers overflowing panel 2024-10-18 11:52:40 +02:00
alonso.torres
ef3b4a5895 🐛 Fix problem with plugins icons 2024-10-17 14:51:01 +02:00
Andrey Antukh
02611029fb Merge pull request #5176 from penpot/juan-relesae-notes-2.3
Relesae notes 2.3 on onboarding slides
2024-10-16 16:31:19 +02:00
Elhombretecla
14e4e6d6ea 🎉 Add release note slides for 2.3 2024-10-16 14:33:20 +02:00
Andrey Antukh
9170c70f2a Merge pull request #5169 from penpot/alotor-bugfixing4
🐛 Fix problem with inner strokes bounds
2024-10-15 13:02:53 +02:00
alonso.torres
83d8bf37a6 🐛 Fix problem with inner strokes bounds 2024-10-15 12:28:23 +02:00
Andrey Antukh
1fb21d537c 🐛 Send thread-id on create-comment-thread rpc method 2024-10-15 09:29:40 +02:00
Andrey Antukh
ac80e9a1ac Respect overrides of jvm_opts on devenv bashrc file 2024-10-15 09:13:46 +02:00
Andrey Antukh
dbbb8e76ab Allow override java opts for build scripts 2024-10-15 09:13:46 +02:00
Andrey Antukh
916f055aec Merge pull request #5165 from penpot/alotor-bugfixing-3
Alotor bugfixing 3
2024-10-14 19:16:39 +02:00
alonso.torres
6d8c183160 Add plugins whitelisting for removing the disclaimer 2024-10-14 15:25:37 +02:00
alonso.torres
9d2f484aa3 🐛 Fix problem with horizontal/vertical lines and shadows 2024-10-14 15:25:37 +02:00
alonso.torres
2dc0cfdee3 🐛 Fix problem with caps and inner shadows 2024-10-14 15:25:37 +02:00
alonso.torres
a25abd0ca4 🐛 Fix percent calculation on grid layout tracks 2024-10-14 15:25:37 +02:00
alonso.torres
3a9119cf29 🐛 Add visual feedback when moving an element into a board 2024-10-14 15:25:37 +02:00
alonso.torres
c236e0765b 🐛 Fix problems with show in viewer and interactions 2024-10-14 15:25:37 +02:00
alonso.torres
f8fad95fef 🐛 Fix problem with shortcuts in text editor 2024-10-14 11:45:50 +02:00
alonso.torres
97ae295cb9 🐛 Fix problem updating layout when toggle visibility in component copy 2024-10-14 11:45:27 +02:00
Eva Marco
bd888dcde2 🐛 Fix constraints buttons 2024-10-14 11:41:26 +02:00
Andrey Antukh
784274f8ae Merge pull request #5163 from penpot/palba-bugfixing-011
Palba bugfixing 011
2024-10-14 11:40:45 +02:00
Pablo Alba
eda6c6a4c3 🐛 Fix "Done" button on toolbar on inspect mode should go to design mode 2024-10-11 14:36:57 +02:00
Pablo Alba
7d7594818c 🐛 Fix Internal Error page: "go to your penpot" wrong design 2024-10-11 14:35:56 +02:00
Andrey Antukh
7cc8f67e24 Merge pull request #5161 from penpot/niwinz-bugfix-6
🐛 Fix storybook build
2024-10-11 12:28:41 +02:00
Andrey Antukh
87fc3bbb8e 🐛 Fix storybook build 2024-10-11 12:11:37 +02:00
Andrey Antukh
bbb2cc972f Merge pull request #5159 from penpot/alotor-plugins-fixes-2
Plugins improvements
2024-10-11 09:04:29 +02:00
alonso.torres
6a07e6ae01 Add update plugin permission dialog 2024-10-10 17:12:39 +02:00
Andrey Antukh
87dfd2b3c8 🐛 Force sync update on storage before immediate refresh 2024-10-10 16:04:15 +02:00
Andrey Antukh
b0bfb8006d 💄 Add cosmetic changes to dashboard templates layer 2024-10-10 16:04:15 +02:00
Andrey Antukh
d46274abf2 Add better error reporting on zip file importation 2024-10-10 16:04:15 +02:00
Andrey Antukh
23f7889cff 💄 Add cosmetic change to create-temp-file rpc method 2024-10-10 16:04:15 +02:00
Andrey Antukh
534659cdc6 🐛 Fix flows import and export on zip format 2024-10-10 16:04:15 +02:00
alonso.torres
1e68d4ec87 Close plugin on esc button 2024-10-10 16:03:45 +02:00
alonso.torres
1779fd3e8b Fix zero case for plugins 2024-10-10 16:03:45 +02:00
alonso.torres
3c496ddd9d ⬆️ Update plugins runtime 2024-10-10 16:03:45 +02:00
Andrey Antukh
47bc9d8ef1 Merge pull request #5157 from penpot/alotor-bugfixing-2
Alotor bugfixing 2
2024-10-10 11:45:48 +02:00
alonso.torres
a3a5fe056d 📚 Update changelog 2024-10-10 11:45:16 +02:00
Eero Pitkänen
fbb3271c81 🐛 Fix dragging path points by returning closest point instead of only the distance 2024-10-10 11:45:16 +02:00
alonso.torres
ecc93d9246 🐛 Fix problem with precision on boolean calculation 2024-10-10 11:45:16 +02:00
alonso.torres
302672f5b0 🐛 Fix problem with hover layers when hidden/blocked 2024-10-10 11:45:16 +02:00
alonso.torres
4f16ea2d2d 🐛 Fix problem with stroke and filter ordering in frames 2024-10-10 11:45:12 +02:00
Andrey Antukh
b7a0b7d629 🐛 Increase feedback limits to reasonable values 2024-10-10 11:27:04 +02:00
Andrey Antukh
bd6f1bef10 🐛 Don't raise an unexpected exception on multiple-input enter
When a enter is pressed and field is empty
2024-10-10 11:27:04 +02:00
Andrey Antukh
c4941bb102 🐛 Fix unexpected exception on handling audit log on team invitations
A regression introduced in previous commits of this release
2024-10-10 11:27:04 +02:00
Andrey Antukh
b8a606a35f 🐛 Fix incorrect dependency for log-emails and smtp flags 2024-10-10 11:23:04 +02:00
Andrey Antukh
370eebeb64 🐛 Remove unused shadow config from exporter 2024-10-10 11:23:04 +02:00
Andrey Antukh
35bcb082a0 🐛 Remove data-testid usage from shape 2024-10-10 11:23:04 +02:00
Andrey Antukh
dd220e228e Merge pull request #5152 from penpot/alotor-fix-selection
🐛 Fix problem with selection
2024-10-09 13:50:51 +02:00
alonso.torres
7b63aa4a4f 🐛 Fix problem with selection 2024-10-09 13:34:33 +02:00
Andrey Antukh
33a07346dd 💄 Add minor cmd naming change for e2e test commands 2024-10-09 13:09:01 +02:00
Andrey Antukh
abd77559ab 🐛 Fix svg exportation with shapes with svg-unsafe characters in the name 2024-10-09 13:09:01 +02:00
Andrey Antukh
28878caca9 🐛 Fix cache issues with plugin runtime import uri 2024-10-09 13:09:01 +02:00
Andrey Antukh
74f3379b5d Merge pull request #5150 from penpot/alotor-bugfixing
Alotor bugfixing
2024-10-09 12:16:26 +02:00
alonso.torres
379770343a 🐛 Close plugin if open when installed 2024-10-09 10:50:56 +02:00
alonso.torres
6327286328 ⬆️ Update runtime 2024-10-09 09:39:47 +02:00
alonso.torres
3a2677a91a 🐛 Fix problem with shadows in text for Safari 2024-10-08 15:40:20 +02:00
alonso.torres
fcd232aa35 🐛 Fix problem with go back button on error page 2024-10-08 15:40:20 +02:00
alonso.torres
f194e2c1c6 📚 Updates changelog 2024-10-08 15:34:41 +02:00
Andrey Antukh
ea6731e22b Add EOF handling on sse response helper 2024-10-08 15:30:33 +02:00
Andrey Antukh
002b1679c3 ♻️ Clean assertion and schema chechking API 2024-10-08 15:30:33 +02:00
Andrey Antukh
45f3a67950 Relax transaction requeriments for team invitation creation 2024-10-08 14:51:14 +02:00
Andrey Antukh
c6917bb0cf Relax transaction requirements on create-team rpc method 2024-10-08 14:51:14 +02:00
Andrey Antukh
f777845d14 Relax transaction requirement on comment thread creation rpc method 2024-10-08 14:51:14 +02:00
Andrey Antukh
a1f5bcae80 ♻️ Add better ergonomics for the internal quotes API 2024-10-08 14:51:14 +02:00
Andrey Antukh
3e11b4aa74 Add facility for wrap a rpc method in a db transaction 2024-10-08 14:51:14 +02:00
Aitor Moreno
4f48236fee Merge pull request #5141 from penpot/niwinz-enhancements-text-editor-v2-2
 Add minor improvements to text editor v2 events handling
2024-10-07 12:58:17 +02:00
Andrey Antukh
ffadf29ad7 Add minor improvements to text editor v2 events handling
Also updates the editor code to the latest version
2024-10-07 10:13:21 +02:00
Aitor Moreno
352efcb610 Merge pull request #5139 from penpot/niwinz-enhancements-text-editor-v2
 Add minor improvements for text-editor-v2
2024-10-04 09:38:50 +02:00
Andrey Antukh
334e83479f Add minor improvements for text-editor-v2 2024-10-03 09:51:04 +02:00
Alejandro Alonso
476eedbd2c Merge remote-tracking branch 'origin/staging' into develop 2024-10-03 07:19:53 +02:00
Alejandro
ae7e28b71b Merge pull request #5137 from penpot/niwinz-enhancements-1
 Add limits for invitation creation RPC method
2024-10-03 07:18:18 +02:00
Andrey Antukh
be30174a49 Add limits for team invitations 2024-10-02 16:05:33 +02:00
Alejandro
8373654f80 Merge pull request #5134 from penpot/alotor-hotfix-2.3
Alotor hotfix 2.3
2024-10-02 13:57:05 +02:00
alonso.torres
471c636580 🐛 Fix visual problem with the font-size dropdown in assets 2024-10-02 13:45:50 +02:00
alonso.torres
635c6efe42 🐛 Fix problem with Ctrl+F shortcut on the dashboard 2024-10-02 13:45:30 +02:00
Alejandro
d570048f78 Merge pull request #5132 from penpot/niwinz-bugfix-1
🐛 Fix issues on migration 55
2024-10-02 13:36:43 +02:00
Andrey Antukh
dcc49dafd3 Merge pull request #5029 from penpot/azazeln28-refactor-text-editor
♻️ Refactor text editor
2024-10-02 11:05:26 +02:00
AzazelN28
7398f7ce0d ♻️ Replace Draft.js with custom editor 2024-10-01 22:31:16 +02:00
Andrey Antukh
76479a2486 🐛 Fix page background migration 2024-10-01 16:44:54 +02:00
Andrey Antukh
31f62dcc12 🐛 Fix incorrect flows conversion on migration 55 2024-10-01 16:34:22 +02:00
Andrey Antukh
3d7df5b005 Merge pull request #5115 from penpot/alotor-plugins
Plugins update
2024-10-01 12:53:03 +02:00
alonso.torres
c16a116707 Modifications after review 2024-10-01 11:57:52 +02:00
alonso.torres
f7f06f59ce ⬆️ Upgrade plugin runtime 2024-10-01 09:34:45 +02:00
alonso.torres
d1277afee6 New plugin install workflow 2024-09-30 16:03:40 +02:00
alonso.torres
a510d01136 Plugins api changes 2024-09-30 15:49:46 +02:00
alonso.torres
0e651df65f Updates permissions for comments 2024-09-30 15:20:34 +02:00
alonso.torres
758e0458bc 🐛 Fix problem when returning parent proxy 2024-09-30 15:20:34 +02:00
alonso.torres
e18b4666ba Update permissions dialog 2024-09-30 15:20:34 +02:00
128 changed files with 8197 additions and 3068 deletions

View File

@@ -111,7 +111,7 @@ jobs:
yarn run build:app:assets
clojure -M:dev:shadow-cljs release main
yarn playwright install --with-deps chromium
yarn e2e:test
yarn test:e2e
- run:
name: "backend tests"

View File

@@ -4,14 +4,58 @@
### :rocket: Epics and highlights
- **New plugin system.**
Penpot now supports custom plugins. Read everything about developing your plugins [HERE](https://help.penpot.app/plugins/)
### :boom: Breaking changes & Deprecations
### :heart: Community contributions (Thank you!)
- All our plugins beta testers :heart:.
- Fix problem when translating multiple path points by @eeropic [#4459](https://github.com/penpot/penpot/issues/4459)
### :sparkles: New features
- **Replace Draft.js completely with a custom editor** [Taiga #7706](https://tree.taiga.io/project/penpot/us/7706)
This refactor adds better IME support, more performant text editing
experience and a better clipboard support while keeping full
retrocompatibility with previous editor.
You can enable it with the `enable-feature-text-editor-v2` configuration flag.
### :bug: Bugs fixed
- Fix problem with constraints buttons [Taiga #8465](https://tree.taiga.io/project/penpot/issue/8465)
- Fix problem with go back button on error page [Taiga #8887](https://tree.taiga.io/project/penpot/issue/8887)
- Fix problem with shadows in text for Safari [Taiga #8770](https://tree.taiga.io/project/penpot/issue/8770)
- Fix a regression with feedback form subject and content limits [Taiga #8908](https://tree.taiga.io/project/penpot/issue/8908)
- Fix problem with stroke and filter ordering in frames [Github #5058](https://github.com/penpot/penpot/issues/5058)
- Fix problem with hover layers when hidden/blocked [Github #5074](https://github.com/penpot/penpot/issues/5074)
- Fix problem with precision on boolean calculation [Taiga #8482](https://tree.taiga.io/project/penpot/issue/8482)
- Fix problem when translating multiple path points [Github #4459](https://github.com/penpot/penpot/issues/4459)
- Fix problem on importing (and exporting) files with flows [Taiga #8914](https://tree.taiga.io/project/penpot/issue/8914)
- Fix Internal Error page: "go to your penpot" wrong design [Taiga #8922](https://tree.taiga.io/project/penpot/issue/8922)
- Fix problem updating layout when toggle visibility in component copy [Github #5143](https://github.com/penpot/penpot/issues/5143)
- Fix "Done" button on toolbar on inspect mode should go to design mode [Taiga #8933](https://tree.taiga.io/project/penpot/issue/8933)
- Fix problem with shortcuts in text editor [Github #5078](https://github.com/penpot/penpot/issues/5078)
- Fix problems with show in viewer and interactions [Github #4868](https://github.com/penpot/penpot/issues/4868)
- Add visual feedback when moving an element into a board [Github #3210](https://github.com/penpot/penpot/issues/3210)
- Fix percent calculation on grid layout tracks [Github #4688](https://github.com/penpot/penpot/issues/4688)
- Fix problem with caps and inner shadows [Github #4517](https://github.com/penpot/penpot/issues/4517)
- Fix problem with horizontal/vertical lines and shadows [Github #4516](https://github.com/penpot/penpot/issues/4516)
- Fix problem with layers overflowing panel [Taiga #9021](https://tree.taiga.io/project/penpot/issue/9021)
## 2.2.1
### :bug: Bugs fixed
- Fix problem with Ctrl+F shortcut on the dashboard [Taiga #8876](https://tree.taiga.io/project/penpot/issue/8876)
- Fix visual problem with the font-size dropdown in assets [Taiga #8872](https://tree.taiga.io/project/penpot/issue/8872)
- Add limits for invitation RPC methods (hard limit 25 emails per request)
## 2.2.0
### :rocket: Epics and highlights
@@ -148,7 +192,7 @@ time being.
### :boom: Breaking changes & Deprecations
### :heart: Community contributions (Thank you!)
### :heart: Communityq contributions (Thank you!)
### :sparkles: New features

View File

@@ -23,6 +23,7 @@ export PENPOT_FLAGS="\
enable-urepl-server \
enable-rpc-climit \
enable-rpc-rlimit \
enable-quotes \
enable-soft-rpc-rlimit \
enable-auto-file-snapshot \
enable-webhooks \

View File

@@ -17,6 +17,7 @@ export PENPOT_FLAGS="\
disable-secure-session-cookies \
enable-rpc-climit \
enable-smtp \
enable-quotes \
enable-file-snapshot \
enable-access-tokens \
enable-tiered-file-data-storage \

View File

@@ -315,15 +315,13 @@
(l/dbg :hint "sendmail"
:id (:id params)
:to (:to params)
:subject (str/trim (:subject params))
:body (str/join "," (map :type (:body params))))
:subject (str/trim (:subject params)))
(.sendMessage ^Transport transport
^MimeMessage message
(.getAllRecipients message))))))
(when (or (contains? cf/flags :log-emails)
(not (contains? cf/flags :smtp)))
(when (contains? cf/flags :log-emails)
(send-to-logger! cfg params))))
(defmethod ig/pre-init-spec ::handler [_]

View File

@@ -60,6 +60,9 @@
(try
(let [result (handler)]
(events/tap :end result))
(catch java.io.EOFException cause
(events/tap :error (errors/handle' cause request)))
(catch Throwable cause
(l/err :hint "unexpected error on processing sse response"
:cause cause)

View File

@@ -278,25 +278,18 @@
:inc 1)
message)
(def ^:private schema:params
[:map {:title "params"}
[:session-id ::sm/uuid]])
(def ^:private decode-params
(sm/decoder schema:params sm/json-transformer))
(def ^:private validate-params!
(sm/validate-fn schema:params))
(defn- http-handler
[cfg {:keys [params ::session/profile-id] :as request}]
(let [{:keys [session-id]} (-> params
decode-params
validate-params!)]
(let [session-id (some-> params :session-id sm/parse-uuid)]
(when-not (uuid? session-id)
(ex/raise :type :validation
:code :missing-session-id
:hint "missing or invalid session-id found"))
(cond
(not profile-id)
(ex/raise :type :authentication
:hint "Authentication required.")
:hint "authentication required")
;; WORKAROUND: we use the adapter specific predicate for
;; performance reasons; for now, the ring default impl for

View File

@@ -135,7 +135,7 @@
(l/dbg :hint "run webhook"
:event-name (:name event)
:webhook-id (:id whook)
:webhook-id (str (:id whook))
:webhook-uri (:uri whook)
:webhook-mtype (:mtype whook))

View File

@@ -149,6 +149,13 @@
:hint "authentication required for this endpoint")
(f cfg params)))))
(defn- wrap-db-transaction
[_ f mdata]
(if (::db/transaction mdata)
(fn [cfg params]
(db/tx-run! cfg f params))
f))
(defn- wrap-audit
[_ f mdata]
(if (or (contains? cf/flags :webhooks)
@@ -196,6 +203,7 @@
(defn- wrap-all
[cfg f mdata]
(as-> f $
(wrap-db-transaction cfg $ mdata)
(cond/wrap cfg $ mdata)
(retry/wrap-retry cfg $ mdata)
(climit/wrap cfg $ mdata)

View File

@@ -30,18 +30,17 @@
:tid token-id
:iat created-at})
expires-at (some-> expiration dt/in-future)]
(db/insert! conn :access-token
{:id token-id
:name name
:token token
:profile-id profile-id
:created-at created-at
:updated-at created-at
:expires-at expires-at
:perms (db/create-array conn "text" [])})))
expires-at (some-> expiration dt/in-future)
token (db/insert! conn :access-token
{:id token-id
:name name
:token token
:profile-id profile-id
:created-at created-at
:updated-at created-at
:expires-at expires-at
:perms (db/create-array conn "text" [])})]
(decode-row token)))
(defn repl:create-access-token
[{:keys [::db/pool] :as system} profile-id name expiration]
@@ -60,14 +59,12 @@
(sv/defmethod ::create-access-token
{::doc/added "1.18"
::sm/params schema:create-access-token}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id name expiration]}]
(db/with-atomic [conn pool]
(let [cfg (assoc cfg ::db/conn conn)]
(quotes/check-quote! conn
{::quotes/id ::quotes/access-tokens-per-profile
::quotes/profile-id profile-id})
(-> (create-access-token cfg profile-id name expiration)
(decode-row)))))
[cfg {:keys [::rpc/profile-id name expiration]}]
(quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
::quotes/profile-id profile-id})
(db/tx-run! cfg create-access-token profile-id name expiration))
(def ^:private schema:delete-access-token
[:map {:title "delete-access-token"}

View File

@@ -71,10 +71,15 @@
[conn comment-id & {:as opts}]
(db/get-by-id conn :comment comment-id opts))
(def ^:private sql:get-next-seqn
"SELECT (f.comment_thread_seqn + 1) AS next_seqn
FROM file AS f
WHERE f.id = ?
FOR UPDATE")
(defn- get-next-seqn
[conn file-id]
(let [sql "select (f.comment_thread_seqn + 1) as next_seqn from file as f where f.id = ?"
res (db/exec-one! conn [sql file-id])]
(let [res (db/exec-one! conn [sql:get-next-seqn file-id])]
(:next-seqn res)))
(def sql:upsert-comment-thread-status
@@ -304,38 +309,43 @@
::rtry/when rtry/conflict-exception?
::sm/params schema:create-comment-thread}
[cfg {:keys [::rpc/profile-id ::rpc/request-at file-id page-id share-id position content frame-id]}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(let [{:keys [team-id project-id page-name]} (get-file conn file-id page-id)]
(let [{:keys [team-id project-id page-name]} (get-file cfg file-id page-id)]
(run! (partial quotes/check-quote! cfg)
(list {::quotes/id ::quotes/comment-threads-per-file
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/project-id project-id
::quotes/file-id file-id}
{::quotes/id ::quotes/comments-per-file
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/project-id project-id
::quotes/file-id file-id}))
(-> cfg
(assoc ::quotes/profile-id profile-id)
(assoc ::quotes/team-id team-id)
(assoc ::quotes/project-id project-id)
(assoc ::quotes/file-id file-id)
(quotes/check! {::quotes/id ::quotes/comment-threads-per-file}
{::quotes/id ::quotes/comments-per-file}))
(create-comment-thread conn {:created-at request-at
:profile-id profile-id
:file-id file-id
:page-id page-id
:page-name page-name
:position position
:content content
:frame-id frame-id})))))
(let [params {:created-at request-at
:profile-id profile-id
:file-id file-id
:page-id page-id
:page-name page-name
:position position
:content content
:frame-id frame-id}
thread (db/tx-run! cfg create-comment-thread params)]
(vary-meta thread assoc ::audit/props thread))))
(defn- create-comment-thread
[conn {:keys [profile-id file-id page-id page-name created-at position content frame-id]}]
[{:keys [::db/conn] :as cfg}
{:keys [profile-id file-id page-id page-name created-at position content frame-id]}]
(let [;; NOTE: we take the next seq number from a separate query
;; because we need to lock the file for avoid race conditions
;; FIXME: this method touches and locks the file table,which
;; is already heavy-update tablel; we need to think on move
;; the sequence state management to a different table or
;; different storage (example: redis) for alivate the update
;; pression on the file table
(let [;; NOTE: we take the next seq number from a separate query because the whole
;; operation can be retried on conflict, and in this case the new seq shold be
;; retrieved from the database.
seqn (get-next-seqn conn file-id)
thread-id (uuid/next)
thread (db/insert! conn :comment-thread
@@ -364,7 +374,8 @@
;; Optimistic update of current seq number on file.
(db/update! conn :file
{:comment-thread-seqn seqn}
{:id file-id})
{:id file-id}
{::db/return-keys false})
(-> thread
(select-keys [:id :file-id :page-id])
@@ -387,7 +398,6 @@
(files/check-comment-permissions! conn profile-id file-id share-id)
(upsert-comment-thread-status! conn profile-id id)))))
;; --- COMMAND: Update Comment Thread
(def ^:private
@@ -432,12 +442,11 @@
{:keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)]
(files/check-comment-permissions! conn profile-id file-id share-id)
(quotes/check-quote! conn
{::quotes/id ::quotes/comments-per-file
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/project-id project-id
::quotes/file-id file-id})
(quotes/check! cfg {::quotes/id ::quotes/comments-per-file
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/project-id project-id
::quotes/file-id file-id})
;; Update the page-name cached attribute on comment thread table.
(when (not= page-name (:page-name thread))

View File

@@ -21,8 +21,8 @@
(def ^:private schema:send-user-feedback
[:map {:title "send-user-feedback"}
[:subject [:string {:max 250}]]
[:content [:string {:max 250}]]])
[:subject [:string {:max 400}]]
[:content [:string {:max 2500}]]])
(sv/defmethod ::send-user-feedback
{::doc/added "1.18"

View File

@@ -98,46 +98,49 @@
{::doc/added "1.17"
::doc/module :files
::webhooks/event? true
::sm/params schema:create-file}
[cfg {:keys [::rpc/profile-id project-id] :as params}]
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(projects/check-edition-permissions! conn profile-id project-id)
(let [team (teams/get-team conn
:profile-id profile-id
:project-id project-id)
team-id (:id team)
::sm/params schema:create-file
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id project-id] :as params}]
(projects/check-edition-permissions! conn profile-id project-id)
(let [team (teams/get-team conn
:profile-id profile-id
:project-id project-id)
team-id (:id team)
;; When we create files, we only need to respect the team
;; features, because some features can be enabled
;; globally, but the team is still not migrated properly.
features (-> (cfeat/get-team-enabled-features cf/flags team)
(cfeat/check-client-features! (:features params)))
;; When we create files, we only need to respect the team
;; features, because some features can be enabled
;; globally, but the team is still not migrated properly.
features (-> (cfeat/get-team-enabled-features cf/flags team)
(cfeat/check-client-features! (:features params)))
;; We also include all no migration features declared by
;; client; that enables the ability to enable a runtime
;; feature on frontend and make it permanent on file
features (-> (:features params #{})
(set/intersection cfeat/no-migration-features)
(set/union features))
;; We also include all no migration features declared by
;; client; that enables the ability to enable a runtime
;; feature on frontend and make it permanent on file
features (-> (:features params #{})
(set/intersection cfeat/no-migration-features)
(set/union features))
params (-> params
(assoc :profile-id profile-id)
(assoc :features features))]
params (-> params
(assoc :profile-id profile-id)
(assoc :features features))]
(run! (partial quotes/check-quote! conn)
(list {::quotes/id ::quotes/files-per-project
::quotes/team-id team-id
::quotes/profile-id profile-id
::quotes/project-id project-id}))
(quotes/check! cfg {::quotes/id ::quotes/files-per-project
::quotes/team-id team-id
::quotes/profile-id profile-id
::quotes/project-id project-id})
;; When newly computed features does not match exactly with
;; the features defined on team row, we update it.
(when (not= features (:features team))
(let [features (db/create-array conn "text" features)]
(db/update! conn :team
{:features features}
{:id team-id})))
;; FIXME: IMPORTANT: this code can have race
;; conditions, because we have no locks for updating
;; team so, creating two files concurrently can lead
;; to lost team features updating
(-> (create-file cfg params)
(vary-meta assoc ::audit/props {:team-id team-id}))))))
;; When newly computed features does not match exactly with
;; the features defined on team row, we update it.
(when (not= features (:features team))
(let [features (db/create-array conn "text" features)]
(db/update! conn :team
{:features features}
{:id team-id})))
(-> (create-file cfg params)
(vary-meta assoc ::audit/props {:team-id team-id}))))

View File

@@ -45,37 +45,38 @@
(sv/defmethod ::create-temp-file
{::doc/added "1.17"
::doc/module :files
::sm/params schema:create-temp-file}
[cfg {:keys [::rpc/profile-id project-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(projects/check-edition-permissions! conn profile-id project-id)
(let [team (teams/get-team conn :profile-id profile-id :project-id project-id)
::sm/params schema:create-temp-file
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id project-id] :as params}]
(projects/check-edition-permissions! conn profile-id project-id)
(let [team (teams/get-team conn :profile-id profile-id :project-id project-id)
;; When we create files, we only need to respect the team
;; features, because some features can be enabled
;; globally, but the team is still not migrated properly.
input-features
(:features params #{})
;; When we create files, we only need to respect the team
;; features, because some features can be enabled
;; globally, but the team is still not migrated properly.
input-features (:features params #{})
;; If the imported project doesn't contain v2 we need to remove it
team-features
(cond-> (cfeat/get-team-enabled-features cf/flags team)
(not (contains? input-features "components/v2"))
(disj "components/v2"))
;; If the imported project doesn't contain v2 we need to remove it
team-features
(cond-> (cfeat/get-team-enabled-features cf/flags team)
(not (contains? input-features "components/v2"))
(disj "components/v2"))
;; We also include all no migration features declared by
;; client; that enables the ability to enable a runtime
;; feature on frontend and make it permanent on file
features
(-> input-features
(set/intersection cfeat/no-migration-features)
(set/union team-features))
params
(-> params
(assoc :profile-id profile-id)
(assoc :deleted-at (dt/in-future {:days 1}))
(assoc :features features))]
;; We also include all no migration features declared by
;; client; that enables the ability to enable a runtime
;; feature on frontend and make it permanent on file
features (-> input-features
(set/intersection cfeat/no-migration-features)
(set/union team-features))
params (-> params
(assoc :profile-id profile-id)
(assoc :deleted-at (dt/in-future {:days 1}))
(assoc :features features))]
(files.create/create-file cfg params)))))
(files.create/create-file cfg params)))
;; --- MUTATION COMMAND: update-temp-file

View File

@@ -86,6 +86,9 @@
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
[:font-style [::sm/one-of {:format "string"} valid-style]]])
;; FIXME: IMPORTANT: refactor this, we should not hold a whole db
;; connection around the font creation
(sv/defmethod ::create-font-variant
{::doc/added "1.18"
::climit/id [[:process-font/by-profile ::rpc/profile-id]
@@ -96,9 +99,9 @@
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(teams/check-edition-permissions! conn profile-id team-id)
(quotes/check-quote! conn {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(create-font-variant cfg (assoc params :profile-id profile-id)))))
(defn create-font-variant

View File

@@ -168,6 +168,17 @@
;; --- MUTATION: Create Project
(defn- create-project
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}]
(let [project (teams/create-project conn params)]
(teams/create-project-role conn profile-id (:id project) :owner)
(db/insert! conn :team-project-profile-rel
{:project-id (:id project)
:profile-id profile-id
:team-id team-id
:is-pinned false})
(assoc project :is-pinned false)))
(def ^:private schema:create-project
[:map {:title "create-project"}
[:team-id ::sm/uuid]
@@ -178,23 +189,15 @@
{::doc/added "1.18"
::webhooks/event? true
::sm/params schema:create-project}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
(db/with-atomic [conn pool]
(teams/check-edition-permissions! conn profile-id team-id)
(quotes/check-quote! conn {::quotes/id ::quotes/projects-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
[cfg {:keys [::rpc/profile-id team-id] :as params}]
(let [params (assoc params :profile-id profile-id)
project (teams/create-project conn params)]
(teams/create-project-role conn profile-id (:id project) :owner)
(db/insert! conn :team-project-profile-rel
{:project-id (:id project)
:profile-id profile-id
:team-id team-id
:is-pinned false})
(assoc project :is-pinned false))))
(teams/check-edition-permissions! cfg profile-id team-id)
(quotes/check! cfg {::quotes/id ::quotes/projects-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(let [params (assoc params :profile-id profile-id)]
(db/tx-run! cfg create-project params)))
;; --- MUTATION: Toggle Project Pin

View File

@@ -82,19 +82,17 @@
(cond-> row
(some? features) (assoc :features (db/decode-pgarray features #{}))))
(defn- check-valid-email-muted
"Check if the member's email is part of the global bounce report."
(defn- check-profile-muted
"Check if the member's email is part of the global bounce report"
[conn member]
(let [email (profile/clean-email (:email member))]
(let [email (profile/clean-email (:email member))]
(when (and member (not (eml/allow-send-emails? conn member)))
(ex/raise :type :validation
:code :member-is-muted
:email email
:hint "the profile has reported repeatedly as spam or has bounces"))))
(defn- check-valid-email-bounce
(defn- check-email-bounce
"Check if the email is part of the global complain report"
[conn email show?]
(when (eml/has-bounce-reports? conn email)
@@ -103,7 +101,7 @@
:email (if show? email "private")
:hint "this email has been repeatedly reported as bounce")))
(defn- check-valid-email-spam
(defn- check-email-spam
"Check if the member email is part of the global complain report"
[conn email show?]
(when (eml/has-complaint-reports? conn email)
@@ -227,16 +225,16 @@
;; --- Query: Team Members
(def sql:team-members
"select tp.*,
"SELECT tp.*,
p.id,
p.email,
p.fullname as name,
p.fullname as fullname,
p.fullname AS name,
p.fullname AS fullname,
p.photo_id,
p.is_active
from team_profile_rel as tp
join profile as p on (p.id = tp.profile_id)
where tp.team_id = ?")
FROM team_profile_rel AS tp
JOIN profile AS p ON (p.id = tp.profile_id)
WHERE tp.team_id = ?")
(defn get-team-members
[conn team-id]
@@ -403,17 +401,19 @@
{::doc/added "1.17"
::sm/params schema:create-team}
[cfg {:keys [::rpc/profile-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(quotes/check-quote! conn {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id})
(let [features (-> (cfeat/get-enabled-features cf/flags)
(cfeat/check-client-features! (:features params)))
team (create-team cfg (assoc params
:profile-id profile-id
:features features))]
(with-meta team
{::audit/props {:id (:id team)}})))))
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id})
(let [features (-> (cfeat/get-enabled-features cf/flags)
(cfeat/check-client-features! (:features params)))
params (-> params
(assoc :profile-id profile-id)
(assoc :features features))
team (db/tx-run! cfg create-team params)]
(with-meta team
{::audit/props {:id (:id team)}})))
(defn create-team
"This is a complete team creation process, it creates the team
@@ -767,21 +767,51 @@
:member-id member-id}))
(defn- create-profile-identity-token
[cfg profile]
[cfg profile-id]
(dm/assert!
"expected valid uuid for profile-id"
(uuid? profile-id))
(tokens/generate (::setup/props cfg)
{:iss :profile-identity
:profile-id (:id profile)
:profile-id profile-id
:exp (dt/in-future {:days 30})}))
(def ^:private schema:create-invitation
[:map {:title "params:create-invitation"}
[::rpc/profile-id ::sm/uuid]
[:team
[:map
[:id ::sm/uuid]
[:name :string]]]
[:profile
[:map
[:id ::sm/uuid]
[:fullname :string]]]
[:role [::sm/one-of valid-roles]]
[:email ::sm/email]])
(def ^:private check-create-invitation-params!
(sm/check-fn schema:create-invitation))
(defn- create-invitation
[{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}]
(dm/assert!
"expected valid connection on cfg parameter"
(db/connection? conn))
(dm/assert!
"expected valid params for `create-invitation` fn"
(check-create-invitation-params! params))
(let [email (profile/clean-email email)
member (profile/get-profile-by-email conn email)]
(check-valid-email-muted conn member)
(check-valid-email-bounce conn email true)
(check-valid-email-spam conn email true)
(check-profile-muted conn member)
(check-email-bounce conn email true)
(check-email-spam conn email true)
;; When we have email verification disabled and invitation user is
;; already present in the database, we proceed to add it to the
@@ -815,7 +845,8 @@
(name role) expire
(name role) expire])
updated? (not= id (:id invitation))
tprops {:profile-id (:id profile)
profile-id (:id profile)
tprops {:profile-id profile-id
:invitation-id (:id invitation)
:valid-until expire
:team-id (:id team)
@@ -823,12 +854,11 @@
:member-id (:id member)
:role role}
itoken (create-invitation-token cfg tprops)
ptoken (create-profile-identity-token cfg profile)]
ptoken (create-profile-identity-token cfg profile-id)]
(when (contains? cf/flags :log-invitation-tokens)
(l/info :hint "invitation token" :token itoken))
(let [props (-> (dissoc tprops :profile-id)
(audit/clean-props))
evname (if updated?
@@ -851,26 +881,27 @@
itoken))))
(defn- add-user-to-team
[conn profile team email role]
[conn profile team role email]
(let [team-id (:id team)
member (db/get* conn :profile
{:email (str/lower email)}
{::sql/columns [:id :email]})
params (merge
{:team-id team-id
:profile-id (:id member)}
(role->params role))]
member (db/get* conn :profile
{:email (str/lower email)}
{::sql/columns [:id :email]})
params (merge
{:team-id team-id
:profile-id (:id member)}
(role->params role))]
;; Do not allow blocked users to join teams.
(when (:is-blocked member)
(ex/raise :type :restriction
:code :profile-blocked))
(quotes/check-quote! conn
{::quotes/id ::quotes/profiles-per-team
::quotes/profile-id (:id member)
::quotes/team-id team-id})
(quotes/check!
{::db/conn conn
::quotes/id ::quotes/profiles-per-team
::quotes/profile-id (:id member)
::quotes/team-id team-id})
;; Insert the member to the team
(db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true})
@@ -902,68 +933,89 @@
[conn team-id]
(db/exec! conn [sql:valid-requests-email team-id]))
(def ^:private xf:map-email
(map :email))
(defn- create-team-invitations
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails] :as params}]
(let [join-requests (into #{} xf:map-email
(get-valid-requests-email conn (:id team)))
team-members (into #{} xf:map-email
(get-team-members conn (:id team)))
invitations (into #{}
(comp
;; We don't re-send inviation to
;; already existing members
(remove team-members)
;; We don't send invitations to
;; join-requested members
(remove join-requests)
(map (fn [email] (assoc params :email email)))
(keep (partial create-invitation cfg)))
emails)]
;; For requested invitations, do not send invitation emails, add
;; the user directly to the team
(->> (filter join-requests emails)
(run! (partial add-user-to-team conn profile team role)))
invitations))
(def ^:private schema:create-team-invitations
[:map {:title "create-team-invitations"}
[:team-id ::sm/uuid]
[:role schema:role]
[:emails [::sm/set ::sm/email]]])
(def ^:private max-invitations-by-request-threshold
"The number of invitations can be sent in a single rpc request"
25)
(sv/defmethod ::create-team-invitations
"A rpc call that allow to send a single or multiple invitations to
join the team."
{::doc/added "1.17"
::sm/params schema:create-team-invitations}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id emails role] :as params}]
(db/with-atomic [conn pool]
(let [perms (get-permissions conn profile-id team-id)
profile (db/get-by-id conn :profile profile-id)
team (db/get-by-id conn :team team-id)
emails (into #{} (map profile/clean-email) emails)]
[cfg {:keys [::rpc/profile-id team-id emails] :as params}]
(let [perms (get-permissions cfg profile-id team-id)
profile (db/get-by-id cfg :profile profile-id)
emails (into #{} (map profile/clean-email) emails)]
(run! (partial quotes/check-quote! conn)
(list {::quotes/id ::quotes/invitations-per-team
::quotes/profile-id profile-id
::quotes/team-id (:id team)
::quotes/incr (count emails)}
{::quotes/id ::quotes/profiles-per-team
::quotes/profile-id profile-id
::quotes/team-id (:id team)
::quotes/incr (count emails)}))
(when-not (:is-admin perms)
(ex/raise :type :validation
:code :insufficient-permissions))
(when-not (:is-admin perms)
(ex/raise :type :validation
:code :insufficient-permissions))
(when (> (count emails) max-invitations-by-request-threshold)
(ex/raise :type :validation
:code :max-invitations-by-request
:hint "the maximum of invitation on single request is reached"
:threshold max-invitations-by-request-threshold))
;; Check if the current profile is allowed to send emails.
(check-valid-email-muted conn profile)
(-> cfg
(assoc ::quotes/profile-id profile-id)
(assoc ::quotes/team-id team-id)
(assoc ::quotes/incr (count emails))
(quotes/check! {::quotes/id ::quotes/invitations-per-team}
{::quotes/id ::quotes/profiles-per-team}))
;; Check if the current profile is allowed to send emails
(check-profile-muted cfg profile)
(let [requested (into #{} (map :email) (get-valid-requests-email conn team-id))
emails-to-add (filter #(contains? requested %) emails)
emails (remove #(contains? requested %) emails)
cfg (assoc cfg ::db/conn conn)
members (->> (db/exec! conn [sql:team-members team-id])
(into #{} (map :email)))
(let [team (db/get-by-id cfg :team team-id)
;; NOTE: Is important pass RPC method params down to the
;; `create-team-invitations` because it uses the implicit
;; RPC properties from params for fill necessary data on
;; emiting an entry to the audit-log
invitations (db/tx-run! cfg create-team-invitations
(-> params
(assoc :profile profile)
(assoc :team team)
(assoc :emails emails)))]
invitations (into #{}
(comp
;; We don't re-send inviation to already existing members
(remove (partial contains? members))
(map (fn [email]
(-> params
(assoc :email email)
(assoc :team team)
(assoc :profile profile)
(assoc :role role))))
(keep (partial create-invitation cfg)))
emails)]
;; For requested invitations, do not send invitation emails, add the user directly to the team
(doseq [email emails-to-add]
(add-user-to-team conn profile team email role))
(with-meta {:total (count invitations)
:invitations invitations}
{::audit/props {:invitations (count invitations)}})))))
(with-meta {:total (count invitations)
:invitations invitations}
{::audit/props {:invitations (count invitations)}}))))
;; --- Mutation: Create Team & Invite Members
@@ -977,52 +1029,50 @@
(sv/defmethod ::create-team-with-invitations
{::doc/added "1.17"
::sm/params schema:create-team-with-invitations}
[cfg {:keys [::rpc/profile-id emails role name] :as params}]
::sm/params schema:create-team-with-invitations
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id emails role name] :as params}]
(let [features (-> (cfeat/get-enabled-features cf/flags)
(cfeat/check-client-features! (:features params)))
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [features (-> (cfeat/get-enabled-features cf/flags)
(cfeat/check-client-features! (:features params)))
params (-> params
(assoc :profile-id profile-id)
(assoc :features features))
params (-> params
(assoc :profile-id profile-id)
(assoc :features features))
team (create-team cfg params)
emails (into #{} (map profile/clean-email) emails)]
cfg (assoc cfg ::db/conn conn)
team (create-team cfg params)
profile (db/get-by-id conn :profile profile-id)
emails (into #{} (map profile/clean-email) emails)]
(-> cfg
(assoc ::quotes/profile-id profile-id)
(assoc ::quotes/team-id (:id team))
(assoc ::quotes/incr (count emails))
(quotes/check! {::quotes/id ::quotes/teams-per-profile}
{::quotes/id ::quotes/invitations-per-team}
{::quotes/id ::quotes/profiles-per-team}))
(let [props {:name name :features features}
event (-> (audit/event-from-rpc-params params)
(assoc ::audit/name "create-team")
(assoc ::audit/props props))]
(audit/submit! cfg event))
(when (> (count emails) max-invitations-by-request-threshold)
(ex/raise :type :validation
:code :max-invitations-by-request
:hint "the maximum of invitation on single request is reached"
:threshold max-invitations-by-request-threshold))
;; Create invitations for all provided emails.
(->> emails
(map (fn [email]
(-> params
(assoc :team team)
(assoc :profile profile)
(assoc :email email)
(assoc :role role))))
(run! (partial create-invitation cfg)))
(let [props {:name name :features features}
event (-> (audit/event-from-rpc-params params)
(assoc ::audit/name "create-team")
(assoc ::audit/props props))]
(audit/submit! cfg event))
(run! (partial quotes/check-quote! conn)
(list {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id}
{::quotes/id ::quotes/invitations-per-team
::quotes/profile-id profile-id
::quotes/team-id (:id team)
::quotes/incr (count emails)}
{::quotes/id ::quotes/profiles-per-team
::quotes/profile-id profile-id
::quotes/team-id (:id team)
::quotes/incr (count emails)}))
;; Create invitations for all provided emails.
(let [profile (db/get-by-id conn :profile profile-id)
params (-> params
(assoc :team team)
(assoc :profile profile)
(assoc :role role))
invitations (->> emails
(map (fn [email] (assoc params :email email)))
(map (partial create-invitation cfg)))]
(vary-meta team assoc ::audit/props {:invitations (count emails)})))))
(vary-meta team assoc ::audit/props {:invitations (count invitations)}))))
;; --- Query: get-team-invitation-token
@@ -1215,11 +1265,11 @@
:code :invalid-parameters))
;; Check that the requester is not muted
(check-valid-email-muted conn requester)
(check-profile-muted conn requester)
;; Check that the owner is not marked as bounce nor spam
(check-valid-email-bounce conn (:email team-owner) false)
(check-valid-email-spam conn (:email team-owner) true)
(check-email-bounce conn (:email team-owner) false)
(check-email-spam conn (:email team-owner) true)
(let [request (create-team-access-request
cfg {:team team :requester requester :team-owner team-owner :file file :is-viewer is-viewer})]

View File

@@ -37,14 +37,12 @@
::doc/added "1.15"
::doc/module :auth
::sm/params schema:verify-token}
[{:keys [::db/pool] :as cfg} {:keys [token] :as params}]
(db/with-atomic [conn pool]
(let [claims (tokens/verify (::setup/props cfg) {:token token})
cfg (assoc cfg :conn conn)]
(process-token cfg params claims))))
[cfg {:keys [token] :as params}]
(let [claims (tokens/verify (::setup/props cfg) {:token token})]
(db/tx-run! cfg process-token params claims)))
(defmethod process-token :change-email
[{:keys [conn] :as cfg} _params {:keys [profile-id email] :as claims}]
[{:keys [::db/conn] :as cfg} _params {:keys [profile-id email] :as claims}]
(let [email (profile/clean-email email)]
(when (profile/get-profile-by-email conn email)
(ex/raise :type :validation
@@ -60,7 +58,7 @@
::audit/profile-id profile-id})))
(defmethod process-token :verify-email
[{:keys [conn] :as cfg} _ {:keys [profile-id] :as claims}]
[{:keys [::db/conn] :as cfg} _ {:keys [profile-id] :as claims}]
(let [profile (profile/get-profile conn profile-id)
claims (assoc claims :profile profile)]
@@ -81,14 +79,14 @@
::audit/profile-id (:id profile)}))))
(defmethod process-token :auth
[{:keys [conn] :as cfg} _params {:keys [profile-id] :as claims}]
[{:keys [::db/conn] :as cfg} _params {:keys [profile-id] :as claims}]
(let [profile (profile/get-profile conn profile-id)]
(assoc claims :profile profile)))
;; --- Team Invitation
(defn- accept-invitation
[{:keys [conn] :as cfg} {:keys [team-id role member-email] :as claims} invitation member]
[{:keys [::db/conn] :as cfg} {:keys [team-id role member-email] :as claims} invitation member]
(let [;; Update the role if there is an invitation
role (or (some-> invitation :role keyword) role)
params (merge
@@ -101,10 +99,9 @@
(ex/raise :type :restriction
:code :profile-blocked))
(quotes/check-quote! conn
{::quotes/id ::quotes/profiles-per-team
::quotes/profile-id (:id member)
::quotes/team-id team-id})
(quotes/check! cfg {::quotes/id ::quotes/profiles-per-team
::quotes/profile-id (:id member)
::quotes/team-id team-id})
;; Insert the invited member to the team
(db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true})
@@ -140,7 +137,7 @@
(sm/lazy-validator schema:team-invitation-claims))
(defmethod process-token :team-invitation
[{:keys [conn] :as cfg}
[{:keys [::db/conn] :as cfg}
{:keys [::rpc/profile-id token] :as params}
{:keys [member-id team-id member-email] :as claims}]

View File

@@ -7,16 +7,13 @@
(ns app.rpc.quotes
"Penpot resource usage quotes."
(:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.spec :as us]
[app.config :as cf]
[app.db :as db]
[app.util.time :as dt]
[app.worker :as wrk]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]))
(defmulti check-quote ::id)
@@ -34,6 +31,9 @@
[::id :keyword]
[::profile-id ::sm/uuid]])
(def valid-quote?
(sm/lazy-validator schema:quote))
(def ^:private enabled (volatile! true))
(defn enable!
@@ -46,20 +46,31 @@
[]
(vswap! enabled (constantly false)))
(defn check-quote!
[ds quote]
(dm/assert!
"expected valid quote map"
(sm/validate schema:quote quote))
(defn- check
[cfg quote]
(let [quote (merge cfg quote)
id (::id quote)]
(when (contains? cf/flags :quotes)
(when @enabled
;; This approach add flexibility on how and where the
;; check-quote! can be called (in or out of transaction)
(db/run! ds (fn [cfg]
(-> (merge cfg quote)
(assoc ::target (name (::id quote)))
(check-quote)))))))
(when-not (valid-quote? quote)
(ex/raise :type :internal
:code :invalid-quote-definition
:hint "found invalid data for quote schema"
:quote (name id)))
(-> (assoc quote ::target (name id))
(check-quote))))
(defn check!
([cfg]
(when (contains? cf/flags :quotes)
(when @enabled
(db/run! cfg check {}))))
([cfg & others]
(when (contains? cf/flags :quotes)
(when @enabled
(db/run! cfg (fn [cfg]
(run! (partial check cfg) others)))))))
(defn- send-notification!
[{:keys [::db/conn] :as params}]
@@ -100,7 +111,7 @@
(map :quote)
(reduce max (- Integer/MAX_VALUE)))
quote (if (pos? quote) quote default)
total (->> (db/exec! conn count-sql) first :total)]
total (:total (db/exec-one! conn count-sql))]
(when (> (+ total incr) quote)
(if (contains? cf/flags :soft-quotes)
@@ -112,72 +123,81 @@
:count total)))))
(def ^:private sql:get-quotes-1
"select id, quote from usage_quote
where target = ?
and profile_id = ?
and team_id is null
and project_id is null
and file_id is null;")
"SELECT id, quote
FROM usage_quote
WHERE target = ?
AND profile_id = ?
AND team_id IS NULL
AND project_id IS NULL
AND file_id IS NULL;")
(def ^:private sql:get-quotes-2
"select id, quote from usage_quote
where target = ?
and ((team_id = ? and (profile_id = ? or profile_id is null)) or
(profile_id = ? and team_id is null and project_id is null and file_id is null));")
"SELECT id, quote
FROM usage_quote
WHERE target = ?
AND ((team_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
(profile_id = ? AND team_id IS NULL AND project_id IS NULL AND file_id IS NULL));")
(def ^:private sql:get-quotes-3
"select id, quote from usage_quote
where target = ?
and ((project_id = ? and (profile_id = ? or profile_id is null)) or
(team_id = ? and (profile_id = ? or profile_id is null)) or
(profile_id = ? and team_id is null and project_id is null and file_id is null));")
"SELECT id, quote
FROM usage_quote
WHERE target = ?
AND ((project_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
(team_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
(profile_id = ? AND team_id IS NULL AND project_id IS NULL AND file_id IS NULL));")
(def ^:private sql:get-quotes-4
"select id, quote from usage_quote
where target = ?
and ((file_id = ? and (profile_id = ? or profile_id is null)) or
(project_id = ? and (profile_id = ? or profile_id is null)) or
(team_id = ? and (profile_id = ? or profile_id is null)) or
(profile_id = ? and team_id is null and project_id is null and file_id is null));")
"SELECT id, quote
FROM usage_quote
WHERE target = ?
AND ((file_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
(project_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
(team_id = ? AND (profile_id = ? OR profile_id IS NULL)) OR
(profile_id = ? AND team_id IS NULL AND project_id IS NULL AND file_id IS NULL));")
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: TEAMS-PER-PROFILE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-teams-per-profile
"select count(*) as total
from team_profile_rel
where profile_id = ?")
(def ^:private schema:teams-per-profile
[:map [::profile-id ::sm/uuid]])
(s/def ::profile-id ::us/uuid)
(s/def ::teams-per-profile
(s/keys :req [::profile-id ::target]))
(def ^:private valid-teams-per-profile-quote?
(sm/lazy-validator schema:teams-per-profile))
(def ^:private sql:get-teams-per-profile
"SELECT count(*) AS total
FROM team_profile_rel
WHERE profile_id = ?")
(defmethod check-quote ::teams-per-profile
[{:keys [::profile-id ::target] :as quote}]
(us/assert! ::teams-per-profile quote)
(assert (valid-teams-per-profile-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-teams-per-profile Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-1 target profile-id])
(assoc ::count-sql [sql:get-teams-per-profile profile-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: ACCESS-TOKENS-PER-PROFILE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-access-tokens-per-profile
"select count(*) as total
from access_token
where profile_id = ?")
(def ^:private schema:access-tokens-per-profile
[:map [::profile-id ::sm/uuid]])
(s/def ::access-tokens-per-profile
(s/keys :req [::profile-id ::target]))
(def ^:private valid-access-tokens-per-profile-quote?
(sm/lazy-validator schema:access-tokens-per-profile))
(def ^:private sql:get-access-tokens-per-profile
"SELECT count(*) AS total
FROM access_token
WHERE profile_id = ?")
(defmethod check-quote ::access-tokens-per-profile
[{:keys [::profile-id ::target] :as quote}]
(us/assert! ::access-tokens-per-profile quote)
(assert (valid-access-tokens-per-profile-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-access-tokens-per-profile Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-1 target profile-id])
@@ -188,40 +208,51 @@
;; QUOTE: PROJECTS-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-projects-per-team
"select count(*) as total
from project as p
where p.team_id = ?
and p.deleted_at is null")
(def ^:private schema:projects-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(s/def ::team-id ::us/uuid)
(s/def ::projects-per-team
(s/keys :req [::profile-id ::team-id ::target]))
(def ^:private valid-projects-per-team-quote?
(sm/lazy-validator schema:projects-per-team))
(def ^:private sql:get-projects-per-team
"SELECT count(*) AS total
FROM project AS p
WHERE p.team_id = ?
AND p.deleted_at IS NULL")
(defmethod check-quote ::projects-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(assert (valid-projects-per-team-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-projects-per-team Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
(assoc ::count-sql [sql:get-projects-per-team team-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: FONT-VARIANTS-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-font-variants-per-team
"select count(*) as total
from team_font_variant as v
where v.team_id = ?")
(def ^:private schema:font-variants-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(s/def ::font-variants-per-team
(s/keys :req [::profile-id ::team-id ::target]))
(def ^:private valid-font-variant-per-team-quote?
(sm/lazy-validator schema:font-variants-per-team))
(def ^:private sql:get-font-variants-per-team
"SELECT count(*) AS total
FROM team_font_variant AS v
WHERE v.team_id = ?")
(defmethod check-quote ::font-variants-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(us/assert! ::font-variants-per-team quote)
(assert (valid-font-variant-per-team-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-font-variants-per-team Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
@@ -233,70 +264,86 @@
;; QUOTE: INVITATIONS-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-invitations-per-team
"select count(*) as total
from team_invitation
where team_id = ?")
(def ^:private schema:invitations-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(s/def ::invitations-per-team
(s/keys :req [::profile-id ::team-id ::target]))
(def ^:private valid-invitations-per-team-quote?
(sm/lazy-validator schema:invitations-per-team))
(def ^:private sql:get-invitations-per-team
"SELECT count(*) AS total
FROM team_invitation
WHERE team_id = ?")
(defmethod check-quote ::invitations-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(us/assert! ::invitations-per-team quote)
(assert (valid-invitations-per-team-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-invitations-per-team Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
(assoc ::count-sql [sql:get-invitations-per-team team-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: PROFILES-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:profiles-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-profiles-per-team-quote?
(sm/lazy-validator schema:profiles-per-team))
(def ^:private sql:get-profiles-per-team
"select (select count(*)
from team_profile_rel
where team_id = ?) +
(select count(*)
from team_invitation
where team_id = ?
and valid_until > now()) as total;")
"SELECT (SELECT count(*)
FROM team_profile_rel
WHERE team_id = ?) +
(SELECT count(*)
FROM team_invitation
WHERE team_id = ?
AND valid_until > now()) AS total;")
;; NOTE: the total number of profiles is determined by the number of
;; effective members plus ongoing valid invitations.
(s/def ::profiles-per-team
(s/keys :req [::profile-id ::team-id ::target]))
(defmethod check-quote ::profiles-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(us/assert! ::profiles-per-team quote)
(assert (valid-profiles-per-team-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-profiles-per-team Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
(assoc ::count-sql [sql:get-profiles-per-team team-id team-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: FILES-PER-PROJECT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-files-per-project
"select count(*) as total
from file as f
where f.project_id = ?
and f.deleted_at is null")
(def ^:private schema:files-per-project
[:map
[::profile-id ::sm/uuid]
[::project-id ::sm/uuid]
[::team-id ::sm/uuid]])
(s/def ::project-id ::us/uuid)
(s/def ::files-per-project
(s/keys :req [::profile-id ::project-id ::team-id ::target]))
(def ^:private valid-files-per-project-quote?
(sm/lazy-validator schema:files-per-project))
(def ^:private sql:get-files-per-project
"SELECT count(*) AS total
FROM file AS f
WHERE f.project_id = ?
AND f.deleted_at IS NULL")
(defmethod check-quote ::files-per-project
[{:keys [::profile-id ::project-id ::team-id ::target] :as quote}]
(us/assert! ::files-per-project quote)
(assert (valid-files-per-project-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-files-per-project Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-3 target project-id profile-id team-id profile-id profile-id])
@@ -307,17 +354,24 @@
;; QUOTE: COMMENT-THREADS-PER-FILE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-comment-threads-per-file
"select count(*) as total
from comment_thread as ct
where ct.file_id = ?")
(def ^:private schema:comment-threads-per-file
[:map
[::profile-id ::sm/uuid]
[::project-id ::sm/uuid]
[::team-id ::sm/uuid]])
(s/def ::comment-threads-per-file
(s/keys :req [::profile-id ::project-id ::team-id ::target]))
(def ^:private valid-comment-threads-per-file-quote?
(sm/lazy-validator schema:comment-threads-per-file))
(def ^:private sql:get-comment-threads-per-file
"SELECT count(*) AS total
FROM comment_thread AS ct
WHERE ct.file_id = ?")
(defmethod check-quote ::comment-threads-per-file
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
(us/assert! ::files-per-project quote)
(assert (valid-comment-threads-per-file-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-comment-threads-per-file Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id
@@ -325,23 +379,28 @@
(assoc ::count-sql [sql:get-comment-threads-per-file file-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: COMMENTS-PER-FILE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:get-comments-per-file
"select count(*) as total
from comment as c
join comment_thread as ct on (ct.id = c.thread_id)
where ct.file_id = ?")
(def ^:private schema:comments-per-file
[:map
[::profile-id ::sm/uuid]
[::project-id ::sm/uuid]
[::team-id ::sm/uuid]])
(s/def ::comments-per-file
(s/keys :req [::profile-id ::project-id ::team-id ::target]))
(def ^:private valid-comments-per-file-quote?
(sm/lazy-validator schema:comments-per-file))
(def ^:private sql:get-comments-per-file
"SELECT count(*) AS total
FROM comment AS c
JOIN comment_thread AS ct ON (ct.id = c.thread_id)
WHERE ct.file_id = ?")
(defmethod check-quote ::comments-per-file
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
(us/assert! ::files-per-project quote)
(assert (valid-comments-per-file-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-comments-per-file Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id

View File

@@ -10,6 +10,7 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.http.client :as http]
@@ -19,26 +20,26 @@
[datoteka.fs :as fs]
[integrant.core :as ig]))
(def ^:private
schema:template
(def ^:private schema:template
[:map {:title "Template"}
[:id ::sm/word-string]
[:name ::sm/word-string]
[:file-uri ::sm/word-string]])
(def ^:private
schema:templates
(def ^:private schema:templates
[:vector schema:template])
(def check-templates!
(sm/check-fn schema:templates
:code :invalid-templates
:hint "invalid templates"))
(defmethod ig/init-key ::setup/templates
[_ _]
(let [templates (-> "app/onboarding.edn" io/resource slurp edn/read-string)
templates (check-templates! templates)
dest (fs/join fs/*cwd* "builtin-templates")]
(dm/verify!
"expected a valid templates file"
(sm/check! schema:templates templates))
(doseq [{:keys [id path] :as template} templates]
(let [path (or path (fs/join dest id))]
(if (fs/exists? path)
@@ -58,9 +59,9 @@
(let [resp (http/req! cfg
{:method :get :uri (:file-uri template)}
{:response-type :input-stream :sync? true})]
(dm/verify!
"unexpected response found on fetching template"
(= 200 (:status resp)))
(when-not (= 200 (:status resp))
(ex/raise :type :internal
:code :unexpected-status-code
:hint (str "unable to download template, recevied status " (:status resp))))
(io/input-stream (:body resp)))))))

View File

@@ -155,9 +155,10 @@
(defn enable-team-feature!
[team-id feature]
(dm/verify!
"feature should be supported"
(contains? cfeat/supported-features feature))
(when-not (contains? cfeat/supported-features feature)
(ex/raise :type :assertion
:code :feature-not-supported
:hint (str "feature '" feature "' not supported")))
(let [team-id (h/parse-uuid team-id)]
(db/tx-run! main/system
@@ -173,9 +174,11 @@
(defn disable-team-feature!
[team-id feature]
(dm/verify!
"feature should be supported"
(contains? cfeat/supported-features feature))
(when-not (contains? cfeat/supported-features feature)
(ex/raise :type :assertion
:code :feature-not-supported
:hint (str "feature '" feature "' not supported")))
(let [team-id (h/parse-uuid team-id)]
(db/tx-run! main/system
@@ -203,9 +206,11 @@
[{:keys [::mbus/msgbus ::db/pool]} & {:keys [dest code message level]
:or {code :generic level :info}
:as params}]
(dm/verify!
["invalid level %" level]
(contains? #{:success :error :info :warning} level))
(when-not (contains? #{:success :error :info :warning} level)
(ex/raise :type :assertion
:code :incorrect-level
:hint (str "level '" level "' not supported")))
(letfn [(send [dest]
(l/inf :hint "sending notification" :dest (str dest))

View File

@@ -21,7 +21,7 @@
(t/use-fixtures :each th/database-reset)
(t/deftest ttf-font-upload-1
(with-mocks [mock {:target 'app.rpc.quotes/check-quote! :return nil}]
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)

View File

@@ -108,14 +108,6 @@
`(do ~@body)
(reverse (partition 2 bindings))))
(defmacro check
"Applies a predicate to the value, if result is true, return the
value if not, returns nil."
[pred-fn value]
`(if (~pred-fn ~value)
~value
nil))
(defmacro get-prop
"A macro based, optimized variant of `get` that access the property
directly on CLJS, on CLJ works as get."
@@ -124,47 +116,32 @@
(list 'js* (c/str "(~{}?." (str/snake prop) "?? ~{})") obj (list 'cljs.core/get obj prop))
(list `c/get obj prop)))
(def ^:dynamic *assert-context* nil)
(defn runtime-assert
[hint f]
(try
(when-not (f)
(throw (ex-info hint {:type :assertion
:code :expr-validation
:hint hint})))
(catch #?(:clj Throwable :cljs :default) cause
(let [data (-> (ex-data cause)
(assoc :type :assertion)
(assoc :code :expr-validation)
(assoc :hint hint))]
(throw (ex-info hint data cause))))))
(defmacro assert!
([expr]
`(assert! nil ~expr))
([hint expr]
(let [hint (cond
(vector? hint)
`(str/ffmt ~@hint)
(let [hint (cond
(vector? hint)
`(str/ffmt ~@hint)
(some? hint)
hint
(some? hint)
hint
:else
(str "expr assert: " (pr-str expr)))]
:else
(str "expr assert: " (pr-str expr)))]
(when *assert*
`(binding [*assert-context* ~hint]
(when-not ~expr
(let [hint# ~hint
params# {:type :assertion
:code :expr-validation
:hint hint#}]
(throw (ex-info hint# params#)))))))))
(defmacro verify!
([expr]
`(verify! nil ~expr))
([hint expr]
(let [hint (cond
(vector? hint)
`(str/ffmt ~@hint)
(some? hint)
hint
:else
(str "expr assert: " (pr-str expr)))]
`(binding [*assert-context* ~hint]
(when-not ~expr
(let [hint# ~hint
params# {:type :assertion
:code :expr-validation
:hint hint#}]
(throw (ex-info hint# params#))))))))
`(runtime-assert ~hint (fn [] ~expr))))))

View File

@@ -49,7 +49,8 @@
"components/v2"
"styles/v2"
"layout/grid"
"plugins/runtime"})
"plugins/runtime"
"text-editor/v2"})
;; A set of features enabled by default
(def default-features
@@ -64,7 +65,8 @@
;; team feature field
(def frontend-only-features
#{"styles/v2"
"plugins/runtime"})
"plugins/runtime"
"text-editor/v2"})
;; Features that are mainly backend only or there are a proper
;; fallback when frontend reports no support for it
@@ -81,7 +83,8 @@
"fdata/pointer-map"
"layout/grid"
"fdata/shape-data-type"
"plugins/runtime"}
"plugins/runtime"
"text-editor/v2"}
(into frontend-only-features)))
(sm/register! ::features
@@ -101,6 +104,7 @@
:feature-fdata-objects-map "fdata/objects-map"
:feature-fdata-pointer-map "fdata/pointer-map"
:feature-plugins "plugins/runtime"
:feature-text-editor-v2 "text-editor/v2"
nil))
(defn migrate-legacy-features

View File

@@ -414,10 +414,11 @@
;; If object has changed or is new verify is correct
(when (and (some? shape-new)
(not= shape-old shape-new))
(dm/verify!
"expected valid shape"
(and (cts/valid-shape? shape-new)
(cts/shape? shape-new))))))]
(when-not (and (cts/valid-shape? shape-new)
(cts/shape? shape-new))
(ex/raise :type :assertion
:code :data-validation
:hint "invalid shape found after applying changes")))))]
(->> (into #{} (map :page-id) items)
(mapcat (fn [page-id]

View File

@@ -1056,9 +1056,14 @@
(not (contains? page :default-grids)))
(assoc :default-grids (:saved-grids options))
(and (some? (:background options))
(not (contains? page :background)))
(assoc :background (:background options))
(and (some? (:flows options))
(not (contains? page :flows)))
(assoc :flows (:flows options))
(or (not (contains? page :flows))
(not (map? (:flows page)))))
(assoc :flows (d/index-by :id (:flows options)))
(and (some? (:guides options))
(not (contains? page :guides)))

View File

@@ -10,6 +10,7 @@
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.rect :as grc]
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]))
(defn shape-stroke-margin
@@ -60,6 +61,7 @@
filter-y (mth/min y (+ y offset-y (- spread) (- blur) -5))
filter-w (+ w (mth/abs offset-x) (* spread 2) (* blur 2) 10)
filter-h (+ h (mth/abs offset-y) (* spread 2) (* blur 2) 10)]
(grc/make-rect filter-x filter-y filter-w filter-h)))
(defn get-rect-filter-bounds
@@ -96,12 +98,15 @@
([shape ignore-margin?]
(let [strokes (:strokes shape)
open-path? (and ^boolean (cfh/path-shape? shape)
^boolean (gsh/open-path? shape))
stroke-width
(->> strokes
(map #(case (get % :stroke-alignment :center)
:center (/ (:stroke-width % 0) 2)
:outer (:stroke-width % 0)
0))
(if open-path? (:stroke-width % 0) 0)))
(reduce d/max 0))
stroke-margin

View File

@@ -852,8 +852,10 @@
(defn ray-overlaps?
[ray-point {selrect :selrect}]
(and (>= (:y ray-point) (:y1 selrect))
(<= (:y ray-point) (:y2 selrect))))
(and (or (> (:y ray-point) (:y1 selrect))
(mth/almost-zero? (- (:y ray-point) (:y1 selrect))))
(or (< (:y ray-point) (:y2 selrect))
(mth/almost-zero? (- (:y ray-point) (:y2 selrect))))))
(defn content->geom-data
[content]

View File

@@ -1988,7 +1988,8 @@
(+ (:position guide) (- (:y new-frame) (:y frame))))
guide {:id guide-id
:frame-id new-id
:position position}]
:position position
:axis (:axis guide)}]
(pcb/set-guide changes guide-id guide))
changes))
changes

View File

@@ -124,7 +124,7 @@
;; All parents of any deleted shape must be resized.
(into res (cfh/get-parent-ids objects id)))
(d/ordered-set)
ids-to-delete)
(concat ids-to-delete ids-to-hide))
all-children
(->> ids-to-delete ;; Children of deleted shapes must be also deleted.
@@ -408,17 +408,12 @@
;; Resize parent containers that need to
(pcb/resize-parents parents))))
(defn change-show-in-viewer [shape hide?]
(cond-> (assoc shape :hide-in-viewer hide?)
;; When a frame is no longer shown in view mode, it cannot have interactions
hide?
(dissoc :interactions)))
(assoc shape :hide-in-viewer hide?))
(defn add-new-interaction [shape interaction]
(-> shape
(update :interactions ctsi/add-interaction interaction)
;; When a interaction is created, the frame must be shown in view mode
(dissoc :hide-in-viewer)))
(update :interactions ctsi/add-interaction interaction)))
(defn show-in-viewer [shape]
(dissoc shape :hide-in-viewer))

View File

@@ -9,7 +9,6 @@
#?(:cljs (:require-macros [app.common.schema :refer [ignoring]]))
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.pprint :as pp]
[app.common.schema.generators :as sg]
[app.common.schema.openapi :as-alias oapi]
@@ -243,59 +242,35 @@
(defn- fast-check!
"A fast path for checking process, assumes the ILazySchema protocol
implemented on the provided `s` schema. Sould not be used directly."
[s value]
[s type code hint value]
(when-not ^boolean (-validate s value)
(let [hint (d/nilv dm/*assert-context* "check error")
explain (-explain s value)]
(throw (ex-info hint {:type :assertion
:code :data-validation
(let [explain (-explain s value)]
(throw (ex-info hint {:type type
:code code
:hint hint
::explain explain}))))
true)
value)
(declare ^:private lazy-schema)
(defn check-fn
"Create a predefined check function"
[s]
(let [schema (if (lazy-schema? s) s (lazy-schema s))]
(partial fast-check! schema)))
[s & {:keys [hint type code]}]
(let [schema (if (lazy-schema? s) s (lazy-schema s))
hint (or ^boolean hint "check error")
type (or ^boolean type :assertion)
code (or ^boolean code :data-validation)]
(partial fast-check! schema type code hint)))
(defn check!
"A helper intended to be used on assertions for validate/check the
schema over provided data. Raises an assertion exception, should be
used together with `dm/assert!` or `dm/verify!`."
[s value]
(let [s (if (lazy-schema? s) s (lazy-schema s))]
(fast-check! s value)))
(defn- fast-validate!
"A fast path for validation process, assumes the ILazySchema protocol
implemented on the provided `s` schema. Sould not be used directly."
([s value] (fast-validate! s value nil))
([s value options]
(when-not ^boolean (-validate s value)
(let [explain (-explain s value)
options (into {:type :validation
:code :data-validation
::explain explain}
options)
hint (get options :hint "schema validation error")]
(throw (ex-info hint options))))
value))
(defn validate-fn
"Create a predefined validate function that raises an expception"
[s]
(let [schema (if (lazy-schema? s) s (lazy-schema s))]
(partial fast-validate! schema)))
(defn validate!
"A generic validation function for predefined schemas."
([s value] (validate! s value nil))
([s value options]
(let [s (if (lazy-schema? s) s (lazy-schema s))]
(fast-validate! s value options))))
schema over provided data. Raises an assertion exception."
[s value & {:keys [hint type code]}]
(let [s (if (lazy-schema? s) s (lazy-schema s))
hint (or ^boolean hint "check error")
type (or ^boolean type :assertion)
code (or ^boolean code :data-validation)]
(fast-check! s type code hint value)))
(defn register! [type s]
(let [s (if (map? s) (m/-simple-schema s) s)]
@@ -391,7 +366,7 @@
(defn parse-email
[s]
(if (string? s)
(re-matches email-re s)
(first (re-seq email-re s))
nil))
(defn email-string?
@@ -1005,6 +980,12 @@
(def check-email!
(check-fn ::email))
(def check-uuid!
(check-fn ::uuid :hint "expected valid uuid instance"))
(def check-string!
(check-fn :string :hint "expected string"))
(def check-coll-of-uuid!
(check-fn ::coll-of-uuid))

View File

@@ -10,6 +10,7 @@
[app.common.colors :as clr]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
@@ -29,12 +30,12 @@
{:x 0 :y 0 :width 1 :height 1})
(defn- assert-valid-num [attr num]
(dm/verify!
["%1 attribute has invalid value: %2" (d/name attr) num]
(and (d/num? num)
(<= num max-safe-int)
(>= num min-safe-int)))
(when-not (and (d/num? num)
(<= num max-safe-int)
(>= num min-safe-int))
(ex/raise :type :assertion
:code :data-validation
:hint (str "invalid numeric value for `" attr "`: " num)))
(cond
(and (> num 0) (< num 1)) 1
(and (< num 0) (> num -1)) -1
@@ -43,19 +44,21 @@
(defn- assert-valid-pos-num
[attr num]
(dm/verify!
["%1 attribute should be positive" (d/name attr)]
(pos? num))
(when-not (pos? num)
(ex/raise :type :assertion
:code :data-validation
:hint (str "invalid numeric value for `" attr "`: " num " (should be positive)")))
num)
(defn- assert-valid-blend-mode
[mode]
(let [clean-value (-> mode str/trim str/lower keyword)]
(dm/verify!
["%1 is not a valid blend mode" clean-value]
(contains? cts/blend-modes clean-value))
clean-value))
(let [value (-> mode str/trim str/lower keyword)]
(when-not (contains? cts/blend-modes value)
(ex/raise :type :assertion
:code :data-validation
:hint (str "unexpected blend mode: " value)))
value))
(defn- svg-dimensions
[{:keys [attrs] :as data}]

View File

@@ -78,6 +78,12 @@
(def text-all-attrs (d/concat-set shape-attrs root-attrs paragraph-attrs text-node-attrs))
(def text-style-attrs
(d/concat-vec root-attrs paragraph-attrs text-node-attrs))
(def default-root-attrs
{:vertical-align "top"})
(def default-text-attrs
{:typography-ref-file nil
:typography-ref-id nil
@@ -92,9 +98,13 @@
:text-transform "none"
:text-align "left"
:text-decoration "none"
:text-direction "ltr"
:fills [{:fill-color clr/black
:fill-opacity 1}]})
(def default-attrs
(merge default-root-attrs default-text-attrs))
(def typography-fields
[:font-id
:font-family

View File

@@ -116,7 +116,7 @@
(sm/register! ::color-attrs schema:color-attrs)
(def check-color!
(sm/check-fn schema:color))
(sm/check-fn schema:color :hint "expected valid color struct"))
(def check-recent-color!
(sm/check-fn schema:recent-color))

View File

@@ -355,7 +355,8 @@
(sm/check-fn schema:shape-attrs))
(def check-shape!
(sm/check-fn schema:shape))
(sm/check-fn schema:shape
:hint "expected valid shape"))
(def valid-shape?
(sm/lazy-validator schema:shape))

View File

@@ -1,75 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.logic.hide-in-viewer-test
(:require
[app.common.files.changes-builder :as pcb]
[app.common.logic.shapes :as cls]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.shape.interactions :as ctsi]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(t/deftest test-remove-show-in-view-mode-delete-interactions
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-frame :frame-dest)
(tho/add-frame :frame-origin)
(ths/add-interaction :frame-origin :frame-dest))
frame-origin (ths/get-shape file :frame-origin)
page (thf/current-page file)
;; ==== Action
changes (-> (pcb/empty-changes nil (:id page))
(pcb/with-objects (:objects page))
(pcb/update-shapes [(:id frame-origin)] #(cls/change-show-in-viewer % true)))
file' (thf/apply-changes file changes)
;; ==== Get
frame-origin' (ths/get-shape file' :frame-origin)]
;; ==== Check
(t/is (some? (:interactions frame-origin)))
(t/is (nil? (:interactions frame-origin')))))
(t/deftest test-add-new-interaction-updates-show-in-view-mode
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-frame :frame-dest :hide-in-viewer true)
(tho/add-frame :frame-origin :hide-in-viewer true))
frame-dest (ths/get-shape file :frame-dest)
frame-origin (ths/get-shape file :frame-origin)
page (thf/current-page file)
;; ==== Action
new-interaction (-> ctsi/default-interaction
(ctsi/set-destination (:id frame-dest))
(assoc :position-relative-to (:id frame-dest)))
changes (-> (pcb/empty-changes nil (:id page))
(pcb/with-objects (:objects page))
(pcb/update-shapes [(:id frame-origin)] #(cls/add-new-interaction % new-interaction)))
file' (thf/apply-changes file changes)
;; ==== Get
frame-origin' (ths/get-shape file' :frame-origin)]
;; ==== Check
(t/is (true? (:hide-in-viewer frame-origin)))
(t/is (nil? (:hide-in-viewer frame-origin')))))

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
export PATH=/usr/lib/jvm/openjdk/bin:/usr/local/nodejs/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
export JAVA_OPTS="-Xmx1000m -Xms50m"
export JAVA_OPTS=${JAVA_OPTS:-"-Xmx1000m -Xms200m"};
alias l='ls --color -GFlh'
alias rm='rm -r'

View File

@@ -10,7 +10,7 @@ rm -rf target
export NODE_ENV=production;
# Build the application
clojure -J-Xms100M -J-Xmx1000M -J-XX:+UseSerialGC -M:dev:shadow-cljs release main;
clojure -M:dev:shadow-cljs release main;
# Remove source
rm -rf target/app;

View File

@@ -15,8 +15,7 @@
:output-wrapper false}
:release
{:closure-defines {goog.debug.LOGGING_ENABLED true}
:compiler-options
{:compiler-options
{:fn-invoke-direct true
:source-map true
:optimizations #shadow/env ["PENPOT_BUILD_OPTIMIZATIONS" :as :keyword :default :simple]

View File

@@ -1 +1 @@
v14.15.0
v20.11.1

View File

@@ -20,10 +20,9 @@
"build:app:assets": "node ./scripts/build-app-assets.js",
"build:storybook": "yarn run build:storybook:assets && yarn run build:storybook:cljs && storybook build",
"build:storybook:assets": "node ./scripts/build-storybook-assets.js",
"build:storybook:cljs": "clojure -M:dev:shadow-cljs release storybook",
"build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook",
"build:renderer": "yarn run wasm-pack build ./renderer --target web --out-dir ../resources/public/js/renderer --release",
"e2e:server": "node ./scripts/e2e-server.js",
"e2e:test": "playwright test --project default",
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"fmt:clj:check": "cljfmt check --parallel=false src/ test/",
"fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -w",
@@ -35,6 +34,7 @@
"test:compile": "clojure -M:dev:shadow-cljs compile test --config-merge '{:autorun false}'",
"test:run": "node target/tests.cjs",
"test:watch": "clojure -M:dev:shadow-cljs watch test",
"test:e2e": "playwright test --project default",
"translations": "node ./scripts/translations.js",
"watch": "yarn run watch:app:assets",
"watch:app:assets": "node ./scripts/watch.js",

View File

@@ -56,7 +56,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
timeout: 2 * 60 * 1000,
command: "yarn e2e:server",
command: "yarn run e2e:server",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},

View File

@@ -0,0 +1,49 @@
{
"~:features": {
"~#set": [
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "New File 5",
"~:revn": 3,
"~:modified-at": "~m1729245873067",
"~:id": "~uaf2494d0-39ba-8184-8005-230696f6df5c",
"~:is-shared": false,
"~:version": 55,
"~:project-id": "~u17866431-4e74-816e-8004-eba55c3a5694",
"~:created-at": "~m1729245083615",
"~:data": {
"~:pages": [
"~uaf2494d0-39ba-8184-8005-230696f6df5d"
],
"~:pages-index": {
"~uaf2494d0-39ba-8184-8005-230696f6df5d": {
"~#penpot/pointer": [
"~uaf2494d0-39ba-8184-8005-230999ec862d",
{
"~:created-at": "~m1729245873074"
}
]
}
},
"~:id": "~uaf2494d0-39ba-8184-8005-230696f6df5c",
"~:options": {
"~:components-v2": true
}
}
}

View File

@@ -0,0 +1,270 @@
{
"~:id": "~uaf2494d0-39ba-8184-8005-230999ec862d",
"~:file-id": "~uaf2494d0-39ba-8184-8005-230696f6df5c",
"~:created-at": "~m1729245873057",
"~:data": {
"~:options": {},
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0.0,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0.0,
"~:y": 0.01
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~u8611ed3e-43d1-80dc-8005-23069fddf984"
]
}
},
"~u8611ed3e-43d1-80dc-8005-23069fddf984": {
"~#shape": {
"~:y": 128,
"~:layout-grid-columns": [
{
"~:type": "~:flex",
"~:value": 1
},
{
"~:type": "~:flex",
"~:value": 1
}
],
"~:hide-fill-on-export": false,
"~:layout-gap-type": "~:multiple",
"~:layout-padding": {
"~:p1": 0,
"~:p2": 0,
"~:p3": 0,
"~:p4": 0
},
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:layout": "~:grid",
"~:hide-in-viewer": false,
"~:name": "Board",
"~:layout-align-items": "~:start",
"~:width": 200,
"~:layout-grid-cells": {
"~u96353b58-4e42-8052-8005-230996dafe13": {
"~:justify-self": "~:auto",
"~:column": 1,
"~:id": "~u96353b58-4e42-8052-8005-230996dafe13",
"~:position": "~:auto",
"~:column-span": 1,
"~:align-self": "~:auto",
"~:row": 1,
"~:row-span": 1,
"~:shapes": []
},
"~u96353b58-4e42-8052-8005-230996dafe14": {
"~:justify-self": "~:auto",
"~:column": 2,
"~:id": "~u96353b58-4e42-8052-8005-230996dafe14",
"~:position": "~:auto",
"~:column-span": 1,
"~:align-self": "~:auto",
"~:row": 1,
"~:row-span": 1,
"~:shapes": []
},
"~u96353b58-4e42-8052-8005-230996dafe15": {
"~:justify-self": "~:auto",
"~:column": 1,
"~:id": "~u96353b58-4e42-8052-8005-230996dafe15",
"~:position": "~:auto",
"~:column-span": 1,
"~:align-self": "~:auto",
"~:row": 2,
"~:row-span": 1,
"~:shapes": []
},
"~u96353b58-4e42-8052-8005-230996dafe16": {
"~:justify-self": "~:auto",
"~:column": 2,
"~:id": "~u96353b58-4e42-8052-8005-230996dafe16",
"~:position": "~:auto",
"~:column-span": 1,
"~:align-self": "~:auto",
"~:row": 2,
"~:row-span": 1,
"~:shapes": []
}
},
"~:layout-padding-type": "~:simple",
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 128,
"~:y": 128
}
},
{
"~#point": {
"~:x": 328,
"~:y": 128
}
},
{
"~#point": {
"~:x": 328,
"~:y": 328
}
},
{
"~#point": {
"~:x": 128,
"~:y": 328
}
}
],
"~:proportion-lock": false,
"~:layout-gap": {
"~:row-gap": 0,
"~:column-gap": 0
},
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:layout-justify-content": "~:stretch",
"~:id": "~u8611ed3e-43d1-80dc-8005-23069fddf984",
"~:layout-justify-items": "~:start",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:layout-align-content": "~:stretch",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 128,
"~:proportion": 1,
"~:layout-grid-rows": [
{
"~:type": "~:flex",
"~:value": 1
},
{
"~:type": "~:flex",
"~:value": 1
}
],
"~:selrect": {
"~#rect": {
"~:x": 128,
"~:y": 128,
"~:width": 200,
"~:height": 200,
"~:x1": 128,
"~:y1": 128,
"~:x2": 328,
"~:y2": 328
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:layout-grid-dir": "~:row",
"~:flip-x": null,
"~:height": 200,
"~:flip-y": null,
"~:shapes": []
}
}
},
"~:id": "~uaf2494d0-39ba-8184-8005-230696f6df5d",
"~:name": "Page 1"
}
}

View File

@@ -61,6 +61,7 @@ export class WorkspacePage extends BaseWebSocketPage {
);
this.toolbarOptions = page.getByTestId("toolbar-options");
this.rectShapeButton = page.getByRole("button", { name: "Rectangle (R)" });
this.boardButton = page.getByRole("button", { name: "Board (B)" });
this.toggleToolbarButton = page.getByRole("button", {
name: "Toggle toolbar",
});
@@ -168,7 +169,7 @@ export class WorkspacePage extends BaseWebSocketPage {
async moveSelectionToShape(name) {
await this.page.locator("rect.viewport-selrect").hover();
await this.page.mouse.down();
await this.viewport.getByTestId(name).first().hover({ force: true });
await this.viewport.getByText(name).first().hover({ force: true });
await this.page.mouse.up();
}

View File

@@ -0,0 +1,34 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../pages/WorkspacePage";
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
});
// Fix for https://tree.taiga.io/project/penpot/issue/9042
test("Bug 9042 - Measurement unit dropdowns for columns are cut off in grid layout edit mode", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-9042.json");
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-fragment-9042.json",
);
await workspacePage.goToWorkspace({
fileId: "af2494d0-39ba-8184-8005-230696f6df5c",
pageId: "af2494d0-39ba-8184-8005-230696f6df5d",
});
await workspacePage.clickLeafLayer("Board");
await workspacePage.expectSelectedLayer("Board");
const layoutContainer = workspacePage.page.getByTestId("inspect-layout");
await layoutContainer.getByRole("button", { name: "Edit grid" }).click();
const rowsContainer = workspacePage.page.getByTestId("inspect-layout-rows");
await rowsContainer.click();
await rowsContainer.getByText("FR").nth(2).click();
await expect(rowsContainer.getByText("%")).toBeInViewport();
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

View File

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@
<script defer src="{{& polyfills}}"></script>
{{/manifest}}
<script type="module" src="{{pluginRuntimeUri}}/index.js"></script>
<script type="module" src="{{& pluginRuntimeUri}}"></script>
<script>
window.penpotTranslations = JSON.parse({{& translations}});

View File

@@ -181,14 +181,16 @@ export async function watch(baseDir, predicate, callback) {
}
async function readShadowManifest() {
const ts = Date.now();
try {
const manifestPath = "resources/public/js/manifest.json";
let content = await fs.readFile(manifestPath, { encoding: "utf8" });
content = JSON.parse(content);
const index = {
config: "js/config.js?ts=" + Date.now(),
polyfills: "js/polyfills.js?ts=" + Date.now(),
ts: ts,
config: "js/config.js?ts=" + ts,
polyfills: "js/polyfills.js?ts=" + ts,
};
for (let item of content) {
@@ -198,12 +200,13 @@ async function readShadowManifest() {
return index;
} catch (cause) {
return {
config: "js/config.js",
polyfills: "js/polyfills.js",
main: "js/main.js",
shared: "js/shared.js",
worker: "js/worker.js",
rasterizer: "js/rasterizer.js",
ts: ts,
config: "js/config.js?ts=" + ts,
polyfills: "js/polyfills.js?ts=" + ts,
main: "js/main.js?ts=" + ts,
shared: "js/shared.js?ts=" + ts,
worker: "js/worker.js?ts=" + ts,
rasterizer: "js/rasterizer.js?ts=" + ts,
};
}
}
@@ -409,8 +412,8 @@ async function generateTemplates() {
const pluginRuntimeUri =
process.env.PENPOT_PLUGIN_DEV === "true"
? "http://localhost:4200"
: "./plugins-runtime";
? "http://localhost:4200/index.js?ts=" + manifest.ts
: "plugins-runtime/index.js?ts=" + manifest.ts;
content = await renderTemplate(
"resources/templates/index.mustache",

View File

@@ -62,6 +62,12 @@
:depends-on #{:shared}
:init-fn app.rasterizer/init}}
:js-options
{:entry-keys ["module" "browser" "main"]
:resolve {"penpot/vendor/text-editor-v2"
{:target :file
:file "vendor/text_editor_v2.js"}}}
:compiler-options
{:output-feature-set :es2020
:output-wrapper false
@@ -145,6 +151,12 @@
:ns-regexp "^frontend-tests.*-test$"
:autorun true
:js-options
{:entry-keys ["module" "browser" "main"]
:resolve {"penpot/vendor/text-editor-v2"
{:target :file
:file "vendor/text_editor_v2.js"}}}
:compiler-options
{:output-feature-set :es2020
:output-wrapper false

View File

@@ -111,6 +111,7 @@
(def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/"))
(def grid-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/"))
(def plugins-list-uri (obj/get global "penpotPluginsListUri" "https://penpot-docs-plugins.pages.dev/plugins/getting-started/#examples"))
(def plugins-whitelist (into #{} (obj/get global "penpotPluginsWhitelist" [])))
(defn- normalize-uri
[uri-str]

View File

@@ -941,7 +941,7 @@
(update-in [:dashboard-projects project-id :count] inc)))))
(defn create-file
[{:keys [project-id] :as params}]
[{:keys [project-id name] :as params}]
(dm/assert! (uuid? project-id))
(ptk/reify ::create-file
ev/Event
@@ -955,7 +955,7 @@
files (get state :dashboard-files)
unames (cfh/get-used-names files)
name (cfh/generate-unique-name unames (str (tr "dashboard.new-file-prefix") " 1"))
name (or name (cfh/generate-unique-name unames (str (tr "dashboard.new-file-prefix") " 1")))
features (-> (features/get-team-enabled-features state)
(set/difference cfeat/frontend-only-features))
params (-> params

View File

@@ -0,0 +1,121 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.data.plugins
(:require
[app.common.data.macros :as dm]
[app.main.data.modal :as modal]
[app.main.store :as st]
[app.plugins.register :as preg]
[app.util.globals :as ug]
[app.util.http :as http]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defn fetch-manifest
[plugin-url]
(->> (http/send! {:method :get
:uri plugin-url
:omit-default-headers true
:response-type :json})
(rx/map :body)
(rx/map #(preg/parse-manifest plugin-url %))))
(defn save-current-plugin
[id]
(ptk/reify ::save-current-plugin
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-local :open-plugins] (fnil conj #{}) id))))
(defn remove-current-plugin
[id]
(ptk/reify ::remove-current-plugin
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-local :open-plugins] (fnil disj #{}) id))))
(defn- load-plugin!
[{:keys [plugin-id name description host code icon permissions]}]
(try
(st/emit! (save-current-plugin plugin-id))
(.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id
:name name
:description description
:host host
:code code
:icon icon
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))
(catch :default e
(st/emit! (remove-current-plugin plugin-id))
(.error js/console "Error" e))))
(defn open-plugin!
[{:keys [url] :as manifest}]
(if url
;; If the saved manifest has a URL we fetch the manifest to check
;; for updates
(->> (fetch-manifest url)
(rx/subs!
(fn [new-manifest]
(let [new-manifest (merge new-manifest (select-keys manifest [:plugin-id]))]
(cond
(not= (:permissions new-manifest) (:permissions manifest))
(modal/show!
:plugin-permissions-update
{:plugin new-manifest
:on-accept
#(do
(preg/install-plugin! new-manifest)
(load-plugin! new-manifest))})
(not= new-manifest manifest)
(do (preg/install-plugin! new-manifest)
(load-plugin! manifest))
:else
(load-plugin! manifest))))
(fn []
;; Error fetching the manifest we'll load the plugin with the
;; old manifest
(load-plugin! manifest))))
(load-plugin! manifest)))
(defn close-plugin!
[{:keys [plugin-id]}]
(try
(.ɵunloadPlugin ^js ug/global plugin-id)
(catch :default e
(.error js/console "Error" e))))
(defn close-current-plugin
[]
(ptk/reify ::close-current-plugin
ptk/EffectEvent
(effect [_ state _]
(let [ids (dm/get-in state [:workspace-local :open-plugins])]
(doseq [id ids]
(close-plugin! (preg/get-plugin id)))))))
(defn delay-open-plugin
[plugin]
(ptk/reify ::delay-open-plugin
ptk/UpdateEvent
(update [_ state]
(assoc state ::open-plugin (:plugin-id plugin)))))
(defn check-open-plugin
[]
(ptk/reify ::check-open-plugin
ptk/WatchEvent
(watch [_ state _]
(when-let [pid (::open-plugin state)]
(open-plugin! (preg/get-plugin pid))
(rx/of #(dissoc % ::open-plugin))))))

View File

@@ -24,6 +24,10 @@ target.stopCallback = function (e, element, combo) {
return false
}
if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) {
return false;
}
if ('composedPath' in e && typeof e.composedPath === 'function') {
// For open shadow trees, update `element` so that the following check works.
const initialEventTarget = e.composedPath()[0];

View File

@@ -42,6 +42,7 @@
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as dps]
[app.main.data.plugins :as dp]
[app.main.data.users :as du]
[app.main.data.workspace.bool :as dwb]
[app.main.data.workspace.collapse :as dwco]
@@ -85,6 +86,7 @@
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[cljs.spec.alpha :as s]
[clojure.set :as set]
[cuerdas.core :as str]
[potok.v2.core :as ptk]
[promesa.core :as p]))
@@ -131,6 +133,7 @@
(when (and (not (boolean (-> state :profile :props :v2-info-shown)))
(features/active-feature? state "components/v2"))
(modal/show :v2-info {}))
(dp/check-open-plugin)
(fdf/fix-deleted-fonts)
(fbs/fix-broken-shapes)))))
@@ -1543,7 +1546,8 @@
(let [objects (wsh/lookup-page-objects state)
selected (->> (wsh/lookup-selected state)
(cfh/clean-loops objects))
features (features/get-team-enabled-features state)
features (-> (features/get-team-enabled-features state)
(set/difference cfeat/frontend-only-features))
file-id (:current-file-id state)
frame-id (cfh/common-parent-frame objects selected)
@@ -1721,8 +1725,8 @@
[:images [:set :map]]
[:position {:optional true} ::gpt/point]])
(def validate-paste-data!
(sm/validate-fn schema:paste-data))
(def paste-data-valid?
(sm/lazy-validator schema:paste-data))
(defn- paste-transit
[{:keys [images] :as pdata}]
@@ -1747,8 +1751,10 @@
(let [file-id (:current-file-id state)
features (features/get-team-enabled-features state)]
(validate-paste-data! pdata {:hint "invalid paste data"
:code :invalid-paste-data})
(when-not (paste-data-valid? pdata)
(ex/raise :type :validation
:code :invalid-paste-data
:hibt "invalid paste data found"))
(cfeat/check-paste-features! features (:features pdata))
(if (= file-id (:file-id pdata))

View File

@@ -465,16 +465,16 @@
(defn change-color-in-selected
[operations new-color old-color]
(dm/verify!
"expected valid change color operations"
(dm/assert!
"expected valid color operations"
(check-change-color-operations! operations))
(dm/verify!
"expected a valid color struct for new-color param"
(dm/assert!
"expected valid color structure"
(ctc/check-color! new-color))
(dm/verify!
"expected a valid color struct for old-color param"
(dm/assert!
"expected valid color structure"
(ctc/check-color! old-color))
(ptk/reify ::change-color-in-selected
@@ -498,7 +498,7 @@
[color stroke?]
(dm/assert!
"should be a valid color"
"expected valid color structure"
(ctc/check-color! color))
(ptk/reify ::apply-color-from-palette

View File

@@ -147,11 +147,15 @@
ptk/WatchEvent
(watch [_ state _]
(let [page-id (or page-id (:current-page-id state))]
(rx/of (dwsh/update-shapes
[shape-id]
(fn [shape]
(cls/add-new-interaction shape interaction))
{:page-id page-id}))))))
(rx/of (dwsh/update-shapes [shape-id]
(fn [shape]
(cls/add-new-interaction shape interaction))
{:page-id page-id})
(when (:destination interaction)
(dwsh/update-shapes [(:destination interaction)]
cls/show-in-viewer
{:page-id page-id})))))))
(defn add-new-interaction
([shape] (add-new-interaction shape nil))
@@ -167,15 +171,20 @@
flows (get page :objects)
flow (ctp/get-frame-flow flows (:id frame))]
(rx/concat
(rx/of (dwsh/update-shapes [(:id shape)]
(fn [shape]
(let [new-interaction (-> ctsi/default-interaction
(ctsi/set-destination destination)
(assoc :position-relative-to (:id shape)))]
(cls/add-new-interaction shape new-interaction)))))
(when (and (not (connected-frame? objects (:id frame)))
(nil? flow))
(rx/of (add-flow (:id frame))))))))))
(rx/of (dwsh/update-shapes
[(:id shape)]
(fn [shape]
(let [new-interaction (-> ctsi/default-interaction
(ctsi/set-destination destination)
(assoc :position-relative-to (:id shape)))]
(cls/add-new-interaction shape new-interaction))))
(when destination
(dwsh/update-shapes [destination] cls/show-in-viewer))
(when (and (not (connected-frame? objects (:id frame)))
(nil? flow))
(add-flow (:id frame))))))))))
(defn remove-interaction
([shape index]
@@ -186,8 +195,7 @@
(watch [_ _ _]
(rx/of (dwsh/update-shapes [(:id shape)]
(fn [shape]
(update shape :interactions
ctsi/remove-interaction index))
(update shape :interactions ctsi/remove-interaction index))
{:page-id page-id}))))))
(defn update-interaction
([shape index update-fn]
@@ -196,11 +204,16 @@
(ptk/reify ::update-interaction
ptk/WatchEvent
(watch [_ _ _]
(rx/of (dwsh/update-shapes [(:id shape)]
(fn [shape]
(update shape :interactions
ctsi/update-interaction index update-fn))
options))))))
(let [interactions (ctsi/update-interaction (:interactions shape) index update-fn)
interaction (nth interactions index)]
(rx/of (dwsh/update-shapes
[(:id shape)]
(fn [shape]
(assoc shape :interactions interactions))
options)
(when (some? (:destination interaction))
(dwsh/update-shapes [(:destination interaction)] cls/show-in-viewer options))))))))
(defn remove-all-interactions-nav-to
"Remove all interactions that navigate to the given frame."

View File

@@ -193,9 +193,17 @@
(defn rename-color
[file-id id new-name]
(dm/verify! (uuid? file-id))
(dm/verify! (uuid? id))
(dm/verify! (string? new-name))
(dm/assert!
"expected valid uuid for `id`"
(uuid? id))
(dm/assert!
"expected valid uuid for `file-id`"
(uuid? file-id))
(dm/assert!
"expected valid string for `new-name`"
(string? new-name))
(ptk/reify ::rename-color
ptk/WatchEvent
@@ -243,8 +251,15 @@
(defn rename-media
[id new-name]
(dm/verify! (uuid? id))
(dm/verify! (string? new-name))
(dm/assert!
"expected valid uuid for `id`"
(uuid? id))
(dm/assert!
"expected valid string for `new-name`"
(string? new-name))
(ptk/reify ::rename-media
ptk/WatchEvent
(watch [it state _]
@@ -261,8 +276,11 @@
(rx/of (dch/commit-changes changes))))))))
(defn delete-media
[{:keys [id] :as params}]
(dm/assert! (uuid? id))
[{:keys [id]}]
(dm/assert!
"expected valid uuid for `id`"
(uuid? id))
(ptk/reify ::delete-media
ev/Event
(-data [_] {:id id})
@@ -435,8 +453,14 @@
(defn rename-component
"Rename the component with the given id, in the current file library."
[id new-name]
(dm/verify! (uuid? id))
(dm/verify! (string? new-name))
(dm/assert!
"expected an uuid instance"
(uuid? id))
(dm/assert!
"expected string for new-name"
(string? new-name))
(ptk/reify ::rename-component
ptk/WatchEvent
(watch [it state _]
@@ -487,8 +511,11 @@
(defn delete-component
"Delete the component with the given id, from the current file library."
[{:keys [id] :as params}]
(dm/assert! (uuid? id))
[{:keys [id]}]
(dm/assert!
"expected valid uuid for `id`"
(uuid? id))
(ptk/reify ::delete-component
ptk/WatchEvent
(watch [it state _]
@@ -1129,7 +1156,9 @@
(defn touch-component
"Update the modified-at attribute of the component to now"
[id]
(dm/verify! (uuid? id))
(dm/assert!
"expected valid uuid for `id`"
(uuid? id))
(ptk/reify ::touch-component
cljs.core/IDeref
(-deref [_] [id])

View File

@@ -160,7 +160,7 @@
selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{})
start-position (apply min #(gpt/distance start-position %) selected-points)
start-position (apply min-key #(gpt/distance start-position %) selected-points)
content (st/get-path state :content)
points (upg/content->points content)]

View File

@@ -98,8 +98,8 @@
(add-shape shape {}))
([shape {:keys [no-select? no-update-layout?]}]
(dm/verify!
"expected a valid shape"
(dm/assert!
"expected valid shape"
(cts/check-shape! shape))
(ptk/reify ::add-shape

View File

@@ -6,9 +6,11 @@
(ns app.main.data.workspace.shortcuts
(:require
[app.common.data.macros :as dm]
[app.main.data.events :as ev]
[app.main.data.exports :as de]
[app.main.data.modal :as modal]
[app.main.data.plugins :as dpl]
[app.main.data.preview :as dp]
[app.main.data.shortcuts :as ds]
[app.main.data.users :as du]
@@ -28,6 +30,7 @@
[app.main.store :as st]
[app.main.ui.hooks.resize :as r]
[app.util.dom :as dom]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -44,6 +47,17 @@
(when-not (deref refs/workspace-read-only?)
(run! st/emit! events)))
(def esc-pressed
(ptk/reify ::esc-pressed
ptk/WatchEvent
(watch [_ state _]
(rx/of
:interrupt
(let [selection (dm/get-in state [:workspace-local :selected])]
(if (empty? selection)
(dpl/close-current-plugin)
(dw/deselect-all true)))))))
;; Shortcuts format https://github.com/ccampbell/mousetrap
(def base-shortcuts
@@ -111,7 +125,7 @@
:escape {:tooltip (ds/esc)
:command "escape"
:subsections [:edit]
:fn #(st/emit! :interrupt (dw/deselect-all true))}
:fn #(st/emit! esc-pressed)}
;; MODIFY LAYERS

View File

@@ -6,6 +6,7 @@
(ns app.main.data.workspace.texts
(:require
["penpot/vendor/text-editor-v2" :as editor.v2]
[app.common.attrs :as attrs]
[app.common.data :as d]
[app.common.data.macros :as dm]
@@ -24,14 +25,26 @@
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.state-helpers :as wsh]
[app.main.data.workspace.undo :as dwu]
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.util.router :as rt]
[app.util.text-editor :as ted]
[app.util.text.content.styles :as styles]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
;; -- V2 Editor Helpers
(def ^function create-editor editor.v2/create)
(def ^function set-editor-root! editor.v2/setRoot)
(def ^function get-editor-root editor.v2/getRoot)
(def ^function dispose! editor.v2/dispose)
(declare v2-update-text-shape-content)
(declare v2-update-text-editor-styles)
;; -- Editor
(defn update-editor
@@ -186,22 +199,41 @@
[{:keys [attrs shape]}]
(shape-current-values shape txt/is-root-node? attrs))
(defn current-paragraph-values
(defn v2-current-text-values
[{:keys [editor-instance attrs]}]
(let [result (-> (.-currentStyle editor-instance)
(styles/get-styles-from-style-declaration)
(select-keys attrs))
result (if (empty? result) txt/default-text-attrs result)]
result))
(defn v1-current-paragraph-values
[{:keys [editor-state attrs shape]}]
(if editor-state
(-> (ted/get-editor-current-block-data editor-state)
(select-keys attrs))
(shape-current-values shape txt/is-paragraph-node? attrs)))
(defn current-text-values
[{:keys [editor-state attrs shape]}]
(if editor-state
(let [result (-> (ted/get-editor-current-inline-styles editor-state)
(select-keys attrs))
result (if (empty? result) txt/default-text-attrs result)]
result)
(shape-current-values shape txt/is-text-node? attrs)))
(defn current-paragraph-values
[{:keys [editor-state editor-instance attrs shape] :as options}]
(cond
(some? editor-instance) (v2-current-text-values options)
(some? editor-state) (v1-current-paragraph-values options)
:else (shape-current-values shape txt/is-paragraph-node? attrs)))
(defn v1-current-text-values
[{:keys [editor-state attrs]}]
(let [result (-> (ted/get-editor-current-inline-styles editor-state)
(select-keys attrs))
result (if (empty? result) txt/default-text-attrs result)]
result))
(defn current-text-values
[{:keys [editor-state editor-instance attrs shape] :as options}]
(cond
(some? editor-instance) (v2-current-text-values options)
(some? editor-state) (v1-current-text-values options)
:else (shape-current-values shape txt/is-text-node? attrs)))
;; --- TEXT EDITION IMPL
@@ -408,7 +440,9 @@
ptk/WatchEvent
(watch [_ state _]
(when (nil? (get-in state [:workspace-editor-state id]))
(when (or
(and (features/active-feature? state "text-editor/v2") (nil? (:workspace-editor state)))
(and (not (features/active-feature? state "text-editor/v2")) (nil? (get-in state [:workspace-editor-state id]))))
(let [objects (wsh/lookup-page-objects state)
shape (get objects id)
@@ -430,8 +464,17 @@
(-> shape
(dissoc :fills)
(d/update-when :content update-content)))]
(rx/of (dwsh/update-shapes shape-ids update-shape)))))
(rx/of (dwsh/update-shapes shape-ids update-shape)))))))
ptk/EffectEvent
(effect [_ state _]
(when (features/active-feature? state "text-editor/v2")
(let [instance (:workspace-editor state)
styles (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration)
((comp update-node-fn migrate-node))
(styles/attrs->styles))]
(editor.v2/applyStylesToSelection instance styles))))))
;; --- RESIZE UTILS
@@ -664,22 +707,36 @@
[id attrs]
(ptk/reify ::update-attrs
ptk/WatchEvent
(watch [_ _ _]
(rx/concat
(let [attrs (select-keys attrs txt/root-attrs)]
(if-not (empty? attrs)
(rx/of (update-root-attrs {:id id :attrs attrs}))
(rx/empty)))
(watch [_ state _]
(let [text-editor-instance (:workspace-editor state)]
(if (and (features/active-feature? state "text-editor/v2")
(some? text-editor-instance))
(rx/empty)
(rx/concat
(let [attrs (select-keys attrs txt/root-attrs)]
(if-not (empty? attrs)
(rx/of (update-root-attrs {:id id :attrs attrs}))
(rx/empty)))
(let [attrs (select-keys attrs txt/paragraph-attrs)]
(if-not (empty? attrs)
(rx/of (update-paragraph-attrs {:id id :attrs attrs}))
(rx/empty)))
(let [attrs (select-keys attrs txt/paragraph-attrs)]
(if-not (empty? attrs)
(rx/of (update-paragraph-attrs {:id id :attrs attrs}))
(rx/empty)))
(let [attrs (select-keys attrs txt/text-node-attrs)]
(if-not (empty? attrs)
(rx/of (update-text-attrs {:id id :attrs attrs}))
(rx/empty)))))))
(let [attrs (select-keys attrs txt/text-node-attrs)]
(if-not (empty? attrs)
(rx/of (update-text-attrs {:id id :attrs attrs}))
(rx/empty)))
(when (features/active-feature? state "text-editor/v2")
(rx/of (v2-update-text-editor-styles id attrs)))))))
ptk/EffectEvent
(effect [_ state _]
(when (features/active-feature? state "text-editor/v2")
(let [instance (:workspace-editor state)
styles (styles/attrs->styles attrs)]
(editor.v2/applyStylesToSelection instance styles))))))
(defn update-all-attrs
[ids attrs]
@@ -773,3 +830,52 @@
(rx/of (update-attrs (:id shape)
{:typography-ref-id typ-id
:typography-ref-file file-id}))))))))
;; -- New Editor
(defn v2-update-text-editor-styles
[id new-styles]
(ptk/reify ::v2-update-text-editor-styles
ptk/UpdateEvent
(update [_ state]
(let [merged-styles (d/merge txt/default-text-attrs
(get-in state [:workspace-global :default-font])
new-styles)]
(update-in state [:workspace-v2-editor-state id] (fnil merge {}) merged-styles)))))
(defn v2-update-text-shape-position-data
[shape-id position-data]
(ptk/reify ::v2-update-text-shape-position-data
ptk/UpdateEvent
(update [_ state]
(let []
(update-in state [:workspace-text-modifier shape-id] {:position-data position-data})))))
(defn v2-update-text-shape-content
([id content]
(v2-update-text-shape-content id content false nil))
([id content update-name?]
(v2-update-text-shape-content id content update-name? nil))
([id content update-name? name]
(ptk/reify ::v2-update-text-shape-content
ptk/WatchEvent
(watch [_ state _]
(let [objects (wsh/lookup-page-objects state)
shape (get objects id)
modifiers (get-in state [:workspace-text-modifier id])
new-shape? (nil? (:content shape))]
(rx/of
(dwsh/update-shapes
[id]
(fn [shape]
(let [{:keys [width height position-data]} modifiers]
(let [new-shape (-> shape
(assoc :content content)
(cond-> position-data
(assoc :position-data position-data))
(cond-> (and update-name? (some? name))
(assoc :name name))
(cond-> (or (some? width) (some? height))
(gsh/transform-shape (ctm/change-size shape width height))))]
new-shape)))
{:undo-group (when new-shape? id)})))))))

View File

@@ -109,7 +109,8 @@
(watch [_ _ _]
(when *assert*
(->> (rx/from cfeat/no-migration-features)
(rx/filter #(not (contains? cfeat/backend-only-features %)))
;; text editor v2 isn't enabled by default even in devenv
(rx/filter #(not (or (contains? cfeat/backend-only-features %) (= "text-editor/v2" %))))
(rx/observe-on :async)
(rx/map enable-feature))))

View File

@@ -184,6 +184,9 @@
(def options-mode-global
(l/derived :options-mode workspace-global))
(def default-font
(l/derived :default-font workspace-global))
(def inspect-expanded
(l/derived :inspect-expanded workspace-local))
@@ -355,6 +358,9 @@
(def workspace-editor-state
(l/derived :workspace-editor-state st/state))
(def workspace-v2-editor-state
(l/derived :workspace-v2-editor-state st/state))
(def workspace-modifiers
(l/derived :workspace-modifiers st/state =))

View File

@@ -105,7 +105,7 @@
:dashboard-team-webhooks
:dashboard-team-settings)
[:?
#_[:& app.main.ui.releases/release-notes-modal {:version "1.19"}]
#_[:& app.main.ui.releases/release-notes-modal {:version "2.3"}]
#_[:& app.main.ui.onboarding/onboarding-templates-modal]
#_[:& app.main.ui.onboarding/onboarding-modal]
#_[:& app.main.ui.onboarding.team-choice/onboarding-team-modal]

View File

@@ -483,7 +483,8 @@
;; Empty values means "submit" the form (whent some items have been added
(when (and (kbd/enter? event) (str/empty? @value) (not-empty @items))
(on-submit form))
(when (fn? on-submit)
(on-submit form event)))
;; If we have a string in the input we add it only if valid
(when (and (valid-item-fn val) (not (str/empty? @value)))

View File

@@ -30,4 +30,4 @@
(def workspace-read-only? (mf/create-context nil))
(def is-component? (mf/create-context false))
(def sidebar (mf/create-context nil))
(def sidebar (mf/create-context nil))

View File

@@ -8,10 +8,15 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.spec :as us]
[app.config :as cf]
[app.main.data.dashboard :as dd]
[app.main.data.dashboard.shortcuts :as sc]
[app.main.data.events :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as notif]
[app.main.data.plugins :as dp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.context :as ctx]
@@ -25,11 +30,16 @@
[app.main.ui.dashboard.team :refer [team-settings-page team-members-page team-invitations-page team-webhooks-page]]
[app.main.ui.dashboard.templates :refer [templates-section]]
[app.main.ui.hooks :as hooks]
[app.main.ui.workspace.plugins]
[app.plugins.register :as preg]
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[app.util.object :as obj]
[app.util.router :as rt]
[beicon.v2.core :as rx]
[goog.events :as events]
[okulary.core :as l]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
(defn ^boolean uuid-str?
@@ -143,6 +153,65 @@
(def dashboard-initialized
(l/derived :current-team-id st/state))
(defn use-plugin-register
[plugin-url team-id project-id]
(let [navegate-file!
(fn [plugin {:keys [project-id id data]}]
(st/emit!
(dp/delay-open-plugin plugin)
(rt/nav :workspace
{:project-id project-id :file-id id}
{:page-id (dm/get-in data [:pages 0])})))
create-file!
(fn [plugin]
(st/emit!
(modal/hide)
(let [data
(with-meta
{:project-id project-id
:name (dm/str "Try plugin: " (:name plugin))}
{:on-success (partial navegate-file! plugin)})]
(-> (dd/create-file data)
(with-meta {::ev/origin "plugin-try-out"})))))
open-try-out-dialog
(fn [plugin]
(modal/show
:plugin-try-out
{:plugin plugin
:on-accept #(create-file! plugin)
:on-close #(modal/hide!)}))
open-permissions-dialog
(fn [plugin]
(modal/show!
:plugin-permissions
{:plugin plugin
:on-accept
#(do (preg/install-plugin! plugin)
(st/emit! (modal/hide)
(rt/nav :dashboard-projects {:team-id team-id})
(open-try-out-dialog plugin)))
:on-close
#(st/emit! (modal/hide)
(rt/nav :dashboard-projects {:team-id team-id}))}))]
(mf/with-layout-effect
[plugin-url team-id project-id]
(when plugin-url
(->> (dp/fetch-manifest plugin-url)
(rx/subs!
(fn [plugin]
(if plugin
(do
(st/emit! (ptk/event ::ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url}))
(open-permissions-dialog plugin))
(st/emit! (notif/error "Cannot parser the plugin manifest"))))
(fn [_]
(st/emit! (notif/error "The plugin URL is incorrect")))))))))
(mf/defc dashboard
{::mf/props :obj}
[{:keys [route profile]}]
@@ -150,8 +219,12 @@
params (parse-params route)
project-id (:project-id params)
team-id (:team-id params)
search-term (:search-term params)
plugin-url (-> route :query-params :plugin)
invite-email (-> route :query-params :invite-email)
teams (mf/deref refs/teams)
@@ -160,6 +233,8 @@
projects (mf/deref refs/dashboard-projects)
project (get projects project-id)
default-project (->> projects vals (d/seek :is-default))
initialized? (mf/deref dashboard-initialized)]
(hooks/use-shortcuts ::dashboard sc/shortcuts)
@@ -178,6 +253,8 @@
(fn []
(events/unlistenByKey key))))
(use-plugin-register plugin-url team-id (:id default-project))
[:& (mf/provider ctx/current-team-id) {:value team-id}
[:& (mf/provider ctx/current-project-id) {:value project-id}
;; NOTE: dashboard events and other related functions assumes
@@ -206,4 +283,3 @@
:search-term search-term
:team team
:invite-email invite-email}])])]]))

View File

@@ -19,7 +19,8 @@
(mf/defc search-page
[{:keys [team search-term] :as props}]
(let [result (mf/deref refs/dashboard-search-result)
(let [search-term (or search-term "")
result (mf/deref refs/dashboard-search-result)
[rowref limit] (hooks/use-dynamic-grid-item-width)]
(mf/use-effect

View File

@@ -10,7 +10,6 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.common.spec :as us]
[app.config :as cfg]
[app.main.data.dashboard :as dd]
[app.main.data.events :as ev]
@@ -30,7 +29,6 @@
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@@ -129,17 +127,10 @@
]
(filterv identity)))
(s/def ::emails (s/and ::us/set-of-valid-emails d/not-empty?))
(s/def ::role ::us/keyword)
(s/def ::team-id ::us/uuid)
(s/def ::invite-member-form
(s/keys :req-un [::role ::emails ::team-id]))
(def ^:private schema:invite-member-form
[:map {:title "InviteMemberForm"}
[:role :keyword]
[:emails [::sm/set {:kind ::sm/email :min 1}]]
[:emails [::sm/set {:min 1} ::sm/email]]
[:team-id ::sm/uuid]])
(mf/defc invite-members-modal
@@ -181,6 +172,14 @@
(st/emit! (ntf/error (tr "errors.profile-is-muted"))
(modal/hide))
(and (= :validation type)
(= :max-invitations-by-request code))
(swap! error-text (tr "errors.maximum-invitations-by-request-reached" (:threshold error)))
(and (= :restriction type)
(= :max-quote-reached code))
(swap! error-text (tr "errors.max-quote-reached" (:target error)))
(or (= :member-is-muted code)
(= :email-has-permanent-bounces code)
(= :email-has-complaints code))
@@ -226,10 +225,9 @@
:name :emails
:auto-focus? true
:trim true
:valid-item-fn us/parse-email
:valid-item-fn sm/parse-email
:caution-item-fn current-members-emails
:label (tr "modals.invite-member.emails")
:on-submit on-submit
:invite-email invite-email}]]
[:div {:class (stl/css :action-buttons)}

View File

@@ -195,19 +195,17 @@
(fn [_event]
(swap! collapsed* not)))
update-can-move
(fn [scroll-left scroll-available client-width]
(reset! can-move {:left (> scroll-left 0)
:right (> scroll-available client-width)}))
on-scroll
(mf/use-fn
(fn [e]
(let [scroll (dom/get-target-scroll e)
scroll-left (:scroll-left scroll)
(let [scroll (dom/get-target-scroll e)
scroll-left (:scroll-left scroll)
scroll-available (- (:scroll-width scroll) scroll-left)
client-rect (dom/get-client-size (dom/get-target e))]
(update-can-move scroll-left scroll-available (unchecked-get client-rect "width")))))
client-rect (dom/get-client-size (dom/get-target e))
client-width (unchecked-get client-rect "width")]
(reset! can-move {:left (> scroll-left 0)
:right (> scroll-available client-width)}))))
on-move-left
(mf/use-fn #(move-left))
@@ -231,7 +229,7 @@
(let [content (mf/ref-val content-ref)]
(when (and (some? content) (some? templates))
(dom/scroll-to content #js {:behavior "instant" :left 0 :top 0})
(.dispatchEvent content (js/Event. "scroll")))))
(dom/dispatch-event content (dom/event "scroll")))))
(mf/with-effect [profile collapsed]
(swap! storage/global assoc ::collapsed collapsed)

View File

@@ -11,11 +11,11 @@
[app.common.schema :as sm]
[app.main.data.dashboard :as dd]
[app.main.data.events :as ev]
[app.main.data.notifications :as ntf]
[app.main.data.users :as du]
[app.main.store :as st]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i]
[app.main.ui.notifications.context-notification :refer [context-notification]]
[app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt]
[potok.v2.core :as ptk]
@@ -57,7 +57,7 @@
(def ^:private schema:invite-form
[:map {:title "InviteForm"}
[:role :keyword]
[:emails {:optional true} [::sm/set {:kind ::sm/email}]]])
[:emails {:optional true} [::sm/set ::sm/email]]])
(defn- get-available-roles
[]
@@ -67,17 +67,14 @@
(mf/defc team-form-step-2
{::mf/props :obj}
[{:keys [name on-back go-to-team?]}]
(let [initial (mf/use-memo
#(do {:role "editor"
:name name}))
(let [initial (mf/with-memo []
{:role "editor" :name name})
form (fm/use-form :schema schema:invite-form
:initial initial)
params (:clean-data @form)
emails (:emails params)
roles (mf/use-memo get-available-roles)
error* (mf/use-state nil)
on-success
(mf/use-fn
@@ -90,8 +87,28 @@
on-error
(mf/use-fn
(fn [_]
(st/emit! (ntf/error (tr "errors.generic")))))
(fn [cause]
(let [{:keys [type code] :as error} (ex-data cause)]
(cond
(and (= :validation type)
(= :profile-is-muted code))
(swap! error* (tr "errors.profile-is-muted"))
(and (= :validation type)
(= :max-invitations-by-request code))
(swap! error* (tr "errors.maximum-invitations-by-request-reached" (:threshold error)))
(and (= :restriction type)
(= :max-quote-reached code))
(swap! error* (tr "errors.max-quote-reached" (:target error)))
(or (= :member-is-muted code)
(= :email-has-permanent-bounces code)
(= :email-has-complaints code))
(swap! error* (tr "errors.email-spam-or-permanent-bounces" (:email error)))
:else
(swap! error* (tr "errors.generic"))))))
on-invite-later
(mf/use-fn
@@ -111,7 +128,7 @@
on-invite-now
(mf/use-fn
(fn [{:keys [name] :as params}]
(fn [{:keys [name emails] :as params}]
(let [mdata {:on-success on-success
:on-error on-error}]
@@ -143,6 +160,10 @@
[:& fm/form {:form form
:class (stl/css :modal-form-invitations)
:on-submit on-submit}
(when-let [content (deref error*)]
[:& context-notification {:content content :level :error}])
[:div {:class (stl/css :role-select)}
[:p {:class (stl/css :role-title)} (tr "onboarding.choice.team-up.roles")]
[:& fm/select {:name :role :options roles}]]
@@ -155,18 +176,22 @@
:valid-item-fn sm/parse-email
:caution-item-fn #{}
:label (tr "modals.invite-member.emails")
:on-submit on-submit}]]
;; :on-submit on-submit
}]]
[:div {:class (stl/css :action-buttons)}
[:button {:class (stl/css :back-button)
:on-click on-back}
(tr "labels.back")]
[:> fm/submit-button*
{:class (stl/css :accept-button)
:label (if (> (count emails) 0)
(tr "onboarding.choice.team-up.create-team-and-invite")
(tr "onboarding.choice.team-up.create-team-without-invite"))}]]
(let [params (:clean-data @form)
emails (:emails params)]
[:> fm/submit-button*
{:class (stl/css :accept-button)
:label (if (> (count emails) 0)
(tr "onboarding.choice.team-up.create-team-and-invite")
(tr "onboarding.choice.team-up.create-team-without-invite"))}])]
[:div {:class (stl/css :modal-hint)}
"(" (tr "onboarding.choice.team-up.create-team-and-send-invites-description") ")"]]]

View File

@@ -29,6 +29,7 @@
[app.main.ui.releases.v2-0]
[app.main.ui.releases.v2-1]
[app.main.ui.releases.v2-2]
[app.main.ui.releases.v2-3]
[app.util.object :as obj]
[app.util.timers :as tm]
[rumext.v2 :as mf]))

View File

@@ -0,0 +1,115 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.releases.v2-3
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
;; TODO: Review all copies and alt text
(defmethod c/render-release-notes "2.3"
[{:keys [slide klass next finish navigate version]}]
(mf/html
(case slide
:start
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.3-slide-0.png"
:class (stl/css :start-image)
:border "0"
:alt "A graphic illustration with Penpot style"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Whats new in Penpot?"]
[:div {:class (stl/css :version-tag)}
(dm/str "Version " version)]]
[:div {:class (stl/css :features-block)}
[:span {:class (stl/css :feature-title)}
"Penpot can now be extended by using Plugins!"]
[:p {:class (stl/css :feature-content)}
"The introduction of our brand new Plugin system allows you to access even richer ecosystem of capabilities."]
[:p {:class (stl/css :feature-content)}
"We are beyond excitement about how this will further involve the Penpot community in building the best design and prototyping platform."]
[:p {:class (stl/css :feature-content)}
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.3-img-slide-1.gif"
:class (stl/css :start-image)
:border "0"
:alt "Build Plugins to enhance your workflow"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Build Plugins and enhance your workflow"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"Penpot Plugins encourage developers to easily customize and expand the platform using standard web technologies like JavaScript, CSS, and HTML."]
[:p {:class (stl/css :feature-content)}
"Find everything you need in ouor full comprehensive documentation to start building your plugins now!"]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 2}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.3-img-slide-2.gif"
:class (stl/css :start-image)
:border "0"
:alt "Plugins are safe and extremely easy to use"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Plugins are safe and extremely easy to use"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"Penpot plugins are quite easy to install."]
[:p {:class (stl/css :feature-content)}
"Be sure to keep an eye on our evolving " [:a {:href "https://penpot.app/penpothub" :target "_blank"} "Penpot Hub"] " to pick the ones that are best suited to enhance your workflow."]
[:p {:class (stl/css :feature-content)}
"This is just the beginning of a myriad of possibilities. Lets build this community together <3."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 2}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))

View File

@@ -0,0 +1,102 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@import "refactor/common-refactor.scss";
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: $s-324 1fr;
height: $s-500;
width: $s-888;
border-radius: $br-8;
background-color: var(--modal-background-color);
border: $s-2 solid var(--modal-border-color);
}
.start-image {
width: $s-324;
border-radius: $br-8 0 0 $br-8;
}
.modal-content {
padding: $s-40;
display: grid;
grid-template-rows: auto 1fr $s-32;
gap: $s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: $s-8;
}
.version-tag {
@include flexCenter;
@include headlineSmallTypography;
height: $s-32;
width: $s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: $br-8;
}
.modal-title {
@include headlineLargeTypography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: $s-16;
width: $s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: $s-8;
}
.feature-title {
@include bodyLargeTypography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include bodyMediumTypography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include bodyMediumTypography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: $s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend .button-primary;
width: $s-100;
justify-self: flex-end;
grid-area: button;
}

View File

@@ -8,6 +8,7 @@
(:require
[app.common.data.macros :as dm]
[app.common.spec :as us]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.users :as du]
@@ -16,6 +17,7 @@
[app.util.router :as rt]
[beicon.v2.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
(s/def ::page-id ::us/uuid)
@@ -94,10 +96,11 @@
(defn on-navigate
[router path]
(let [location (.-location js/document)
[base-path qs] (str/split path "?")
location-path (dm/str (.-origin location) (.-pathname location))
valid-location? (= location-path (dm/str cf/public-uri))
match (match-path router path)
empty-path? (or (= path "") (= path "/"))]
empty-path? (or (= base-path "") (= base-path "/"))]
(cond
(not valid-location?)
(st/emit! (rt/assign-exception {:type :not-found}))
@@ -116,7 +119,7 @@
(st/emit! (rt/nav :auth-login))
empty-path?
(st/emit! (rt/nav :dashboard-projects {:team-id (du/get-current-team-id profile)}))
(st/emit! (rt/nav :dashboard-projects {:team-id (du/get-current-team-id profile)} (u/query-string->map qs)))
:else
(st/emit! (rt/assign-exception {:type :not-found})))))))))

View File

@@ -173,7 +173,7 @@
(mf/defc export-flows
[{:keys [flows]}]
[:> "penpot:flows" #js {}
(for [{:keys [id name starting-frame]} flows]
(for [{:keys [id name starting-frame]} (vals flows)]
[:> "penpot:flow" #js {:id id
:name name
:starting-frame starting-frame}])])

View File

@@ -10,6 +10,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.shapes.bounds :as gsb]
[app.common.math :as mth]
[app.common.uuid :as uuid]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@@ -129,6 +130,34 @@
[filters]
(map #(assoc %1 :filter-in %2) filters (cons nil (map :id filters))))
(defn filter-coords
[bounds selrect padding]
(if (or (mth/close? 0.01 (:width selrect))
(mth/close? 0.01 (:height selrect)))
;; We cannot use "objectBoundingbox" if the shape doesn't have width/heigth
;; From the SVG spec (https://www.w3.org/TR/SVG11/coords.html#ObjectBoundingBox
;; Keyword objectBoundingBox should not be used when the geometry of the applicable element
;; has no width or no height, such as the case of a horizontal or vertical line, even when
;; the line has actual thickness when viewed due to having a non-zero stroke width since
;; stroke width is ignored for bounding box calculations. When the geometry of the
;; applicable element has no width or height and objectBoundingBox is specified, then
;; the given effect (e.g., a gradient or a filter) will be ignored.
(let [filter-width (+ (:width bounds) (* 2 (:horizontal padding)))
filter-height (+ (:height bounds) (* 2 (:vertical padding)))
filter-x (- (:x bounds) #_(:x selrect) (:horizontal padding))
filter-y (- (:y bounds) #_(:y selrect) (:vertical padding))
filter-units "userSpaceOnUse"]
[filter-x filter-y filter-width filter-height filter-units])
;; If the width/height is not zero we use objectBoundingBox as it's more stable
(let [filter-width (/ (+ (:width bounds) (* 2 (:horizontal padding))) (:width selrect))
filter-height (/ (+ (:height bounds) (* 2 (:vertical padding))) (:height selrect))
filter-x (/ (- (:x bounds) (:x selrect) (:horizontal padding)) (:width selrect))
filter-y (/ (- (:y bounds) (:y selrect) (:vertical padding)) (:height selrect))
filter-units "objectBoundingBox"]
[filter-x filter-y filter-width filter-height filter-units])))
(mf/defc filters
[{:keys [filter-id shape]}]
@@ -136,17 +165,17 @@
bounds (gsb/get-rect-filter-bounds (:selrect shape) filters (or (-> shape :blur :value) 0))
padding (gsb/calculate-padding shape)
selrect (:selrect shape)
filter-x (/ (- (:x bounds) (:x selrect) (:horizontal padding)) (:width selrect))
filter-y (/ (- (:y bounds) (:y selrect) (:vertical padding)) (:height selrect))
filter-width (/ (+ (:width bounds) (* 2 (:horizontal padding))) (:width selrect))
filter-height (/ (+ (:height bounds) (* 2 (:vertical padding))) (:height selrect))]
[filter-x filter-y filter-width filter-height filter-units]
(filter-coords bounds selrect padding)]
(when (> (count filters) 2)
[:filter {:id filter-id
:x filter-x
:y filter-y
:width filter-width
:height filter-height
:filterUnits "objectBoundingBox"
:filterUnits filter-units
:color-interpolation-filters "sRGB"}
(for [[index entry] (d/enumerate filters)]
[:& filter-entry {:key (dm/str filter-id "-" index)

View File

@@ -15,6 +15,7 @@
[app.main.ui.context :as muc]
[app.main.ui.shapes.attrs :as attrs]
[app.main.ui.shapes.custom-stroke :refer [shape-fills shape-strokes]]
[app.main.ui.shapes.filters :as filters]
[app.util.debug :as dbg]
[app.util.object :as obj]
[rumext.v2 :as mf]))
@@ -65,6 +66,11 @@
render-id (mf/use-ctx muc/render-id)
filter-id-blur (dm/fmt "filter-blur-%" render-id)
filter-id-shadows (dm/fmt "filter-shadow-%" render-id)
filter-str-blur (filters/filter-str filter-id-blur (dissoc shape :shadow))
filter-str-shadows (filters/filter-str filter-id-shadows (dissoc shape :blur))
x (dm/get-prop shape :x)
y (dm/get-prop shape :y)
w (dm/get-prop shape :width)
@@ -86,29 +92,37 @@
:className "frame-background"})))
path? (some? (.-d props))]
[:*
[:g {:clip-path (when-not ^boolean show-content?
(frame-clip-url shape render-id))
;; A frame sets back normal fill behavior (default
;; transparent). It may have been changed to default black
;; if a shape coming from an imported SVG file is
;; rendered. See main.ui.shapes.attrs/add-style-attrs.
:fill "none"
:opacity opacity}
;; We need to separate blur from shadows because the blur is applied to the strokes
;; while the shadows have to be placed *under* the stroke (for example, the inner shadows)
;; and the shadows needs to be applied only to the content (without the stroke)
[:g {:filter filter-str-blur}
[:defs
[:& filters/filters {:shape (dissoc shape :blur) :filter-id filter-id-shadows}]
[:& filters/filters {:shape (assoc shape :shadow []) :filter-id filter-id-blur}]]
[:& shape-fills {:shape shape}
(if ^boolean path?
[:> :path props]
[:> :rect props])]
;; This need to be separated in two layers so the clip doesn't affect the shadow filters
;; otherwise the shadow will be clipped and not visible
[:g {:filter filter-str-shadows}
[:g {:clip-path (when-not ^boolean show-content? (frame-clip-url shape render-id))
;; A frame sets back normal fill behavior (default
;; transparent). It may have been changed to default black
;; if a shape coming from an imported SVG file is
;; rendered. See main.ui.shapes.attrs/add-style-attrs.
:fill "none"
:opacity opacity}
children]
[:& shape-fills {:shape shape}
(if ^boolean path?
[:> :path props]
[:> :rect props])]
children]]
[:& shape-strokes {:shape shape}
(if ^boolean path?
[:> :path props]
[:> :rect props])]]))
(mf/defc frame-thumbnail-image
{::mf/wrap-props false}
[props]

View File

@@ -56,7 +56,6 @@
(let [shape (unchecked-get props "shape")
children (unchecked-get props "children")
pointer-events (unchecked-get props "pointer-events")
disable-shadows? (unchecked-get props "disable-shadows?")
shape-id (dm/get-prop shape :id)
preview-blend-mode-ref
@@ -67,7 +66,6 @@
type (dm/get-prop shape :type)
render-id (h/use-render-id)
filter-id (dm/str "filter-" render-id)
styles (-> (obj/create)
(obj/set! "pointerEvents" pointer-events)
(cond-> (not (cfh/frame-shape? shape))
@@ -82,32 +80,30 @@
shape-without-blur (dissoc shape :blur)
shape-without-shadows (assoc shape :shadow [])
filter-id (dm/str "filter-" render-id)
filter-str
(when (and (or (cfh/group-shape? shape)
(cfh/frame-shape? shape)
(cfh/svg-raw-shape? shape))
(not disable-shadows?))
(when (or (cfh/group-shape? shape)
(cfh/svg-raw-shape? shape))
(filters/filter-str filter-id shape))
wrapper-props
(-> (obj/clone props)
(obj/unset! "shape")
(obj/unset! "children")
(obj/unset! "disable-shadows?")
(obj/set! "ref" ref)
(obj/set! "id" (dm/fmt "shape-%" shape-id))
(obj/set! "data-testid" (:name shape))
;; TODO: This is added for backward compatibility.
(cond-> (and (cfh/text-shape? shape) (empty? (:position-data shape)))
(-> (obj/set! "x" (:x shape))
(obj/set! "y" (:y shape))
(obj/set! "width" (:width shape))
(obj/set! "height" (:height shape))))
(obj/set! "style" styles))
wrapper-props
(cond-> wrapper-props
;; NOTE: This is added for backward compatibility
(and (cfh/text-shape? shape)
(empty? (:position-data shape)))
(-> (obj/set! "x" (:x shape))
(obj/set! "y" (:y shape))
(obj/set! "width" (:width shape))
(obj/set! "height" (:height shape)))
(= :group type)
(-> (attrs/add-fill-props! shape render-id)
(attrs/add-border-props! shape))
@@ -115,11 +111,13 @@
(some? filter-str)
(obj/set! "filter" filter-str))
svg-group? (and (contains? shape :svg-attrs) (= :group type))
svg-group?
(and (contains? shape :svg-attrs) (= :group type))
children (cond-> children
svg-group?
(propagate-wrapper-styles wrapper-props))]
children
(cond-> children
svg-group?
(propagate-wrapper-styles wrapper-props))]
[:& (mf/provider muc/render-id) {:value render-id}
[:> :g wrapper-props
@@ -128,9 +126,14 @@
[:defs
[:& defs/svg-defs {:shape shape :render-id render-id}]
[:& filters/filters {:shape shape :filter-id filter-id}]
[:& filters/filters {:shape shape-without-blur :filter-id (dm/fmt "filter-shadow-%" render-id)}]
[:& filters/filters {:shape shape-without-shadows :filter-id (dm/fmt "filter-blur-%" render-id)}]
;; The filters for frames should be setup inside the container.
(when-not (cfh/frame-shape? shape)
[:*
[:& filters/filters {:shape shape :filter-id filter-id}]
[:& filters/filters {:shape shape-without-blur :filter-id (dm/fmt "filter-shadow-%" render-id)}]
[:& filters/filters {:shape shape-without-shadows :filter-id (dm/fmt "filter-blur-%" render-id)}]])
[:& frame/frame-clip-def {:shape shape :render-id render-id}]
;; Text fills need to be defined afterwards because they are specified per text-block

View File

@@ -55,6 +55,7 @@
:fontSize 0 ;;(str (:font-size data (:font-size txt/default-text-attrs)) "px")
:lineHeight (:line-height data (:line-height txt/default-text-attrs))
:margin 0}]
(cond-> base
(some? line-height) (obj/set! "lineHeight" line-height)
(some? text-align) (obj/set! "textAlign" text-align))))
@@ -74,6 +75,7 @@
font-variant-id (:font-variant-id data)
font-size (:font-size data)
fill-color (or (-> data :fills first :fill-color) (:fill-color data))
fill-opacity (or (-> data :fills first :fill-opacity) (:fill-opacity data))
fill-gradient (or (-> data :fills first :fill-color-gradient) (:fill-color-gradient data))
@@ -92,6 +94,7 @@
base #js {:textDecoration text-decoration
:textTransform text-transform
:fontSize font-size
:color (if (and show-text? (not gradient?)) text-color "transparent")
:background (when (and show-text? gradient?) text-color)
:caretColor (if (and (not gradient?) text-color) text-color "black")

View File

@@ -98,7 +98,11 @@
(obj/set! "fill" (str "url(#fill-" index "-" render-id ")")))}
(cond-> browser-props
(obj/merge! browser-props)))
shape (assoc shape :fills (:fills data))
shape (-> shape
(assoc :fills (:fills data))
;; The text elements have the shadow and blur already applied in the
;; group parent.
(dissoc :shadow :blur))
;; Need to create new render-id per text-block
render-id (dm/str render-id "-" index)]

View File

@@ -20,6 +20,8 @@
[app.main.ui.auth.recovery-request :refer [recovery-request-page recovery-sent-page]]
[app.main.ui.auth.register :as register]
[app.main.ui.dashboard.sidebar :refer [sidebar]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*]]
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
[app.main.ui.icons :as i]
[app.main.ui.viewer.header :as viewer.header]
[app.util.dom :as dom]
@@ -42,11 +44,10 @@
[:section {:class (stl/css :exception-layout)}
[:button
{:class (stl/css :exception-header)
:on-click rt/nav-root}
i/logo-icon
:on-click on-nav-root}
[:> raw-svg* {:id "penpot-logo-icon" :class (stl/css :penpot-logo)}]
(when profile-id
(str "< "
(tr "not-found.no-permission.go-dashboard")))]
[:div {:class (stl/css :go-back-wrapper)} [:> icon* {:id "arrow" :class (stl/css :back-arrow)}] [:span (tr "not-found.no-permission.go-dashboard")]])]
[:div {:class (stl/css :deco-before)} i/logo-error-screen]
(when-not profile-id
[:button {:class (stl/css :login-header)

View File

@@ -66,6 +66,7 @@
justify-content: flex-end;
height: 100%;
width: 25%;
padding-bottom: $s-28;
&:first-child {
text-align: right;
@@ -82,12 +83,25 @@
cursor: pointer;
display: flex;
align-items: center;
}
svg {
fill: var(--color-foreground-primary);
width: $s-48;
height: auto;
}
.penpot-logo {
fill: var(--color-foreground-primary);
width: $s-48;
height: $s-48;
}
.back-arrow {
transform: rotate(180deg);
}
.go-back-wrapper {
display: flex;
justify-content: center;
align-items: center;
gap: $s-8;
margin-left: $s-12;
font-size: $fs-14;
}
.login-header {

View File

@@ -16,6 +16,7 @@
[app.main.data.events :as ev]
[app.main.data.exports :as de]
[app.main.data.modal :as modal]
[app.main.data.plugins :as dp]
[app.main.data.shortcuts :as scd]
[app.main.data.users :as du]
[app.main.data.workspace :as dw]
@@ -29,7 +30,6 @@
[app.main.ui.context :as ctx]
[app.main.ui.hooks.resize :as r]
[app.main.ui.icons :as i]
[app.main.ui.workspace.plugins :as uwp]
[app.plugins.register :as preg]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
@@ -637,7 +637,7 @@
::ev/origin "workspace:menu"
:name name
:host host}))
(uwp/open-plugin! manifest))
(dp/open-plugin! manifest))
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
@@ -646,7 +646,7 @@
::ev/origin "workspace:menu"
:name name
:host host}))
(uwp/open-plugin! manifest))))}
(dp/open-plugin! manifest))))}
[:span {:class (stl/css :item-name)} name]])])))
(mf/defc menu

View File

@@ -9,9 +9,10 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.config :as cf]
[app.config :as cfg]
[app.main.data.events :as ev]
[app.main.data.modal :as modal]
[app.main.data.plugins :as dp]
[app.main.store :as st]
[app.main.ui.components.search-bar :refer [search-bar]]
[app.main.ui.components.title-bar :refer [title-bar]]
@@ -19,7 +20,6 @@
[app.plugins.register :as preg]
[app.util.avatars :as avatars]
[app.util.dom :as dom]
[app.util.http :as http]
[app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
@@ -29,6 +29,15 @@
(def ^:private close-icon
(i/icon-xref :close (stl/css :close-icon)))
(defn icon-url
"Creates an sanitizes de icon URL to display"
[host icon]
(dm/str host
(if (and (not (str/ends-with? host "/"))
(not (str/starts-with? icon "/")))
"/" "")
icon))
(mf/defc plugin-entry
[{:keys [index manifest on-open-plugin on-remove-plugin]}]
@@ -49,7 +58,7 @@
[:div {:class (stl/css :plugins-list-element)}
[:div {:class (stl/css :plugin-icon)}
[:img {:src (if (some? icon)
(dm/str host icon)
(icon-url host icon)
(avatars/generate {:name name}))}]]
[:div {:class (stl/css :plugin-description)}
[:div {:class (stl/css :plugin-title)} name]
@@ -59,22 +68,6 @@
[:button {:class (stl/css :trash-button)
:on-click handle-delete-click} i/delete]]))
(defn open-plugin!
[{:keys [plugin-id name description host code icon permissions]}]
(try
(.ɵloadPlugin
js/window
#js {:pluginId plugin-id
:name name
:description description
:host host
:code code
:icon icon
:permissions (apply array permissions)})
(catch :default e
(.error js/console "Error" e))))
(mf/defc plugin-management-dialog
{::mf/register modal/components
::mf/register-as :plugin-management}
@@ -111,15 +104,11 @@
(mf/deps plugins-state plugin-url)
(fn []
(reset! fetching-manifest? true)
(->> (http/send! {:method :get
:uri plugin-url
:omit-default-headers true
:response-type :json})
(rx/map :body)
(->> (dp/fetch-manifest plugin-url)
(rx/subs!
(fn [body]
(fn [plugin]
(reset! fetching-manifest? false)
(if-let [plugin (preg/parse-manifest plugin-url body)]
(if plugin
(do
(st/emit! (ptk/event ::ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url}))
(modal/show!
@@ -133,7 +122,8 @@
(reset! plugin-url* ""))
;; Cannot get the manifest
(reset! input-status* :error-manifest)))
(fn [_]
(fn [err]
(.error js/console err)
(reset! fetching-manifest? false)
(reset! input-status* :error-url))))))
@@ -144,7 +134,7 @@
::ev/origin "workspace:plugins"
:name (:name manifest)
:host (:host manifest)}))
(open-plugin! manifest)
(dp/open-plugin! manifest)
(modal/hide!)))
handle-remove-plugin
@@ -156,6 +146,7 @@
(st/emit! (ptk/event ::ev/event {::ev/name "remove-plugin"
:name (:name plugin)
:host (:host plugin)}))
(dp/close-plugin! plugin)
(preg/remove-plugin! plugin)
(reset! plugins-state* (preg/plugins-list)))))]
@@ -183,10 +174,11 @@
[:div {:class (stl/css-case :info true :error error?)}
(tr "workspace.plugins.error.manifest")])
[:> i18n/tr-html*
{:class (stl/css :discover)
:on-click #(st/emit! (ptk/event ::ev/event {::ev/name "open-plugins-list"}))
:content (tr "workspace.plugins.discover" cf/plugins-list-uri)}]
(when-not (empty? plugins-state)
[:> i18n/tr-html*
{:class (stl/css :discover)
:on-click #(st/emit! (ptk/event ::ev/event {::ev/name "open-plugins-list"}))
:content (tr "workspace.plugins.discover" cfg/plugins-list-uri)}])
[:hr]
@@ -195,7 +187,7 @@
[:div {:class (stl/css :plugins-empty-logo)} i/puzzle]
[:div {:class (stl/css :plugins-empty-text)} (tr "workspace.plugins.empty-plugins")]
[:a {:class (stl/css :plugins-link)
:href cf/plugins-list-uri
:href cfg/plugins-list-uri
:target "_blank"
:on-click #(st/emit! (ptk/event ::ev/event {::ev/name "open-plugins-list"}))}
(tr "workspace.plugins.plugin-list-link") i/external-link]]
@@ -212,10 +204,66 @@
:on-open-plugin handle-open-plugin
:on-remove-plugin handle-remove-plugin}])]])]]]))
(mf/defc plugins-permission-list
[{:keys [permissions]}]
[:div {:class (stl/css :permissions-list)}
(cond
(contains? permissions "content:write")
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.content-write")]]
(contains? permissions "content:read")
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.content-read")]])
(cond
(contains? permissions "user:read")
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-2
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.user-read")]])
(cond
(contains? permissions "library:write")
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-3
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.library-write")]]
(contains? permissions "library:read")
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-3
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.library-read")]])
(cond
(contains? permissions "comment:write")
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.comment-write")]]
(contains? permissions "comment:read")
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.comment-read")]])
(cond
(contains? permissions "allow:downloads")
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.allow-download")]])])
(mf/defc plugins-permissions-dialog
{::mf/register modal/components
::mf/register-as :plugin-permissions}
[{:keys [plugin on-accept]}]
[{:keys [plugin on-accept on-close]}]
(let [{:keys [host permissions]} plugin
permissions (set permissions)
@@ -224,63 +272,33 @@
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(st/emit! (modal/hide))
(ptk/event ::ev/event {::ev/name "allow-plugin-permissions"
:host host
:permissions (->> permissions (str/join ", "))})
(on-accept)))
(st/emit! (ptk/event ::ev/event {::ev/name "allow-plugin-permissions"
:host host
:permissions (->> permissions (str/join ", "))})
(modal/hide))
(when on-accept (on-accept))))
handle-close-dialog
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(ptk/event ::ev/event {::ev/name "reject-plugin-permissions"
:host host
:permissions (->> permissions (str/join ", "))})
(st/emit! (modal/hide))))]
(st/emit! (ptk/event ::ev/event {::ev/name "reject-plugin-permissions"
:host host
:permissions (->> permissions (str/join ", "))})
(modal/hide))
(when on-close (on-close))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :plugin-permissions)}
[:button {:class (stl/css :close-btn) :on-click handle-close-dialog} close-icon]
[:div {:class (stl/css :modal-title)} (tr "workspace.plugins.permissions.title")]
[:div {:class (stl/css :modal-title)} (tr "workspace.plugins.permissions.title" (str/upper (:name plugin)))]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :permissions-list)}
(cond
(contains? permissions "content:write")
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.content-write")]]
[:& plugins-permission-list {:permissions permissions}]
(contains? permissions "content:read")
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.content-read")]])
(cond
(contains? permissions "user:read")
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-2
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.user-read")]])
(cond
(contains? permissions "library:write")
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-3
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.library-write")]]
(contains? permissions "library:read")
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-3
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.library-read")]])]
[:div {:class (stl/css :permissions-disclaimer)}
(tr "workspace.plugins.permissions.disclaimer")]]
(when-not (contains? cfg/plugins-whitelist host)
[:div {:class (stl/css :permissions-disclaimer)}
(tr "workspace.plugins.permissions.disclaimer")])]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
@@ -295,3 +313,109 @@
:type "button"
:value (tr "ds.confirm-allow")
:on-click handle-accept-dialog}]]]]]))
(mf/defc plugins-permissions-updated-dialog
{::mf/register modal/components
::mf/register-as :plugin-permissions-update}
[{:keys [plugin on-accept on-close]}]
(let [{:keys [host permissions]} plugin
permissions (set permissions)
handle-accept-dialog
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(st/emit! (ptk/event ::ev/event {::ev/name "allow-plugin-permissions"
:host host
:permissions (->> permissions (str/join ", "))})
(modal/hide))
(when on-accept (on-accept))))
handle-close-dialog
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(st/emit! (ptk/event ::ev/event {::ev/name "reject-plugin-permissions"
:host host
:permissions (->> permissions (str/join ", "))})
(modal/hide))
(when on-close (on-close))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :plugin-permissions)}
[:button {:class (stl/css :close-btn) :on-click handle-close-dialog} close-icon]
[:div {:class (stl/css :modal-title)}
(tr "workspace.plugins.permissions-update.title" (str/upper (:name plugin)))]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-paragraph)}
(tr "workspace.plugins.permissions-update.warning")]
[:& plugins-permission-list {:permissions permissions}]]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
[:input
{:class (stl/css :cancel-button :button-expand)
:type "button"
:value (tr "ds.confirm-cancel")
:on-click handle-close-dialog}]
[:input
{:class (stl/css :primary-button :button-expand)
:type "button"
:value (tr "ds.confirm-allow")
:on-click handle-accept-dialog}]]]]]))
(mf/defc plugins-try-out-dialog
{::mf/register modal/components
::mf/register-as :plugin-try-out}
[{:keys [plugin on-accept on-close]}]
(let [{:keys [icon host name]} plugin
handle-accept-dialog
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(st/emit! (ptk/event ::ev/event {::ev/name "try-out-accept"})
(modal/hide))
(when on-accept (on-accept))))
handle-close-dialog
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(st/emit! (ptk/event ::ev/event {::ev/name "try-out-cancel"})
(modal/hide))
(when on-close (on-close))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :plugin-try-out)}
[:button {:class (stl/css :close-btn) :on-click handle-close-dialog} close-icon]
[:div {:class (stl/css :modal-title)}
[:div {:class (stl/css :plugin-icon)}
[:img {:src (if (some? icon)
(icon-url host icon)
(avatars/generate {:name name}))}]]
(tr "workspace.plugins.try-out.title" (str/upper (:name plugin)))]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-message)}
(tr "workspace.plugins.try-out.message")]]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
[:input
{:class (stl/css :cancel-button :button-expand)
:type "button"
:value (tr "workspace.plugins.try-out.cancel")
:on-click handle-close-dialog}]
[:input
{:class (stl/css :primary-button :button-expand)
:type "button"
:value (tr "workspace.plugins.try-out.try")
:on-click handle-accept-dialog}]]]]]))

View File

@@ -14,6 +14,7 @@
@extend .modal-container-base;
display: grid;
grid-template-rows: auto 1fr auto;
max-height: initial;
&.plugin-permissions {
width: $s-412;
@@ -25,6 +26,11 @@
max-width: $s-472;
}
&.plugin-try-out {
width: $s-452;
max-width: $s-452;
}
hr {
border-color: var(--color-background-tertiary);
}
@@ -47,6 +53,8 @@
@include headlineMediumTypography;
margin-block-end: $s-32;
color: var(--modal-title-foreground-color);
display: flex;
gap: $s-12;
}
.modal-content {
@@ -63,6 +71,16 @@
}
}
.modal-message {
font-size: $fs-14;
color: var(--color-foreground-secondary);
}
.modal-paragraph {
font-size: $fs-14;
color: var(--color-foreground-primary);
}
.primary-button {
@extend .button-primary;
@include headlineSmallTypography;
@@ -253,8 +271,8 @@ div.input-error {
.permissions-disclaimer {
@include bodySmallTypography;
padding: $s-16;
background: var(--color-background-tertiary);
color: var(--color-foreground-secondary);
background: var(--color-background-quaternary);
color: var(--color-foreground-primary);
border-radius: $br-4;
}

View File

@@ -8,7 +8,6 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.shapes.bounds :as gsb]
[app.common.math :as mth]
[app.common.thumbnails :as thc]
@@ -45,7 +44,7 @@
(refs/children-objects shape-id))
childs (mf/deref childs-ref)]
[:& shape-container {:shape shape :ref ref :disable-shadows? (cfh/is-direct-child-of-root? shape)}
[:& shape-container {:shape shape :ref ref}
[:& frame-shape {:shape shape :childs childs}]
(when *assert*
[:& wsd/shape-debug {:shape shape}])]))))
@@ -187,7 +186,7 @@
(fdm/use-dynamic-modifiers objects (mf/ref-val content-ref) modifiers)
[:& shape-container {:shape shape :disable-shadows? thumbnail?}
[:& shape-container {:shape shape}
[:g.frame-container
{:id (dm/str "frame-container-" frame-id)
:key "frame-container"

View File

@@ -200,7 +200,7 @@
(fn [editor]
(st/emit! (dwt/update-editor editor))
(when editor
(dom/add-class! (dom/get-element-by-class "public-DraftEditor-content") "mousetrap")
(dom/add-class! (dom/get-element-by-class "public-DraftEditor-content") "mousetrap")
(.focus ^js editor))))
handle-return

View File

@@ -0,0 +1,268 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.shapes.text.v2-editor
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.shapes :as gsh]
[app.common.geom.shapes.text :as gst]
[app.common.math :as mth]
[app.common.text :as txt]
[app.config :as cf]
[app.main.data.workspace :as dw]
[app.main.data.workspace.texts :as dwt]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.css-cursors :as cur]
[app.util.dom :as dom]
[app.util.globals :as global]
[app.util.keyboard :as kbd]
[app.util.object :as obj]
[app.util.text.content :as content]
[app.util.text.content.styles :as styles]
[rumext.v2 :as mf]))
(defn- initialize-event-handlers
"Internal editor events handler initializer/destructor"
[shape-id content selection-ref editor-ref container-ref]
(let [editor-node
(mf/ref-val editor-ref)
selection-node
(mf/ref-val selection-ref)
;; Gets the default font from the workspace refs.
default-font
(deref refs/default-font)
style-defaults
(styles/get-style-defaults
(merge txt/default-attrs default-font))
options
#js {:styleDefaults style-defaults
:selectionImposterElement selection-node}
instance
(dwt/create-editor editor-node options)
on-key-up
(fn [event]
(dom/stop-propagation event)
(when (kbd/esc? event)
(st/emit! :interrupt (dw/clear-edition-mode))))
on-blur
(fn []
(when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
(st/emit! (dwt/v2-update-text-shape-content shape-id content true)))
(let [container-node (mf/ref-val container-ref)]
(dom/set-style! container-node "opacity" 0)))
on-focus
(fn []
(let [container-node (mf/ref-val container-ref)]
(dom/set-style! container-node "opacity" 1)))
on-style-change
(fn [event]
(let [styles (styles/get-styles-from-event event)]
(st/emit! (dwt/v2-update-text-editor-styles shape-id styles))))
on-needs-layout
(fn []
(when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
(st/emit! (dwt/v2-update-text-shape-content shape-id content true)))
;; FIXME: We need to find a better way to trigger layout changes.
#_(st/emit!
(dwt/v2-update-text-shape-position-data shape-id [])))
on-change
(fn []
(when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
(st/emit! (dwt/v2-update-text-shape-content shape-id content true))))]
(.addEventListener ^js global/document "keyup" on-key-up)
(.addEventListener ^js instance "blur" on-blur)
(.addEventListener ^js instance "focus" on-focus)
(.addEventListener ^js instance "needslayout" on-needs-layout)
(.addEventListener ^js instance "stylechange" on-style-change)
(.addEventListener ^js instance "change" on-change)
(st/emit! (dwt/update-editor instance))
(when (some? content)
(dwt/set-editor-root! instance (content/cljs->dom content)))
(st/emit! (dwt/focus-editor))
;; This function is called when the component is unmount
(fn []
(.removeEventListener ^js global/document "keyup" on-key-up)
(.removeEventListener ^js instance "blur" on-blur)
(.removeEventListener ^js instance "focus" on-focus)
(.removeEventListener ^js instance "needslayout" on-needs-layout)
(.removeEventListener ^js instance "stylechange" on-style-change)
(.removeEventListener ^js instance "change" on-change)
(dwt/dispose! instance)
(st/emit! (dwt/update-editor nil)))))
(mf/defc text-editor-html
"Text editor (HTML)"
{::mf/wrap [mf/memo]
::mf/props :obj}
[{:keys [shape]}]
(let [content (:content shape)
shape-id (dm/get-prop shape :id)
;; This is a reference to the dom element that
;; should contain the TextEditor.
editor-ref (mf/use-ref nil)
;; This reference is to the container
container-ref (mf/use-ref nil)
selection-ref (mf/use-ref nil)]
;; WARN: we explicitly do not pass content on effect dependency
;; array because we only need to initialize this once with initial
;; content
(mf/with-effect [shape-id]
(initialize-event-handlers shape-id
content
selection-ref
editor-ref
container-ref))
[:div
{:class (dm/str (cur/get-dynamic "text" (:rotation shape))
" "
(stl/css :text-editor-container))
:ref container-ref
:data-testid "text-editor-container"
:style {:width (:width shape)
:height (:height shape)}
;; We hide the editor when is blurred because otherwise the
;; selection won't let us see the underlying text. Use opacity
;; because display or visibility won't allow to recover focus
;; afterwards.
;; IMPORTANT! This is now done through DOM mutations (see
;; on-blur and on-focus) but I keep this for future references.
;; :opacity (when @blurred 0)}}
}
[:div
{:class (stl/css :text-editor-selection-imposter)
:ref selection-ref}]
[:div
{:class (dm/str
"mousetrap "
(stl/css-case
:text-editor-content true
:grow-type-fixed (= (:grow-type shape) :fixed)
:grow-type-auto-width (= (:grow-type shape) :auto-width)
:grow-type-auto-height (= (:grow-type shape) :auto-height)
:align-top (= (:vertical-align content "top") "top")
:align-center (= (:vertical-align content) "center")
:align-bottom (= (:vertical-align content) "bottom")))
:ref editor-ref
:data-testid "text-editor-content"
:data-x (dm/get-prop shape :x)
:data-y (dm/get-prop shape :y)
:content-editable true
:role "textbox"
:aria-multiline true
:aria-autocomplete "none"}]]))
(defn- shape->justify
[{:keys [content]}]
(case (d/nilv (:vertical-align content) "top")
"center" "center"
"top" "flex-start"
"bottom" "flex-end"
nil))
;;
;; Text Editor Wrapper
;; This is an SVG element that wraps the HTML editor.
;;
(mf/defc text-editor
"Text editor wrapper component"
{::mf/wrap [mf/memo]
::mf/props :obj
::mf/forward-ref true}
[{:keys [shape modifiers] :as props} _]
(let [shape-id (dm/get-prop shape :id)
modifiers (dm/get-in modifiers [shape-id :modifiers])
clip-id (dm/str "text-edition-clip" shape-id)
text-modifier-ref
(mf/use-memo (mf/deps (:id shape)) #(refs/workspace-text-modifier-by-id (:id shape)))
text-modifier
(mf/deref text-modifier-ref)
;; For Safari It's necesary to scale the editor with the zoom
;; level to fix a problem with foreignObjects not scaling
;; correctly with the viewbox
;;
;; NOTE: this teoretically breaks hooks rules, but in practice
;; it is imposible to really break it
maybe-zoom
(when (cf/check-browser? :safari-16)
(mf/deref refs/selected-zoom))
shape (cond-> shape
(some? text-modifier)
(dwt/apply-text-modifier text-modifier)
(some? modifiers)
(gsh/transform-shape modifiers))
bounds (gst/shape->rect shape)
x (mth/min (dm/get-prop bounds :x)
(dm/get-prop shape :x))
y (mth/min (dm/get-prop bounds :y)
(dm/get-prop shape :y))
width (mth/max (dm/get-prop bounds :width)
(dm/get-prop shape :width))
height (mth/max (dm/get-prop bounds :height)
(dm/get-prop shape :height))
style
(cond-> #js {:pointerEvents "all"}
(not (cf/check-browser? :safari))
(obj/merge!
#js {:transform (dm/fmt "translate(%px, %px)" (- (dm/get-prop shape :x) x) (- (dm/get-prop shape :y) y))})
(cf/check-browser? :safari-17)
(obj/merge!
#js {:height "100%"
:display "flex"
:flexDirection "column"
:justifyContent (shape->justify shape)})
(cf/check-browser? :safari-16)
(obj/merge!
#js {:position "fixed"
:left 0
:top (- (dm/get-prop shape :y) y)
:transform-origin "top left"
:transform (when (some? maybe-zoom)
(dm/fmt "scale(%)" maybe-zoom))}))]
[:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id)
:transform (dm/str (gsh/transform-matrix shape))}
[:defs
[:clipPath {:id clip-id}
[:rect {:x x :y y :width width :height height}]]]
[:foreignObject {:x x :y y :width width :height height}
[:div {:style style}
[:& text-editor-html {:shape shape
:key (dm/str shape-id)}]]]]))

View File

@@ -0,0 +1,84 @@
:global {
.selection-imposter-rect {
position: absolute;
background-color: var(--text-editor-selection-background-color);
}
}
.text-editor-container {
height: 100%;
position: relative;
}
.text-editor-selection-imposter {
position: relative;
}
.text-editor-content {
height: 100%;
font-family: sourcesanspro;
outline: none;
user-select: text;
white-space: pre-wrap;
overflow-wrap: break-word;
caret-color: black;
color: transparent;
[data-itype="paragraph"] {
line-height: inherit;
user-select: text;
margin: 0px;
font-size: 0px;
}
[data-itype="inline"] {
line-break: auto;
line-height: inherit;
overflow-wrap: initial;
caret-color: rgb(0, 0, 0);
}
[data-itype="root"] {
display: flex;
flex-direction: column;
height: 100%;
}
}
// Grow type
.grow-type-fixed,
.grow-type-auto-height {
[data-itype="inline"],
[data-itype="paragraph"] {
white-space: break-spaces;
}
}
.grow-type-auto-width {
[data-itype="inline"],
[data-itype="paragraph"] {
white-space: nowrap;
}
}
// Vertical align.
.align-top {
[data-itype="root"] {
justify-content: start;
}
}
.align-center {
[data-itype="root"] {
justify-content: center;
}
}
.align-bottom {
[data-itype="root"] {
justify-content: end;
}
}

View File

@@ -26,6 +26,7 @@
[app.util.object :as obj]
[app.util.text-editor :as ted]
[app.util.text-svg-position :as tsp]
[app.util.text.content :as content]
[promesa.core :as p]
[rumext.v2 :as mf]))
@@ -46,6 +47,12 @@
(dissoc :modifiers)))
shape))
(defn- update-shape-with-content
[shape content editor-content]
(cond-> shape
(and (some? shape) (some? editor-content))
(assoc :content (d/txt-merge content editor-content))))
(defn- update-with-editor-state
"Updates the shape with the current state in the editor"
[shape editor-state]
@@ -56,9 +63,15 @@
(ted/get-editor-current-content)
(ted/export-content)))]
(cond-> shape
(and (some? shape) (some? editor-content))
(assoc :content (d/txt-merge content editor-content)))))
(update-shape-with-content shape content editor-content)))
(defn- update-with-editor-v2
"Updates the shape with the current editor"
[shape editor]
(let [content (:content shape)
editor-content (content/dom->cljs (.-root editor))]
(update-shape-with-content shape content editor-content)))
(defn- update-text-shape
[{:keys [grow-type id migrate] :as shape} node]
@@ -219,22 +232,28 @@
{::mf/wrap-props false
::mf/wrap [mf/memo]}
[props]
(let [shape (obj/get props "shape")
shape-id (:id shape)
workspace-editor-state (mf/deref refs/workspace-editor-state)
workspace-v2-editor-state (mf/deref refs/workspace-v2-editor-state)
workspace-editor (mf/deref refs/workspace-editor)
editor-state (get workspace-editor-state (:id shape))
editor-state (get workspace-editor-state shape-id)
v2-editor-state (get workspace-v2-editor-state shape-id)
text-modifier-ref
(mf/use-memo (mf/deps (:id shape)) #(refs/workspace-text-modifier-by-id (:id shape)))
(mf/use-memo (mf/deps shape-id) #(refs/workspace-text-modifier-by-id shape-id))
text-modifier
(mf/deref text-modifier-ref)
shape (cond-> shape
(some? editor-state)
(update-with-editor-state editor-state))
(update-with-editor-state editor-state)
(and (some? v2-editor-state) (some? workspace-editor))
(update-with-editor-v2 workspace-editor))
;; When we have a text with grow-type :auto-height or :auto-height we need to check the correct height
;; otherwise the center alignment will break

View File

@@ -32,6 +32,7 @@ $width-settings-bar-max: $s-500;
.layers-tab {
padding-top: $s-4;
overflow-x: hidden;
}
.left-header {

View File

@@ -11,7 +11,7 @@
height: 100%;
grid-auto-rows: max-content;
// TODO: ugly hack :( Fix this! we shouldn't be hardcoding this height
max-height: calc(100vh - $s-80);
height: calc(100vh - $s-92);
scrollbar-gutter: stable;
overflow-y: auto;
padding-top: $s-8;

View File

@@ -230,6 +230,10 @@
}
}
.layer-row:hover .element-actions.selected & {
opacity: $op-10;
}
.layer-row.highlight &,
.layer-row:hover & {
display: flex;

View File

@@ -138,6 +138,8 @@
first-selected-shape (first selected-shapes)
shape-parent-frame (cfh/get-frame objects (:frame-id first-selected-shape))
options-mode (mf/deref refs/options-mode-global)
on-change-tab
(fn [options-mode]
(let [options-mode (keyword options-mode)]
@@ -187,6 +189,7 @@
[:> tab-switcher* {:tabs tabs
:default-selected "info"
:on-change-tab on-change-tab
:selected (name options-mode)
:class (stl/css :options-tab-switcher)}]]))
;; TODO: this need optimizations, selected-objects and

View File

@@ -36,32 +36,37 @@
.constraints-bottom {
@include flexCenter;
grid-area: top;
.constraint-btn,
.constraint-btn-special,
.constraint-btn-rotated {
@include buttonStyle;
@include flexCenter;
width: 100%;
height: 100%;
.resalted-area {
width: $s-32;
height: $s-3;
border-radius: $br-8;
background-color: var(--button-constraint-background-color-rest);
padding: 0;
margin: 0;
}
&.active .resalted-area {
outline: $s-4 solid var(--button-constraint-border-color-hover);
background-color: var(--button-constraint-background-color-hover);
}
&:hover .resalted-area,
&:focus .resalted-area {
outline: $s-4 solid var(--button-constraint-border-color-hover);
background-color: var(--button-constraint-background-color-hover);
}
}
.constraint-btn,
.constraint-btn-special,
.constraint-btn-rotated {
@include buttonStyle;
@include flexCenter;
width: 100%;
height: 100%;
--resalted-area-background-color: var(--button-constraint-background-color-rest);
--resalted-area-border-color: none;
&.active {
--resalted-area-border-color: var(--button-constraint-border-color-hover);
--resalted-area-background-color: var(--button-constraint-background-color-hover);
}
&:hover,
&:focus-visible {
--resalted-area-border-color: var(--button-constraint-border-color-hover);
--resalted-area-background-color: var(--button-constraint-background-color-hover);
}
}
.resalted-area {
width: $s-32;
height: $s-3;
border-radius: $br-8;
background-color: var(--resalted-area-background-color);
outline: $s-4 solid var(--resalted-area-border-color);
padding: 0;
margin: 0;
}
.constraints-left {
grid-area: left;
.constraint-btn-rotated {
@@ -73,6 +78,7 @@
}
}
}
.constraints-center {
grid-area: center;
position: relative;
@@ -113,7 +119,7 @@
grid-area: bottom;
}
.contraints-selects {
.constraints-selects {
@include flexColumn;
}

Some files were not shown because too many files have changed in this diff Show More