mirror of
https://github.com/penpot/penpot.git
synced 2026-01-20 20:32:22 -05:00
Compare commits
41 Commits
renderer-p
...
2.3.0-RC2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd220e228e | ||
|
|
7b63aa4a4f | ||
|
|
33a07346dd | ||
|
|
abd77559ab | ||
|
|
28878caca9 | ||
|
|
74f3379b5d | ||
|
|
379770343a | ||
|
|
6327286328 | ||
|
|
3a2677a91a | ||
|
|
fcd232aa35 | ||
|
|
f194e2c1c6 | ||
|
|
ea6731e22b | ||
|
|
002b1679c3 | ||
|
|
45f3a67950 | ||
|
|
c6917bb0cf | ||
|
|
f777845d14 | ||
|
|
a1f5bcae80 | ||
|
|
3e11b4aa74 | ||
|
|
4f48236fee | ||
|
|
ffadf29ad7 | ||
|
|
352efcb610 | ||
|
|
334e83479f | ||
|
|
476eedbd2c | ||
|
|
ae7e28b71b | ||
|
|
be30174a49 | ||
|
|
8373654f80 | ||
|
|
471c636580 | ||
|
|
635c6efe42 | ||
|
|
d570048f78 | ||
|
|
dcc49dafd3 | ||
|
|
7398f7ce0d | ||
|
|
76479a2486 | ||
|
|
31f62dcc12 | ||
|
|
3d7df5b005 | ||
|
|
c16a116707 | ||
|
|
f7f06f59ce | ||
|
|
d1277afee6 | ||
|
|
a510d01136 | ||
|
|
0e651df65f | ||
|
|
758e0458bc | ||
|
|
e18b4666ba |
@@ -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"
|
||||
|
||||
26
CHANGES.md
26
CHANGES.md
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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})]
|
||||
|
||||
@@ -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}]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))))))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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}]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1 +1 @@
|
||||
v14.15.0
|
||||
v20.11.1
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
50
frontend/src/app/main/data/plugins.cljs
Normal file
50
frontend/src/app/main/data/plugins.cljs
Normal 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))))))
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)})))))))
|
||||
|
||||
@@ -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))))
|
||||
|
||||
|
||||
@@ -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 =))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}])])]]))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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") ")"]]]
|
||||
|
||||
|
||||
@@ -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})))))))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}]]]]]))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
268
frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs
Normal file
268
frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs
Normal 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)}]]]]))
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)))]
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")))]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 %)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
20
frontend/src/app/util/text/content.cljs
Normal file
20
frontend/src/app/util/text/content.cljs
Normal 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))
|
||||
83
frontend/src/app/util/text/content/from_dom.cljs
Normal file
83
frontend/src/app/util/text/content/from_dom.cljs
Normal 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)))
|
||||
198
frontend/src/app/util/text/content/styles.cljs
Normal file
198
frontend/src/app/util/text/content/styles.cljs
Normal 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)))
|
||||
124
frontend/src/app/util/text/content/to_dom.cljs
Normal file
124
frontend/src/app/util/text/content/to_dom.cljs
Normal 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])))))
|
||||
43
frontend/src/app/util/text/ui.cljs
Normal file
43
frontend/src/app/util/text/ui.cljs
Normal 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)))
|
||||
@@ -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")))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
2788
frontend/vendor/text_editor_v2.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user