Compare commits

...

41 Commits

Author SHA1 Message Date
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
82 changed files with 7098 additions and 2770 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,40 @@
### :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:.
### :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 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)
## 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

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

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

@@ -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,41 @@
::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}]
(db/tx-run! cfg create-comment-thread params))))
(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 +372,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 +396,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 +440,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

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

@@ -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,50 @@
: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"}
[: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 +844,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 +853,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 +880,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 +932,84 @@
[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} profile team role emails]
(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]
{:email email
:team team
:profile profile
:role role}))
(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 role] :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)))
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)}})))))
(let [team (db/get-by-id cfg :team team-id)
invitations (db/tx-run! cfg create-team-invitations profile team role emails)]
(with-meta {:total (count invitations)
:invitations invitations}
{::audit/props {:invitations (count invitations)}}))))
;; --- Mutation: Create Team & Invite Members
@@ -977,52 +1023,51 @@
(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)]
(->> emails
(map (fn [email]
(-> params
(assoc :team team)
(assoc :profile profile)
(assoc :email email)
(assoc :role role))))
(run! (partial create-invitation cfg))))
(vary-meta team assoc ::audit/props {:invitations (count emails)})))))
(vary-meta team assoc ::audit/props {:invitations (count emails)})))
;; --- Query: get-team-invitation-token
@@ -1215,11 +1260,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

@@ -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 +1 @@
v14.15.0
v20.11.1

View File

@@ -23,7 +23,6 @@
"build:storybook:cljs": "clojure -M:dev:shadow-cljs release 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

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

@@ -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,50 @@
;; 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.plugins.register :as pr]
[app.util.globals :as ug]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defn open-plugin!
[{:keys [plugin-id name description host code icon permissions]}]
(try
(.ɵloadPlugin
^js ug/global
#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))))
(defn close-plugin!
[{:keys [plugin-id]}]
(try
(.ɵunloadPlugin ^js ug/global plugin-id)
(catch :default e
(.error js/console "Error" e))))
(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! (pr/get-plugin pid))
(rx/of #(dissoc % ::open-plugin))))))

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

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

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

@@ -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,17 @@
[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.http :as http]
[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 +154,70 @@
(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
(->> (http/send! {:method :get
:uri plugin-url
:omit-default-headers true
:response-type :json})
(rx/map :body)
(rx/subs!
(fn [body]
(if-let [plugin (preg/parse-manifest plugin-url body)]
(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 +225,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 +239,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 +259,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 +289,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

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

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

@@ -13,6 +13,7 @@
[app.main.ui.context :as muc]
[app.main.ui.hooks :as h]
[app.main.ui.shapes.attrs :as attrs]
[app.main.ui.shapes.embed :as embed]
[app.main.ui.shapes.export :as ed]
[app.main.ui.shapes.fills :as fills]
[app.main.ui.shapes.filters :as filters]
@@ -78,6 +79,7 @@
(obj/set! "mixBlendMode" (d/name blend-mode))))
include-metadata? (mf/use-ctx ed/include-metadata-ctx)
embed? (mf/use-ctx embed/context)
shape-without-blur (dissoc shape :blur)
shape-without-shadows (assoc shape :shadow [])
@@ -96,30 +98,39 @@
(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))
;; FIXME: this can set the data-testid attribute with
;; invalid values (unescaped) what can cause unexpected
;; problems; we don't set this attribute when embed is
;; enabled for fix the output on the svg exportation process
(not embed?)
(obj/set! "data-testid" (:name shape))
(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

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

@@ -42,11 +42,10 @@
[:section {:class (stl/css :exception-layout)}
[:button
{:class (stl/css :exception-header)
:on-click rt/nav-root}
:on-click on-nav-root}
i/logo-icon
(when profile-id
(str "< "
(tr "not-found.no-permission.go-dashboard")))]
(str "< " (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

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

@@ -12,6 +12,7 @@
[app.config :as cf]
[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]]
@@ -59,22 +60,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}
@@ -144,7 +129,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 +141,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)))))]
@@ -215,7 +201,7 @@
(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,25 +210,26 @@
(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)}
@@ -277,7 +264,27 @@
[:div {:class (stl/css :permissions-list-entry)}
i/oauth-3
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.library-read")]])]
(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")]])]
[:div {:class (stl/css :permissions-disclaimer)}
(tr "workspace.plugins.permissions.disclaimer")]]
@@ -295,3 +302,55 @@
: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)
(dm/str 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,11 @@
}
}
.modal-message {
font-size: $fs-14;
color: var(--color-foreground-secondary);
}
.primary-button {
@extend .button-primary;
@include headlineSmallTypography;
@@ -253,8 +266,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-quaternary);
border-radius: $br-4;
}

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

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

@@ -24,6 +24,7 @@
[app.main.ui.workspace.sidebar.options.menus.typography :refer [typography-entry text-options]]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.text.ui :as txu]
[app.util.timers :as ts]
[rumext.v2 :as mf]))
@@ -278,7 +279,7 @@
100
(fn []
(when (not= "INPUT" (-> (dom/get-active) (dom/get-tag-name)))
(let [node (dom/get-element-by-class "public-DraftEditor-content")]
(let [node (txu/get-text-editor-content)]
(dom/focus! node))))))}]
[:div {:class (stl/css :element-set)}

View File

@@ -15,6 +15,7 @@
[app.main.data.fonts :as fts]
[app.main.data.shortcuts :as dsc]
[app.main.data.workspace :as dw]
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.refs :as refs]
[app.main.store :as st]
@@ -399,10 +400,11 @@
{::mf/wrap-props false}
[{:keys [values on-change on-blur]}]
(let [text-transform (or (:text-transform values) "none")
unset-value (if (features/active-feature? @st/state "text-editor/v2") "none" "unset")
handle-change
(fn [type]
(if (= text-transform type)
(on-change {:text-transform "unset"})
(on-change {:text-transform unset-value})
(on-change {:text-transform type}))
(when (some? on-blur) (on-blur)))]

View File

@@ -10,7 +10,9 @@
[app.common.text :as txt]
[app.common.types.shape.layout :as ctl]
[app.main.data.workspace.texts :as dwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.hooks :as hooks]
[app.main.ui.workspace.sidebar.options.menus.blur :refer [blur-menu]]
[app.main.ui.workspace.sidebar.options.menus.color-selection :refer [color-selection-menu]]
@@ -47,15 +49,22 @@
parents-by-ids-ref (mf/use-memo (mf/deps ids) #(refs/parents-by-ids ids))
parents (mf/deref parents-by-ids-ref)
state-map (mf/deref refs/workspace-editor-state)
state-map (if (features/active-feature? @st/state "text-editor/v2")
(mf/deref refs/workspace-v2-editor-state)
(mf/deref refs/workspace-editor-state))
shared-libs (mf/deref refs/workspace-libraries)
editor-state (get state-map (:id shape))
editor-state (when (not (features/active-feature? @st/state "text-editor/v2"))
(get state-map (:id shape)))
layer-values (select-keys shape layer-attrs)
editor-instance (when (features/active-feature? @st/state "text-editor/v2")
(mf/deref refs/workspace-editor))
fill-values (-> (dwt/current-text-values
{:editor-state editor-state
:editor-instance editor-instance
:shape shape
:attrs (conj txt/text-fill-attrs :fills)})
(d/update-in-when [:fill-color-gradient :type] keyword))
@@ -75,10 +84,12 @@
:attrs txt/root-attrs})
(dwt/current-paragraph-values
{:editor-state editor-state
:editor-instance editor-instance
:shape shape
:attrs txt/paragraph-attrs})
(dwt/current-text-values
{:editor-state editor-state
:editor-instance editor-instance
:shape shape
:attrs txt/text-node-attrs}))
layout-item-values (select-keys shape layout-item-attrs)]

View File

@@ -15,15 +15,18 @@
[app.common.types.shape-tree :as ctt]
[app.common.types.shape.layout :as ctl]
[app.main.data.workspace.modifiers :as dwm]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.context :as ctx]
[app.main.ui.flex-controls :as mfc]
[app.main.ui.hooks :as ui-hooks]
[app.main.ui.measurements :as msr]
[app.main.ui.shapes.export :as use]
[app.main.ui.workspace.shapes :as shapes]
[app.main.ui.workspace.shapes.text.editor :as editor]
[app.main.ui.workspace.shapes.text.editor :as editor-v1]
[app.main.ui.workspace.shapes.text.text-edition-outline :refer [text-edition-outline]]
[app.main.ui.workspace.shapes.text.v2-editor :as editor-v2]
[app.main.ui.workspace.shapes.text.viewport-texts-html :as stvh]
[app.main.ui.workspace.viewport.actions :as actions]
[app.main.ui.workspace.viewport.comments :as comments]
@@ -383,8 +386,11 @@
[:g {:style {:pointer-events (if disable-events? "none" "auto")}}
(when show-text-editor?
[:& editor/text-editor-svg {:shape editing-shape
:modifiers modifiers}])
(if (features/active-feature? @st/state "text-editor/v2")
[:& editor-v2/text-editor {:shape editing-shape
:modifiers modifiers}]
[:& editor-v1/text-editor-svg {:shape editing-shape
:modifiers modifiers}]))
(when show-frame-outline?
(let [outlined-frame-id

View File

@@ -30,6 +30,7 @@
[app.util.mouse :as mse]
[app.util.object :as obj]
[app.util.rxops :refer [throttle-fn]]
[app.util.text.ui :as txu]
[app.util.timers :as ts]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
@@ -49,7 +50,7 @@
;; We need to handle editor related stuff here because
;; handling on editor dom node does not works properly.
(let [target (dom/get-target bevent)
editor (.closest ^js target ".public-DraftEditor-content")]
editor (txu/closest-text-editor-content target)]
;; Capture mouse pointer to detect the movements even if cursor
;; leaves the viewport or the browser itself
;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture
@@ -319,7 +320,7 @@
mod? (kbd/mod? event)
target (dom/get-target event)
editing? (or (some? (.closest ^js target ".public-DraftEditor-content"))
editing? (or (txu/some-text-editor-content? target)
(= "rich-text" (obj/get target "className"))
(= "INPUT" (obj/get target "tagName"))
(= "TEXTAREA" (obj/get target "tagName")))]
@@ -338,7 +339,7 @@
mod? (kbd/mod? event)
target (dom/get-target event)
editing? (or (some? (.closest ^js target ".public-DraftEditor-content"))
editing? (or (txu/some-text-editor-content? target)
(= "rich-text" (obj/get target "className"))
(= "INPUT" (obj/get target "tagName"))
(= "TEXTAREA" (obj/get target "tagName")))]

View File

@@ -218,7 +218,7 @@
(rx/filter some?))))))
over-shapes-stream
(mf/with-memo [move-stream mod-str]
(mf/with-memo [query-point move-stream mod-str]
(->> (rx/merge
;; This stream works to "refresh" the outlines when the control is pressed
;; but the mouse has not been moved from its position.

View File

@@ -76,18 +76,15 @@
(getFile
[_]
(file/file-proxy $plugin (:current-file-id @st/state)))
(when (some? (:current-file-id @st/state))
(file/file-proxy $plugin (:current-file-id @st/state))))
(getPage
[_]
(let [file-id (:current-file-id @st/state)
page-id (:current-page-id @st/state)]
(page/page-proxy $plugin file-id page-id)))
(getSelected
[_]
(let [selection (get-in @st/state [:workspace-local :selected])]
(apply array (map str selection))))
(when (and (some? file-id) (some? page-id))
(page/page-proxy $plugin file-id page-id))))
(getSelectedShapes
[_]
@@ -143,7 +140,9 @@
(getRoot
[_]
(shape/shape-proxy $plugin uuid/zero))
(when (and (some? (:current-file-id @st/state))
(some? (:current-page-id @st/state)))
(shape/shape-proxy $plugin uuid/zero)))
(getTheme
[_]
@@ -444,6 +443,7 @@
{:name "root" :get #(.getRoot ^js %)}
{:name "currentFile" :get #(.getFile ^js %)}
{:name "currentPage" :get #(.getPage ^js %)}
{:name "theme" :get #(.getTheme ^js %)}
{:name "selection"
:get #(.getSelectedShapes ^js %)

View File

@@ -27,9 +27,16 @@
(remove [_]
(p/create
(fn [resolve reject]
(->> (rp/cmd! :delete-comment {:id $id})
(rx/tap #(st/emit! (dc/retrieve-comment-threads $file)))
(rx/subs! #(resolve) reject))))))
(cond
(not (r/check-permission $plugin "comment:write"))
(do
(u/display-not-valid :remove "Plugin doesn't have 'comment:write' permission")
(reject "Plugin doesn't have 'comment:write' permission"))
:else
(->> (rp/cmd! :delete-comment {:id $id})
(rx/tap #(st/emit! (dc/retrieve-comment-threads $file)))
(rx/subs! #(resolve) reject)))))))
(defn comment-proxy? [p]
(instance? CommentProxy p))
@@ -60,8 +67,8 @@
(not= (:id profile) (:owner-id data))
(u/display-not-valid :content "Cannot change content from another user's comments")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :content "Plugin doesn't have 'content:write' permission")
(not (r/check-permission plugin-id "comment:write"))
(u/display-not-valid :content "Plugin doesn't have 'comment:write' permission")
:else
(->> (rp/cmd! :update-comment {:id (:id data) :content content})
@@ -74,22 +81,29 @@
[_]
(p/create
(fn [resolve reject]
(->> (rp/cmd! :get-comments {:thread-id $id})
(rx/subs!
(fn [comments]
(resolve
(format/format-array
#(comment-proxy $plugin $file $page $id $users %) comments)))
reject)))))
(cond
(not (r/check-permission $plugin "comment:read"))
(do
(u/display-not-valid :findComments "Plugin doesn't have 'comment:read' permission")
(reject "Plugin doesn't have 'comment:read' permission"))
:else
(->> (rp/cmd! :get-comments {:thread-id $id})
(rx/subs!
(fn [comments]
(resolve
(format/format-array
#(comment-proxy $plugin $file $page $id $users %) comments)))
reject))))))
(reply
[_ content]
(cond
(not (r/check-permission $plugin "content:write"))
(u/display-not-valid :content "Plugin doesn't have 'content:write' permission")
(not (r/check-permission $plugin "comment:write"))
(u/display-not-valid :reply "Plugin doesn't have 'comment:write' permission")
(or (not (string? content)) (empty? content))
(u/display-not-valid :content "Not valid")
(u/display-not-valid :reply "Not valid")
:else
(p/create
@@ -100,11 +114,11 @@
(remove [_]
(let [profile (:profile @st/state)]
(cond
(not (r/check-permission $plugin "content:write"))
(u/display-not-valid :removeCommentThread "Plugin doesn't have 'content:write' permission")
(not (r/check-permission $plugin "comment:write"))
(u/display-not-valid :remove "Plugin doesn't have 'comment:write' permission")
(not= (:id profile) owner)
(u/display-not-valid :content "Cannot change content from another user's comments")
(u/display-not-valid :remove "Cannot change content from another user's comments")
:else
(p/create
@@ -140,8 +154,8 @@
(or (not (us/safe-number? (:x position))) (not (us/safe-number? (:y position))))
(u/display-not-valid :position "Not valid point")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :content "Plugin doesn't have 'content:write' permission")
(not (r/check-permission plugin-id "comment:write"))
(u/display-not-valid :position "Plugin doesn't have 'comment:write' permission")
:else
(do (st/emit! (dwc/update-comment-thread-position @data* [(:x position) (:y position)]))
@@ -155,8 +169,8 @@
(not (boolean? is-resolved))
(u/display-not-valid :resolved "Not a boolean type")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :resolved "Plugin doesn't have 'content:write' permission")
(not (r/check-permission plugin-id "comment:write"))
(u/display-not-valid :resolved "Plugin doesn't have 'comment:write' permission")
:else
(do (st/emit! (dc/update-comment-thread (assoc @data* :is-resolved is-resolved)))

View File

@@ -258,7 +258,7 @@
(u/display-not-valid :removeRulerGuide "Guide not provided")
(not (r/check-permission $plugin "content:write"))
(u/display-not-valid :removeRulerGuide "Plugin doesn't have 'content:write' permission")
(u/display-not-valid :removeRulerGuide "Plugin doesn't have 'comment:write' permission")
:else
(let [guide (u/proxy->ruler-guide value)]
@@ -279,8 +279,8 @@
(and (some? board) (or (not (shape/shape-proxy? board)) (not (cfh/frame-shape? shape))))
(u/display-not-valid :addCommentThread "Board not valid")
(not (r/check-permission $plugin "content:write"))
(u/display-not-valid :addCommentThread "Plugin doesn't have 'content:write' permission")
(not (r/check-permission $plugin "comment:write"))
(u/display-not-valid :addCommentThread "Plugin doesn't have 'comment:write' permission")
:else
(let [position
@@ -311,7 +311,7 @@
(not (pc/comment-thread-proxy? thread))
(u/display-not-valid :removeCommentThread "Comment thread not valid")
(not (r/check-permission $plugin "content:write"))
(not (r/check-permission $plugin "comment:write"))
(u/display-not-valid :removeCommentThread "Plugin doesn't have 'content:write' permission")
:else
@@ -328,23 +328,30 @@
user-id (-> @st/state :profile :id)]
(p/create
(fn [resolve reject]
(->> (rx/zip (rp/cmd! :get-team-users {:file-id $file})
(rp/cmd! :get-comment-threads {:file-id $file}))
(rx/take 1)
(rx/subs!
(fn [[users comments]]
(let [users (d/index-by :id users)
comments
(cond->> comments
(not show-resolved)
(filter (comp not :is-resolved))
(cond
(not (r/check-permission $plugin "comment:read"))
(do
(u/display-not-valid :findCommentThreads "Plugin doesn't have 'comment:read' permission")
(reject "Plugin doesn't have 'comment:read' permission"))
only-yours
(filter #(contains? (:participants %) user-id)))]
(resolve
(format/format-array
#(pc/comment-thread-proxy $plugin $file $id users %) comments))))
reject)))))))
:else
(->> (rx/zip (rp/cmd! :get-team-users {:file-id $file})
(rp/cmd! :get-comment-threads {:file-id $file}))
(rx/take 1)
(rx/subs!
(fn [[users comments]]
(let [users (d/index-by :id users)
comments
(cond->> comments
(not show-resolved)
(filter (comp not :is-resolved))
only-yours
(filter #(contains? (:participants %) user-id)))]
(resolve
(format/format-array
#(pc/comment-thread-proxy $plugin $file $id users %) comments))))
reject))))))))
(crc/define-properties!
PageProxy

View File

@@ -5,7 +5,6 @@
;; Copyright (c) KALEIDOS INC
(ns app.plugins.register
"RPC for plugins runtime."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
@@ -26,6 +25,10 @@
(->> (:ids @registry)
(mapv #(dm/get-in @registry [:data %]))))
(defn get-plugin
[id]
(dm/get-in @registry [:data id]))
(defn parse-manifest
"Read the manifest.json defined by the plugins definition and transforms it into an
object that will be stored in the register."
@@ -42,7 +45,10 @@
(conj "content:read")
(contains? permissions "library:write")
(conj "content:write"))
(conj "content:write")
(contains? permissions "comment:write")
(conj "comment:read"))
origin (obj/get (js/URL. plugin-url) "origin")

View File

@@ -987,7 +987,7 @@
:get (fn [self]
(let [shape (u/proxy->shape self)
parent-id (:parent-id shape)]
(shape-proxy (obj/get self "$file") (obj/get self "$page") parent-id)))}
(shape-proxy plugin-id (obj/get self "$file") (obj/get self "$page") parent-id)))}
{:name "parentX"
:get (fn [self]
(let [shape (u/proxy->shape self)

View File

@@ -92,7 +92,6 @@
"</style>")]
(.insertAdjacentHTML ^js node "beforeend" style)))
(defn get-element-by-class
([classname]
(dom/getElementByClass classname))
@@ -632,6 +631,11 @@
(when (some? node)
(.setAttribute node attr value)))
(defn set-style!
[^js node ^string style value]
(when (some? node)
(.setProperty (.-style node) style value)))
(defn remove-attribute! [^js node ^string attr]
(when (some? node)
(.removeAttribute node attr)))

View File

@@ -0,0 +1,20 @@
;; 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.util.text.content
(:require
[app.util.text.content.from-dom :as fd]
[app.util.text.content.to-dom :as td]))
(defn dom->cljs
"Gets the editor content from a DOM structure"
[root]
(fd/create-root root))
(defn cljs->dom
"Sets the editor content from a CLJS structure"
[root]
(td/create-root root))

View File

@@ -0,0 +1,83 @@
;; 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.util.text.content.from-dom
(:require
[app.common.data :as d]
[app.common.text :as txt]
[app.util.text.content.styles :as styles]))
(defn is-text-node
[node]
(= (.-nodeType node) js/Node.TEXT_NODE))
(defn is-element
[node tag]
(and (= (.-nodeType node) js/Node.ELEMENT_NODE)
(= (.-nodeName node) (.toUpperCase tag))))
(defn is-line-break
[node]
(is-element node "br"))
(defn is-inline-child
[node]
(or (is-line-break node)
(is-text-node node)))
(defn get-inline-text
[element]
(when-not (is-inline-child (.-firstChild element))
(throw (js/TypeError. "Invalid inline child")))
(if (is-line-break (.-firstChild element))
""
(.-textContent element)))
(defn get-attrs-from-styles
[element attrs]
(reduce (fn [acc key]
(let [style (.-style element)]
(if (contains? styles/mapping key)
(let [style-name (styles/get-style-name-as-css-variable key)
[_ style-decode] (get styles/mapping key)
value (style-decode (.getPropertyValue style style-name))]
(assoc acc key value))
(let [style-name (styles/get-style-name key)]
(assoc acc key (styles/normalize-attr-value key (.getPropertyValue style style-name))))))) {} attrs))
(defn get-inline-styles
[element]
(get-attrs-from-styles element txt/text-node-attrs))
(defn get-paragraph-styles
[element]
(get-attrs-from-styles element (d/concat-set txt/paragraph-attrs txt/text-node-attrs)))
(defn get-root-styles
[element]
(get-attrs-from-styles element txt/root-attrs))
(defn create-inline
[element]
(d/merge {:text (get-inline-text element)
:key (.-id element)}
(get-inline-styles element)))
(defn create-paragraph
[element]
(d/merge {:type "paragraph"
:key (.-id element)
:children (mapv create-inline (.-children element))}
(get-paragraph-styles element)))
(defn create-root
[element]
(let [root-styles (get-root-styles element)]
(d/merge {:type "root",
:key (.-id element)
:children [{:type "paragraph-set"
:children (mapv create-paragraph (.-children element))}]}
root-styles)))

View File

@@ -0,0 +1,198 @@
;; 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.util.text.content.styles
(:require
[app.common.text :as txt]
[app.common.transit :as transit]
[cuerdas.core :as str]))
(defn encode
[value]
(transit/encode-str value))
(defn decode
[value]
(if (= value "")
nil
(transit/decode-str value)))
(def mapping
{:fills [encode decode]
:typography-ref-id [encode decode]
:typography-ref-file [encode decode]
:font-id [identity identity]
:font-variant-id [identity identity]
:vertical-align [identity identity]})
(defn normalize-style-value
"This function adds units to style values"
[k v]
(cond
(and (or (= k :font-size)
(= k :letter-spacing))
(not= (str/slice v -2) "px"))
(str v "px")
:else
v))
(defn normalize-attr-value
"This function strips units from attr values"
[k v]
(cond
(and (or (= k :font-size)
(= k :letter-spacing))
(= (str/slice v -2) "px"))
(str/slice v 0 -2)
:else
v))
(defn get-style-name-as-css-variable
[key]
(str/concat "--" (name key)))
(defn get-style-name
[key]
(cond
(= key :text-direction)
"direction"
:else
(name key)))
(defn get-style-keyword
[key]
(keyword (get-style-name-as-css-variable key)))
(defn get-attr-keyword-from-css-variable
[style-name]
(keyword (str/slice style-name 2)))
(defn get-attr-keyword
[style-name]
(cond
(= style-name "direction")
:text-direction
:else
(keyword style-name)))
(defn attr-needs-mapping?
[key]
(let [contained? (contains? mapping key)]
contained?))
(defn attr->style-key
[key]
(if (attr-needs-mapping? key)
(let [name (get-style-name-as-css-variable key)]
(keyword name))
(cond
(= key :text-direction)
(keyword "direction")
:else
key)))
(defn attr->style-value
([key value]
(attr->style-value key value false))
([key value normalize?]
(if (attr-needs-mapping? key)
(let [[encoder] (get mapping key)]
(if normalize?
(normalize-style-value key (encoder value))
(encoder value)))
(if normalize?
(normalize-style-value key value)
value))))
(defn attr->style
[[key value]]
[(attr->style-key key)
(attr->style-value key value)])
(defn attrs->styles
"Maps attrs to styles"
[styles]
(let [mapped-styles
(into {} (map attr->style styles))]
(clj->js mapped-styles)))
(defn style-needs-mapping?
[name]
(str/starts-with? name "--"))
(defn style->attr-key
[key]
(if (style-needs-mapping? key)
(keyword (str/slice key 2))
(keyword key)))
(defn style->attr-value
([name value]
(style->attr-value name value false))
([name value normalize?]
(if (style-needs-mapping? name)
(let [key (get-attr-keyword-from-css-variable name)
[_ decoder] (get mapping key)]
(if normalize?
(normalize-attr-value key (decoder value))
(decoder value)))
(let [key (get-attr-keyword name)]
(if normalize?
(normalize-attr-value key value)
value)))))
(defn style->attr
"Maps style to attr"
[[key value]]
[(style->attr-key key)
(style->attr-value key value)])
(defn styles->attrs
"Maps styles to attrs"
[styles]
(let [mapped-attrs
(into {} (map style->attr styles))]
mapped-attrs))
(defn get-style-defaults
"Returns a Javascript object compatible with the TextEditor default styles"
[style-defaults]
(clj->js
(reduce
(fn [acc [k v]]
(if (contains? mapping k)
(let [[style-encode] (get mapping k)
style-name (get-style-name-as-css-variable k)
style-value (normalize-style-value style-name (style-encode v))]
(assoc acc style-name style-value))
(let [style-name (get-style-name k)
style-value (normalize-style-value style-name v)]
(assoc acc style-name style-value)))) {} style-defaults)))
(defn get-styles-from-style-declaration
"Returns a ClojureScript object compatible with text nodes"
[style-declaration]
(reduce
(fn [acc k]
(if (contains? mapping k)
(let [style-name (get-style-name-as-css-variable k)
[_ style-decode] (get mapping k)
style-value (.getPropertyValue style-declaration style-name)]
(assoc acc k (style-decode style-value)))
(let [style-name (get-style-name k)
style-value (normalize-attr-value k (.getPropertyValue style-declaration style-name))]
(assoc acc k style-value)))) {} txt/text-style-attrs))
(defn get-styles-from-event
"Returns a ClojureScript object compatible with text nodes"
[e]
(let [style-declaration (.-detail e)]
(get-styles-from-style-declaration style-declaration)))

View File

@@ -0,0 +1,124 @@
;; 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.util.text.content.to-dom
(:require
[app.common.data :as d]
[app.common.text :as txt]
[app.util.dom :as dom]
[app.util.text.content.styles :as styles]))
(defn set-dataset
[element data]
(doseq [[data-name data-value] data]
(dom/set-data! element (name data-name) data-value)))
(defn set-styles
[element styles]
(doseq [[style-name style-value] styles]
(if (contains? styles/mapping style-name)
(let [[style-encode] (get styles/mapping style-name)
style-encoded-value (style-encode style-value)]
(dom/set-style! element (styles/get-style-name-as-css-variable style-name) style-encoded-value))
(dom/set-style! element (styles/get-style-name style-name) (styles/normalize-style-value style-name style-value)))))
(defn create-element
([tag]
(create-element tag nil nil))
([tag attrs]
(create-element tag attrs nil))
([tag attrs children]
(let [element (dom/create-element tag)]
;; set attributes to the element if necessary.
(doseq [[attr-name attr-value] attrs]
(case attr-name
:data (set-dataset element attr-value)
:style (set-styles element attr-value)
(dom/set-attribute! element (name attr-name) attr-value)))
;; add childs to the element if necessary.
(doseq [child children]
(dom/append-child! element child))
;; we need to return the DOM element
element)))
(defn get-styles-from-attrs
[node attrs]
(let [styles (reduce (fn [acc key] (assoc acc key (get node key))) {} attrs)
fills
(cond
;; DEPRECATED: still here for backward compatibility with
;; old penpot files that still has a single color.
(or (some? (:fill-color node))
(some? (:fill-opacity node))
(some? (:fill-color-gradient node)))
[(d/without-nils (select-keys node [:fill-color :fill-opacity :fill-color-gradient
:fill-color-ref-id :fill-color-ref-file]))]
(nil? (:fills node))
[{:fill-color "#000000" :fill-opacity 1}]
:else
(:fills node))]
(assoc styles :fills fills)))
(defn get-paragraph-styles
[paragraph]
(let [styles (get-styles-from-attrs paragraph (d/concat-set txt/paragraph-attrs txt/text-node-attrs))
;; If the text is not empty we must the paragraph font size to 0,
;; it affects to the height calculation the browser does
font-size (if (some #(not= "" (:text %)) (:children paragraph))
"0"
(:font-size styles (:font-size txt/default-text-attrs)))]
(cond-> styles
;; Every paragraph must have line-height to be correctly rendered
(nil? (:line-height styles)) (assoc :line-height (:line-height txt/default-text-attrs))
true (assoc :font-size font-size))))
(defn get-root-styles
[root]
(get-styles-from-attrs root txt/root-attrs))
(defn get-inline-styles
[inline paragraph]
(let [node (if (= "" (:text inline)) paragraph inline)
styles (get-styles-from-attrs node txt/text-node-attrs)]
(dissoc styles :line-height)))
(defn get-inline-children
[inline]
[(if (= "" (:text inline))
(dom/create-element "br")
(dom/create-text (:text inline)))])
(defn create-inline
[inline paragraph]
(create-element
"span"
{:id (:key inline)
:data {:itype "inline"}
:style (get-inline-styles inline paragraph)}
(get-inline-children inline)))
(defn create-paragraph
[paragraph]
(create-element
"div"
{:id (:key paragraph)
:data {:itype "paragraph"}
:style (get-paragraph-styles paragraph)}
(mapv #(create-inline % paragraph) (:children paragraph))))
(defn create-root
[root]
(let [root-styles (get-root-styles root)]
(create-element
"div"
{:id (:key root)
:data {:itype "root"}
:style root-styles}
(mapv create-paragraph (get-in root [:children 0 :children])))))

View File

@@ -0,0 +1,43 @@
;; 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.util.text.ui
(:require
[app.main.features :as features]
[app.main.store :as st]
[app.util.dom :as dom]))
(defn v1-closest-text-editor-content
[target]
(.closest ^js target ".public-DraftEditor-content"))
(defn v2-closest-text-editor-content
[target]
(.closest ^js target ".text-editor-content"))
(defn closest-text-editor-content
[target]
(if (features/active-feature? @st/state "text-editor/v2")
(v2-closest-text-editor-content target)
(v1-closest-text-editor-content target)))
(defn some-text-editor-content?
[target]
(some? (closest-text-editor-content target)))
(defn v1-get-text-editor-content
[]
(dom/get-element-by-class "public-DraftEditor-content"))
(defn v2-get-text-editor-content
[]
(dom/get-element-by-class "text-editor-content"))
(defn get-text-editor-content
[]
(if (features/active-feature? @st/state "text-editor/v2")
(v2-get-text-editor-content)
(v1-get-text-editor-content)))

View File

@@ -509,3 +509,8 @@
#_(.reload js/location)
(fn [cause]
(js/console.log "EE:" cause)))))))
(defn ^:export enable-text-v2
[]
(st/emit! (features/enable-feature "text-editor/v2")))

View File

@@ -923,6 +923,9 @@ msgstr "Are you sure?"
msgid "errors.auth-provider-not-allowed"
msgstr "Auth provider not allowed for this profile"
msgid "errors.maximum-invitations-by-request-reached"
msgstr "The maximum (%s) number of emails that can be invited in a single request has been reached"
#: src/app/main/ui/auth/login.cljs:61
msgid "errors.auth-provider-not-configured"
msgstr "Authentication provider not configured."
@@ -5580,7 +5583,7 @@ msgstr "Read and modify the content of files that users have access to."
#: src/app/main/ui/workspace/plugins.cljs:274
msgid "workspace.plugins.permissions.disclaimer"
msgstr "Note that this plugin has been created by an external party."
msgstr "Please note that this plugin is created by an external party, so ensure you trust it before granting access. Your data privacy and security are important to us. If you have any concerns, please contact support."
#: src/app/main/ui/workspace/plugins.cljs:271
msgid "workspace.plugins.permissions.library-read"
@@ -5590,14 +5593,35 @@ msgstr "Read your libraries and assets."
msgid "workspace.plugins.permissions.library-write"
msgstr "Read and modify your libraries and assets."
msgid "workspace.plugins.permissions.comment-read"
msgstr "Read your comments and replies."
msgid "workspace.plugins.permissions.comment-write"
msgstr "Read and modify your comments and reply in your name."
msgid "workspace.plugins.permissions.allow-download"
msgstr "Start file downloads."
#: src/app/main/ui/workspace/plugins.cljs:236
msgid "workspace.plugins.permissions.title"
msgstr "THIS PLUGIN WANTS ACCESS TO:"
msgstr "'%s' PLUGIN WANTS ACCESS TO:"
#: src/app/main/ui/workspace/plugins.cljs:258
msgid "workspace.plugins.permissions.user-read"
msgstr "Read the profile information of the current user."
msgid "workspace.plugins.try-out.title"
msgstr "'%s' PLUGIN IS INSTALLED FOR YOUR USER!"
msgid "workspace.plugins.try-out.message"
msgstr "Want to take a look? It will open in a new draft for your current team. (If not, you can always find it in the installed plugins of any file.)"
msgid "workspace.plugins.try-out.cancel"
msgstr "NOT NOW"
msgid "workspace.plugins.try-out.try"
msgstr "TRY PLUGIN"
#: src/app/main/ui/workspace/plugins.cljs:192
msgid "workspace.plugins.plugin-list-link"
msgstr "Plugins List"

View File

@@ -5567,7 +5567,7 @@ msgstr "Leer y modificar el contenido de sus archivos."
#: src/app/main/ui/workspace/plugins.cljs:274
msgid "workspace.plugins.permissions.disclaimer"
msgstr "Tenga en cuenta que esta extensión ha sido desarrollada por terceros."
msgstr "Ten en cuenta que las extensiones están desarrolladas por terceros, aseguraté que confías antes de conceder el permiso. Tu privacidad y seguridad es importante para nosotros. Si tienes cualquier duda, contacta con soporte."
#: src/app/main/ui/workspace/plugins.cljs:271
msgid "workspace.plugins.permissions.library-read"
@@ -5577,14 +5577,35 @@ msgstr "Leer la información de sus bibliotecas y recursos."
msgid "workspace.plugins.permissions.library-write"
msgstr "Leer y modificar la información de sus bibliotecas y recursos."
msgid "workspace.plugins.permissions.comment-read"
msgstr "Leer tus comentarios y respuestas."
msgid "workspace.plugins.permissions.comment-write"
msgstr "Leer y modificar tus comentarios y responder en tu nombre."
msgid "workspace.plugins.permissions.allow-download"
msgstr "Comenzar descargas de ficheros."
#: src/app/main/ui/workspace/plugins.cljs:236
msgid "workspace.plugins.permissions.title"
msgstr "LA EXTENSIÓN SOLICITA PERMISO PARA ACCEDER:"
msgstr "LA EXTENSIÓN '%s' SOLICITA PERMISO PARA ACCEDER:"
#: src/app/main/ui/workspace/plugins.cljs:258
msgid "workspace.plugins.permissions.user-read"
msgstr "Leer la información del usuario actual."
msgid "workspace.plugins.try-out.title"
msgstr "¡LA EXTENSIÓN '%s' HA SIDO INSTALADA PARA TU USUARIO!"
msgid "workspace.plugins.try-out.message"
msgstr "¿Quieres echar un vistazo?. Crearemos un nuevo borrador en tu equipo actual. (Si no, puedes encontrar los plugins instalados en cualquier fichero.)"
msgid "workspace.plugins.try-out.cancel"
msgstr "AHORA NO"
msgid "workspace.plugins.try-out.try"
msgstr "PROBAR PLUGIN"
#: src/app/main/ui/workspace/plugins.cljs:192
msgid "workspace.plugins.plugin-list-link"
msgstr "Lista de extensiones"
@@ -6096,3 +6117,6 @@ msgstr "Actualizar"
#, unused
msgid "workspace.viewport.click-to-close-path"
msgstr "Pulsar para cerrar la ruta"
msgid "errors.maximum-invitations-by-request-reached"
msgstr "Se ha alcanzado el número máximo (%s) de correos electrónicos que se pueden invitar en una sola solicitud"

2788
frontend/vendor/text_editor_v2.js vendored Normal file
View File

File diff suppressed because it is too large Load Diff