Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2024-02-02 10:15:40 +01:00
commit 06033ea955
86 changed files with 2124 additions and 1707 deletions

View File

@ -3,15 +3,26 @@
;; Optional: queue, ommited means Integer/MAX_VALUE ;; Optional: queue, ommited means Integer/MAX_VALUE
;; Optional: timeout, ommited means no timeout ;; Optional: timeout, ommited means no timeout
;; Note: queue and timeout are excluding ;; Note: queue and timeout are excluding
{:update-file/by-profile {:update-file/global {:permits 20}
:update-file/by-profile
{:permits 1 :queue 5} {:permits 1 :queue 5}
:update-file/global {:permits 20} :process-font/global {:permits 4}
:process-font/by-profile {:permits 1}
:derive-password/global {:permits 8}
:process-font/global {:permits 4}
:process-image/global {:permits 8} :process-image/global {:permits 8}
:process-image/by-profile {:permits 1}
:auth/global {:permits 8}
:root/global
{:permits 40}
:root/by-profile
{:permits 10}
:file-thumbnail-ops/global
{:permits 20}
:file-thumbnail-ops/by-profile :file-thumbnail-ops/by-profile
{:permits 2} {:permits 2}

View File

@ -129,6 +129,9 @@
:id (str team-id) :id (str team-id)
:fonts (count fonts)) :fonts (count fonts))
(when-let [photo-id (:photo-id team)]
(vswap! bfc/*state* update :storage-objects conj photo-id))
(vswap! bfc/*state* update :teams conj team-id) (vswap! bfc/*state* update :teams conj team-id)
(vswap! bfc/*state* bfc/collect-storage-objects fonts) (vswap! bfc/*state* bfc/collect-storage-objects fonts)

View File

@ -237,8 +237,7 @@
(jdbc/get-connection system-or-pool) (jdbc/get-connection system-or-pool)
(if (map? system-or-pool) (if (map? system-or-pool)
(open (::pool system-or-pool)) (open (::pool system-or-pool))
(ex/raise :type :internal (throw (IllegalArgumentException. "unable to resolve connection pool")))))
:code :unable-resolve-pool))))
(defn get-update-count (defn get-update-count
[result] [result]
@ -250,9 +249,7 @@
cfg-or-conn cfg-or-conn
(if (map? cfg-or-conn) (if (map? cfg-or-conn)
(get-connection (::conn cfg-or-conn)) (get-connection (::conn cfg-or-conn))
(ex/raise :type :internal (throw (IllegalArgumentException. "unable to resolve connection")))))
:code :unable-resolve-connection
:hint "expected conn or system map"))))
(defn connection-map? (defn connection-map?
"Check if the provided value is a map like data structure that "Check if the provided value is a map like data structure that
@ -260,15 +257,15 @@
[o] [o]
(and (map? o) (connection? (::conn o)))) (and (map? o) (connection? (::conn o))))
(defn- get-connectable (defn get-connectable
"Resolve to a connection or connection pool instance; if it is not
possible, raises an exception"
[o] [o]
(cond (cond
(connection? o) o (connection? o) o
(pool? o) o (pool? o) o
(map? o) (get-connectable (or (::conn o) (::pool o))) (map? o) (get-connectable (or (::conn o) (::pool o)))
:else (ex/raise :type :internal :else (throw (IllegalArgumentException. "unable to resolve connectable"))))
:code :unable-resolve-connectable
:hint "expected conn, pool or system")))
(def ^:private params-mapping (def ^:private params-mapping
{::return-keys? :return-keys {::return-keys? :return-keys

View File

@ -200,22 +200,15 @@
;; NOTE: this operation may cause primary key conflicts on inserts ;; NOTE: this operation may cause primary key conflicts on inserts
;; because of the timestamp precission (two concurrent requests), in ;; because of the timestamp precission (two concurrent requests), in
;; this case we just retry the operation. ;; this case we just retry the operation.
(let [cfg (-> cfg (let [tnow (dt/now)
(assoc ::rtry/when rtry/conflict-exception?)
(assoc ::rtry/max-retries 6)
(assoc ::rtry/label "persist-audit-log"))
params (-> params params (-> params
(assoc :created-at tnow)
(assoc :tracked-at tnow)
(update :props db/tjson) (update :props db/tjson)
(update :context db/tjson) (update :context db/tjson)
(update :ip-addr db/inet) (update :ip-addr db/inet)
(assoc :source "backend"))] (assoc :source "backend"))]
(db/insert! cfg :audit-log params)))
(rtry/invoke cfg (fn [cfg]
(let [tnow (dt/now)
params (-> params
(assoc :created-at tnow)
(assoc :tracked-at tnow))]
(db/insert! cfg :audit-log params))))))
(when (and (contains? cf/flags :webhooks) (when (and (contains? cf/flags :webhooks)
(::webhooks/event? event)) (::webhooks/event? event))
@ -246,9 +239,13 @@
"Submit audit event to the collector." "Submit audit event to the collector."
[cfg params] [cfg params]
(try (try
(let [event (d/without-nils params)] (let [event (d/without-nils params)
cfg (-> cfg
(assoc ::rtry/when rtry/conflict-exception?)
(assoc ::rtry/max-retries 6)
(assoc ::rtry/label "persist-audit-log"))]
(us/verify! ::event event) (us/verify! ::event event)
(db/tx-run! cfg handle-event! event)) (rtry/invoke! cfg db/tx-run! handle-event! event))
(catch Throwable cause (catch Throwable cause
(l/error :hint "unexpected error processing event" :cause cause)))) (l/error :hint "unexpected error processing event" :cause cause))))

View File

@ -322,9 +322,7 @@
::rpc/climit (ig/ref ::rpc/climit) ::rpc/climit (ig/ref ::rpc/climit)
::rpc/rlimit (ig/ref ::rpc/rlimit) ::rpc/rlimit (ig/ref ::rpc/rlimit)
::setup/templates (ig/ref ::setup/templates) ::setup/templates (ig/ref ::setup/templates)
::props (ig/ref ::setup/props) ::props (ig/ref ::setup/props)}
:pool (ig/ref ::db/pool)}
:app.rpc.doc/routes :app.rpc.doc/routes
{:methods (ig/ref :app.rpc/methods)} {:methods (ig/ref :app.rpc/methods)}

View File

@ -139,15 +139,10 @@
(f cfg (us/conform spec params))) (f cfg (us/conform spec params)))
f))) f)))
;; TODO: integrate with sm/define
(defn- wrap-params-validation (defn- wrap-params-validation
[_ f mdata] [_ f mdata]
(if-let [schema (::sm/params mdata)] (if-let [schema (::sm/params mdata)]
(let [schema (if (sm/lazy-schema? schema) (let [validate (sm/validator schema)
schema
(sm/define schema))
validate (sm/validator schema)
explain (sm/explainer schema) explain (sm/explainer schema)
decode (sm/decoder schema)] decode (sm/decoder schema)]
(fn [cfg params] (fn [cfg params]
@ -245,8 +240,7 @@
::mtx/metrics ::mtx/metrics
::main/props] ::main/props]
:opt [::climit :opt [::climit
::rlimit] ::rlimit]))
:req-un [::db/pool]))
(defmethod ig/init-key ::methods (defmethod ig/init-key ::methods
[_ cfg] [_ cfg]

View File

@ -21,26 +21,31 @@
[app.worker :as-alias wrk] [app.worker :as-alias wrk]
[clojure.edn :as edn] [clojure.edn :as edn]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[cuerdas.core :as str]
[datoteka.fs :as fs] [datoteka.fs :as fs]
[integrant.core :as ig] [integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px] [promesa.exec :as px]
[promesa.exec.bulkhead :as pbh]) [promesa.exec.bulkhead :as pbh])
(:import (:import
clojure.lang.ExceptionInfo)) clojure.lang.ExceptionInfo
java.util.concurrent.atomic.AtomicLong))
(set! *warn-on-reflection* true) (set! *warn-on-reflection* true)
(defn- id->str (defn- id->str
[id] ([id]
(-> (str id) (-> (str id)
(subs 1))) (subs 1)))
([id key]
(if key
(str (-> (str id) (subs 1)) "/" key)
(id->str id))))
(defn- create-cache (defn- create-cache
[{:keys [::wrk/executor]}] [{:keys [::wrk/executor]}]
(letfn [(on-remove [key _ cause] (letfn [(on-remove [key _ cause]
(let [[id skey] key] (let [[id skey] key]
(l/dbg :hint "destroy limiter" :id (id->str id) :key skey :reason (str cause))))] (l/dbg :hint "disposed" :id (id->str id skey) :reason (str cause))))]
(cache/create :executor executor (cache/create :executor executor
:on-remove on-remove :on-remove on-remove
:keepalive "5m"))) :keepalive "5m")))
@ -81,132 +86,179 @@
(defn- create-limiter (defn- create-limiter
[config [id skey]] [config [id skey]]
(l/dbg :hint "create limiter" :id (id->str id) :key skey) (l/dbg :hint "created" :id (id->str id skey))
(pbh/create :permits (or (:permits config) (:concurrency config)) (pbh/create :permits (or (:permits config) (:concurrency config))
:queue (or (:queue config) (:queue-size config)) :queue (or (:queue config) (:queue-size config))
:timeout (:timeout config) :timeout (:timeout config)
:type :semaphore)) :type :semaphore))
(defn- invoke! (defmacro ^:private measure-and-log!
[config cache metrics id key f] [metrics mlabels stats id action limit-id limit-label profile-id elapsed]
(if-let [limiter (cache/get cache [id key] (partial create-limiter config))] `(let [mpermits# (:max-permits ~stats)
(let [tpoint (dt/tpoint) mqueue# (:max-queue ~stats)
labels (into-array String [(id->str id)]) permits# (:permits ~stats)
wrapped (fn [] queue# (:queue ~stats)
(let [elapsed (tpoint) queue# (- queue# mpermits#)
stats (pbh/get-stats limiter)] queue# (if (neg? queue#) 0 queue#)
(l/trc :hint "acquired" level# (if (pos? queue#) :warn :trace)]
:id (id->str id)
:key key
:permits (:permits stats)
:queue (:queue stats)
:max-permits (:max-permits stats)
:max-queue (:max-queue stats)
:elapsed (dt/format-duration elapsed))
(mtx/run! metrics (mtx/run! ~metrics
:id :rpc-climit-timing :id :rpc-climit-queue
:val (inst-ms elapsed) :val queue#
:labels labels) :labels ~mlabels)
(try
(f)
(finally
(let [elapsed (tpoint)]
(l/trc :hint "finished"
:id (id->str id)
:key key
:permits (:permits stats)
:queue (:queue stats)
:max-permits (:max-permits stats)
:max-queue (:max-queue stats)
:elapsed (dt/format-duration elapsed)))))))
measure!
(fn [stats]
(mtx/run! metrics
:id :rpc-climit-queue
:val (:queue stats)
:labels labels)
(mtx/run! metrics
:id :rpc-climit-permits
:val (:permits stats)
:labels labels))]
(try (mtx/run! ~metrics
(let [stats (pbh/get-stats limiter)] :id :rpc-climit-permits
(measure! stats) :val permits#
(l/trc :hint "enqueued" :labels ~mlabels)
:id (id->str id)
:key key (l/log level#
:permits (:permits stats) :hint ~action
:queue (:queue stats) :req ~id
:max-permits (:max-permits stats) :id ~limit-id
:max-queue (:max-queue stats)) :label ~limit-label
(px/invoke! limiter wrapped)) :profile-id (str ~profile-id)
(catch ExceptionInfo cause :permits permits#
(let [{:keys [type code]} (ex-data cause)] :queue queue#
(if (= :bulkhead-error type) :max-permits mpermits#
:max-queue mqueue#
~@(if (some? elapsed)
[:elapsed `(dt/format-duration ~elapsed)]
[]))))
(def ^:private idseq (AtomicLong. 0))
(defn- invoke
[limiter metrics limit-id limit-key limit-label profile-id f params]
(let [tpoint (dt/tpoint)
limit-id (id->str limit-id limit-key)
mlabels (into-array String [limit-id])
stats (pbh/get-stats limiter)
id (.incrementAndGet ^AtomicLong idseq)]
(try
(measure-and-log! metrics mlabels stats id "enqueued" limit-id limit-label profile-id nil)
(px/invoke! limiter (fn []
(let [elapsed (tpoint)
stats (pbh/get-stats limiter)]
(measure-and-log! metrics mlabels stats id "acquired" limit-id limit-label profile-id elapsed)
(mtx/run! metrics
:id :rpc-climit-timing
:val (inst-ms elapsed)
:labels mlabels)
(apply f params))))
(catch ExceptionInfo cause
(let [{:keys [type code]} (ex-data cause)]
(if (= :bulkhead-error type)
(let [elapsed (tpoint)]
(measure-and-log! metrics mlabels stats id "reject" limit-id limit-label profile-id elapsed)
(ex/raise :type :concurrency-limit (ex/raise :type :concurrency-limit
:code code :code code
:hint "concurrency limit reached") :hint "concurrency limit reached"
(throw cause)))) :cause cause))
(throw cause))))
(finally (finally
(measure! (pbh/get-stats limiter))))) (let [elapsed (tpoint)
stats (pbh/get-stats limiter)]
(do (measure-and-log! metrics mlabels stats id "finished" limit-id limit-label profile-id elapsed))))))
(l/wrn :hint "no limiter found" :id (id->str id))
(f))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MIDDLEWARE ;; MIDDLEWARE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def noop-fn (constantly nil)) (def ^:private noop-fn (constantly nil))
(def ^:private global-limits
[[:root/global noop-fn]
[:root/by-profile ::rpc/profile-id]])
(defn- get-limits
[cfg]
(when-let [ref (get cfg ::id)]
(cond
(keyword? ref)
[[ref]]
(and (vector? ref)
(keyword (first ref)))
[ref]
(and (vector? ref)
(vector? (first ref)))
(rseq ref)
:else
(throw (IllegalArgumentException. "unable to normalize limit")))))
(defn wrap (defn wrap
[{:keys [::rpc/climit ::mtx/metrics]} f {:keys [::id ::key-fn] :or {key-fn noop-fn} :as mdata}] [{:keys [::rpc/climit ::mtx/metrics]} handler mdata]
(if (and (some? climit) (some? id)) (let [cache (::cache climit)
(let [cache (::cache climit) config (::config climit)
config (::config climit)] label (::sv/name mdata)]
(if-let [config (get config id)]
(do
(l/dbg :hint "instrumenting method"
:limit (id->str id)
:service-name (::sv/name mdata)
:timeout (:timeout config)
:permits (:permits config)
:queue (:queue config)
:keyed? (not= key-fn noop-fn))
(fn [cfg params] (reduce (fn [handler [limit-id key-fn]]
(invoke! config cache metrics id (key-fn params) (partial f cfg params)))) (if-let [config (get config limit-id)]
(let [key-fn (or key-fn noop-fn)]
(l/dbg :hint "instrumenting method"
:method label
:limit (id->str limit-id)
:timeout (:timeout config)
:permits (:permits config)
:queue (:queue config)
:keyed (not= key-fn noop-fn))
(do
(l/wrn :hint "no config found for specified queue" :id (id->str id))
f)))
f)) (if (and (= key-fn ::rpc/profile-id)
(false? (::rpc/auth mdata true)))
;; We don't enforce by-profile limit on methods that does
;; not require authentication
handler
(fn [cfg params]
(let [limit-key (key-fn params)
cache-key [limit-id limit-key]
limiter (cache/get cache cache-key (partial create-limiter config))
profile-id (if (= key-fn ::rpc/profile-id)
limit-key
(get params ::rpc/profile-id))]
(invoke limiter metrics limit-id limit-key label profile-id handler [cfg params])))))
(do
(l/wrn :hint "no config found for specified queue" :id (id->str limit-id))
handler)))
handler
(concat global-limits (get-limits mdata)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUBLIC API ;; PUBLIC API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn configure (defn- build-exec-chain
[{:keys [::rpc/climit]} id] [{:keys [::label ::profile-id ::rpc/climit ::mtx/metrics] :as cfg} f]
(us/assert! ::rpc/climit climit) (let [config (get climit ::config)
(assoc climit ::id id)) cache (get climit ::cache)]
(defn run! (reduce (fn [handler [limit-id limit-key :as ckey]]
(let [config (get config limit-id)]
(when-not config
(throw (IllegalArgumentException.
(str/ffmt "config not found for: %" limit-id))))
(fn [& params]
(let [limiter (cache/get cache ckey (partial create-limiter config))]
(invoke limiter metrics limit-id limit-key label profile-id handler params)))))
f
(get-limits cfg))))
(defn invoke!
"Run a function in context of climit. "Run a function in context of climit.
Intended to be used in virtual threads." Intended to be used in virtual threads."
([{:keys [::id ::cache ::config ::mtx/metrics]} f] [{:keys [::executor] :as cfg} f & params]
(if-let [config (get config id)] (let [f (if (some? executor)
(invoke! config cache metrics id nil f) (fn [& params] (px/await! (px/submit! executor (fn [] (apply f params)))))
(f))) f)
f (build-exec-chain cfg f)]
([{:keys [::id ::cache ::config ::mtx/metrics]} f executor] (apply f params)))
(let [f #(p/await! (px/submit! executor f))]
(if-let [config (get config id)]
(invoke! config cache metrics id nil f)
(f)))))

View File

@ -21,6 +21,7 @@
[app.loggers.audit :as audit] [app.loggers.audit :as audit]
[app.main :as-alias main] [app.main :as-alias main]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.profile :as profile] [app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams] [app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
@ -39,7 +40,7 @@
;; ---- COMMAND: login with password ;; ---- COMMAND: login with password
(defn login-with-password (defn login-with-password
[{:keys [::db/pool] :as cfg} {:keys [email password] :as params}] [cfg {:keys [email password] :as params}]
(when-not (or (contains? cf/flags :login) (when-not (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password)) (contains? cf/flags :login-with-password))
@ -47,7 +48,7 @@
:code :login-disabled :code :login-disabled
:hint "login is disabled in this instance")) :hint "login is disabled in this instance"))
(letfn [(check-password [conn profile password] (letfn [(check-password [cfg profile password]
(if (= (:password profile) "!") (if (= (:password profile) "!")
(ex/raise :type :validation (ex/raise :type :validation
:code :account-without-password :code :account-without-password
@ -57,10 +58,10 @@
(l/trc :hint "updating profile password" (l/trc :hint "updating profile password"
:id (str (:id profile)) :id (str (:id profile))
:email (:email profile)) :email (:email profile))
(profile/update-profile-password! conn (assoc profile :password password))) (profile/update-profile-password! cfg (assoc profile :password password)))
(:valid result)))) (:valid result))))
(validate-profile [conn profile] (validate-profile [cfg profile]
(when-not profile (when-not profile
(ex/raise :type :validation (ex/raise :type :validation
:code :wrong-credentials)) :code :wrong-credentials))
@ -70,7 +71,7 @@
(when (:is-blocked profile) (when (:is-blocked profile)
(ex/raise :type :restriction (ex/raise :type :restriction
:code :profile-blocked)) :code :profile-blocked))
(when-not (check-password conn profile password) (when-not (check-password cfg profile password)
(ex/raise :type :validation (ex/raise :type :validation
:code :wrong-credentials)) :code :wrong-credentials))
(when-let [deleted-at (:deleted-at profile)] (when-let [deleted-at (:deleted-at profile)]
@ -78,27 +79,29 @@
(ex/raise :type :validation (ex/raise :type :validation
:code :wrong-credentials))) :code :wrong-credentials)))
profile)] profile)
(db/with-atomic [conn pool] (login [{:keys [::db/conn] :as cfg}]
(let [profile (->> (profile/get-profile-by-email conn email) (let [profile (->> (profile/get-profile-by-email conn email)
(validate-profile conn) (validate-profile cfg)
(profile/strip-private-attrs)) (profile/strip-private-attrs))
invitation (when-let [token (:invitation-token params)] invitation (when-let [token (:invitation-token params)]
(tokens/verify (::main/props cfg) {:token token :iss :team-invitation})) (tokens/verify (::main/props cfg) {:token token :iss :team-invitation}))
;; If invitation member-id does not matches the profile-id, we just proceed to ignore the ;; If invitation member-id does not matches the profile-id, we just proceed to ignore the
;; invitation because invitations matches exactly; and user can't login with other email and ;; invitation because invitations matches exactly; and user can't login with other email and
;; accept invitation with other email ;; accept invitation with other email
response (if (and (some? invitation) (= (:id profile) (:member-id invitation))) response (if (and (some? invitation) (= (:id profile) (:member-id invitation)))
{:invitation-token (:invitation-token params)} {:invitation-token (:invitation-token params)}
(assoc profile :is-admin (let [admins (cf/get :admins)] (assoc profile :is-admin (let [admins (cf/get :admins)]
(contains? admins (:email profile)))))] (contains? admins (:email profile)))))]
(-> response (-> response
(rph/with-transform (session/create-fn cfg (:id profile))) (rph/with-transform (session/create-fn cfg (:id profile)))
(rph/with-meta {::audit/props (audit/profile->props profile) (rph/with-meta {::audit/props (audit/profile->props profile)
::audit/profile-id (:id profile)})))))) ::audit/profile-id (:id profile)}))))]
(db/tx-run! cfg login)))
(def schema:login-with-password (def schema:login-with-password
[:map {:title "login-with-password"} [:map {:title "login-with-password"}
@ -110,6 +113,7 @@
"Performs authentication using penpot password." "Performs authentication using penpot password."
{::rpc/auth false {::rpc/auth false
::doc/added "1.15" ::doc/added "1.15"
::climit/id :auth/global
::sm/params schema:login-with-password} ::sm/params schema:login-with-password}
[cfg params] [cfg params]
(login-with-password cfg params)) (login-with-password cfg params))
@ -149,7 +153,8 @@
(sv/defmethod ::recover-profile (sv/defmethod ::recover-profile
{::rpc/auth false {::rpc/auth false
::doc/added "1.15" ::doc/added "1.15"
::sm/params schema:recover-profile} ::sm/params schema:recover-profile
::climit/id :auth/global}
[cfg params] [cfg params]
(recover-profile cfg params)) (recover-profile cfg params))
@ -360,7 +365,6 @@
{::audit/type "fact" {::audit/type "fact"
::audit/name "register-profile-retry" ::audit/name "register-profile-retry"
::audit/profile-id id})) ::audit/profile-id id}))
(cond (cond
;; If invitation token comes in params, this is because the ;; If invitation token comes in params, this is because the
;; user comes from team-invitation process; in this case, ;; user comes from team-invitation process; in this case,
@ -402,7 +406,6 @@
{::audit/replace-props (audit/profile->props profile) {::audit/replace-props (audit/profile->props profile)
::audit/profile-id (:id profile)}))))) ::audit/profile-id (:id profile)})))))
(def schema:register-profile (def schema:register-profile
[:map {:title "register-profile"} [:map {:title "register-profile"}
[:token schema:token] [:token schema:token]
@ -411,7 +414,8 @@
(sv/defmethod ::register-profile (sv/defmethod ::register-profile
{::rpc/auth false {::rpc/auth false
::doc/added "1.15" ::doc/added "1.15"
::sm/params schema:register-profile} ::sm/params schema:register-profile
::climit/id :auth/global}
[{:keys [::db/pool] :as cfg} params] [{:keys [::db/pool] :as cfg} params]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(-> (assoc cfg ::db/conn conn) (-> (assoc cfg ::db/conn conn)

View File

@ -9,7 +9,7 @@
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.spec :as us] [app.common.schema :as sm]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.db :as db] [app.db :as db]
[app.db.sql :as sql] [app.db.sql :as sql]
@ -24,18 +24,21 @@
[app.rpc.retry :as rtry] [app.rpc.retry :as rtry]
[app.util.pointer-map :as pmap] [app.util.pointer-map :as pmap]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]))
[clojure.spec.alpha :as s]))
;; --- GENERAL PURPOSE INTERNAL HELPERS ;; --- GENERAL PURPOSE INTERNAL HELPERS
(defn decode-row (defn- decode-row
[{:keys [participants position] :as row}] [{:keys [participants position] :as row}]
(cond-> row (cond-> row
(db/pgpoint? position) (assoc :position (db/decode-pgpoint position)) (db/pgpoint? position) (assoc :position (db/decode-pgpoint position))
(db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants)))) (db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))))
(def sql:get-file (def xf-decode-row
(map decode-row))
(def ^:privateqpage-name
sql:get-file
"select f.id, f.modified_at, f.revn, f.features, "select f.id, f.modified_at, f.revn, f.features,
f.project_id, p.team_id, f.data f.project_id, p.team_id, f.data
from file as f from file as f
@ -45,17 +48,19 @@
(defn- get-file (defn- get-file
"A specialized version of get-file for comments module." "A specialized version of get-file for comments module."
[{:keys [::db/conn] :as cfg} file-id page-id] [cfg file-id page-id]
(if-let [{:keys [data] :as file} (some-> (db/exec-one! conn [sql:get-file file-id]) (let [file (db/exec-one! cfg [sql:get-file file-id])]
(files/decode-row))] (when-not file
(binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)] (ex/raise :type :not-found
(-> file :code :object-not-found
(assoc :page-name (dm/get-in data [:pages-index page-id :name])) :hint "file not found"))
(assoc :page-id page-id)))
(ex/raise :type :not-found (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
:code :object-not-found (let [{:keys [data] :as file} (files/decode-row file)]
:hint "file not found"))) (-> file
(assoc :page-name (dm/get-in data [:pages-index page-id :name]))
(assoc :page-id page-id)
(dissoc :data))))))
(defn- get-comment-thread (defn- get-comment-thread
[conn thread-id & {:as opts}] [conn thread-id & {:as opts}]
@ -93,23 +98,25 @@
(declare ^:private get-comment-threads) (declare ^:private get-comment-threads)
(s/def ::team-id ::us/uuid) (def ^:private
(s/def ::file-id ::us/uuid) schema:get-comment-threads
(s/def ::share-id (s/nilable ::us/uuid)) [:and
[:map {:title "get-comment-threads"}
(s/def ::get-comment-threads [:file-id {:optional true} ::sm/uuid]
(s/and (s/keys :req [::rpc/profile-id] [:team-id {:optional true} ::sm/uuid]
:opt-un [::file-id ::share-id ::team-id]) [:share-id {:optional true} [:maybe ::sm/uuid]]]
#(or (:file-id %) (:team-id %)))) [::sm/contains-any #{:file-id :team-id}]])
(sv/defmethod ::get-comment-threads (sv/defmethod ::get-comment-threads
{::doc/added "1.15"} {::doc/added "1.15"
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id share-id] :as params}] ::sm/params schema:get-comment-threads}
(dm/with-open [conn (db/open pool)] [cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
(files/check-comment-permissions! conn profile-id file-id share-id)
(get-comment-threads conn profile-id file-id)))
(def sql:comment-threads (db/run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id)
(get-comment-threads conn profile-id file-id))))
(def ^:private sql:comment-threads
"select distinct on (ct.id) "select distinct on (ct.id)
ct.*, ct.*,
f.name as file_name, f.name as file_name,
@ -134,23 +141,24 @@
(defn- get-comment-threads (defn- get-comment-threads
[conn profile-id file-id] [conn profile-id file-id]
(->> (db/exec! conn [sql:comment-threads profile-id file-id]) (->> (db/exec! conn [sql:comment-threads profile-id file-id])
(into [] (map decode-row)))) (into [] xf-decode-row)))
;; --- COMMAND: Get Unread Comment Threads ;; --- COMMAND: Get Unread Comment Threads
(declare ^:private get-unread-comment-threads) (declare ^:private get-unread-comment-threads)
(s/def ::team-id ::us/uuid) (def ^:private
(s/def ::get-unread-comment-threads schema:get-unread-comment-threads
(s/keys :req [::rpc/profile-id] [:map {:title "get-unread-comment-threads"}
:req-un [::team-id])) [:team-id ::sm/uuid]])
(sv/defmethod ::get-unread-comment-threads (sv/defmethod ::get-unread-comment-threads
{::doc/added "1.15"} {::doc/added "1.15"
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}] ::sm/params schema:get-unread-comment-threads}
(dm/with-open [conn (db/open pool)] [cfg {:keys [::rpc/profile-id team-id] :as params}]
(teams/check-read-permissions! conn profile-id team-id) (db/run! cfg (fn [{:keys [::db/conn]}]
(get-unread-comment-threads conn profile-id team-id))) (teams/check-read-permissions! conn profile-id team-id)
(get-unread-comment-threads conn profile-id team-id))))
(def sql:comment-threads-by-team (def sql:comment-threads-by-team
"select distinct on (ct.id) "select distinct on (ct.id)
@ -182,62 +190,60 @@
(defn- get-unread-comment-threads (defn- get-unread-comment-threads
[conn profile-id team-id] [conn profile-id team-id]
(->> (db/exec! conn [sql:unread-comment-threads-by-team profile-id team-id]) (->> (db/exec! conn [sql:unread-comment-threads-by-team profile-id team-id])
(into [] (map decode-row)))) (into [] xf-decode-row)))
;; --- COMMAND: Get Single Comment Thread ;; --- COMMAND: Get Single Comment Thread
(s/def ::get-comment-thread (def ^:private
(s/keys :req [::rpc/profile-id] schema:get-comment-thread
:req-un [::file-id ::us/id] [:map {:title "get-comment-thread"}
:opt-un [::share-id])) [:file-id ::sm/uuid]
[:id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(sv/defmethod ::get-comment-thread (sv/defmethod ::get-comment-thread
{::doc/added "1.15"} {::doc/added "1.15"
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id id share-id] :as params}] ::sm/params schema:get-comment-thread}
(dm/with-open [conn (db/open pool)] [cfg {:keys [::rpc/profile-id file-id id share-id] :as params}]
(files/check-comment-permissions! conn profile-id file-id share-id) (db/run! cfg (fn [{:keys [::db/conn]}]
(let [sql (str "with threads as (" sql:comment-threads ")" (files/check-comment-permissions! conn profile-id file-id share-id)
"select * from threads where id = ?")] (let [sql (str "with threads as (" sql:comment-threads ")"
(-> (db/exec-one! conn [sql profile-id file-id id]) "select * from threads where id = ?")]
(decode-row))))) (-> (db/exec-one! conn [sql profile-id file-id id])
(decode-row))))))
;; --- COMMAND: Retrieve Comments ;; --- COMMAND: Retrieve Comments
(declare ^:private get-comments) (declare ^:private get-comments)
(s/def ::thread-id ::us/uuid) (def ^:private
(s/def ::get-comments schema:get-comments
(s/keys :req [::rpc/profile-id] [:map {:title "get-comments"}
:req-un [::thread-id] [:thread-id ::sm/uuid]
:opt-un [::share-id])) [:share-id {:optional true} [:maybe ::sm/uuid]]])
(sv/defmethod ::get-comments (sv/defmethod ::get-comments
{::doc/added "1.15"} {::doc/added "1.15"
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id thread-id share-id] :as params}] ::sm/params schema:get-comments}
(dm/with-open [conn (db/open pool)] [cfg {:keys [::rpc/profile-id thread-id share-id]}]
(let [{:keys [file-id] :as thread} (get-comment-thread conn thread-id)] (db/run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id) (let [{:keys [file-id] :as thread} (get-comment-thread conn thread-id)]
(get-comments conn thread-id)))) (files/check-comment-permissions! conn profile-id file-id share-id)
(get-comments conn thread-id)))))
(def sql:comments
"select c.* from comment as c
where c.thread_id = ?
order by c.created_at asc")
(defn- get-comments (defn- get-comments
[conn thread-id] [conn thread-id]
(->> (db/query conn :comment (->> (db/query conn :comment
{:thread-id thread-id} {:thread-id thread-id}
{:order-by [[:created-at :asc]]}) {:order-by [[:created-at :asc]]})
(into [] (map decode-row)))) (into [] xf-decode-row)))
;; --- COMMAND: Get file comments users ;; --- COMMAND: Get file comments users
;; All the profiles that had comment the file, plus the current ;; All the profiles that had comment the file, plus the current
;; profile. ;; profile.
(def sql:file-comment-users (def ^:private sql:file-comment-users
"WITH available_profiles AS ( "WITH available_profiles AS (
SELECT DISTINCT owner_id AS id SELECT DISTINCT owner_id AS id
FROM comment FROM comment
@ -256,20 +262,22 @@
[conn file-id profile-id] [conn file-id profile-id]
(db/exec! conn [sql:file-comment-users file-id profile-id])) (db/exec! conn [sql:file-comment-users file-id profile-id]))
(s/def ::get-profiles-for-file-comments (def ^:private
(s/keys :req [::rpc/profile-id] schema:get-profiles-for-file-comments
:req-un [::file-id] [:map {:title "get-profiles-for-file-comments"}
:opt-un [::share-id])) [:file-id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(sv/defmethod ::get-profiles-for-file-comments (sv/defmethod ::get-profiles-for-file-comments
"Retrieves a list of profiles with limited set of properties of all "Retrieves a list of profiles with limited set of properties of all
participants on comment threads of the file." participants on comment threads of the file."
{::doc/added "1.15" {::doc/added "1.15"
::doc/changes ["1.15" "Imported from queries and renamed."]} ::doc/changes ["1.15" "Imported from queries and renamed."]
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id share-id]}] ::sm/params schema:get-profiles-for-file-comments}
(dm/with-open [conn (db/open pool)] [cfg {:keys [::rpc/profile-id file-id share-id]}]
(files/check-comment-permissions! conn profile-id file-id share-id) (db/run! cfg (fn [{:keys [::db/conn]}]
(get-file-comments-users conn file-id profile-id))) (files/check-comment-permissions! conn profile-id file-id share-id)
(get-file-comments-users conn file-id profile-id))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MUTATION COMMANDS ;; MUTATION COMMANDS
@ -279,52 +287,52 @@
;; --- COMMAND: Create Comment Thread ;; --- COMMAND: Create Comment Thread
(s/def ::page-id ::us/uuid) (def ^:private
(s/def ::position ::gpt/point) schema:create-comment-thread
(s/def ::content ::us/string) [:map {:title "create-comment-thread"}
(s/def ::frame-id ::us/uuid) [:file-id ::sm/uuid]
[:position ::gpt/point]
(s/def ::create-comment-thread [:content :string]
(s/keys :req [::rpc/profile-id] [:page-id ::sm/uuid]
:req-un [::file-id ::position ::content ::page-id ::frame-id] [:frame-id ::sm/uuid]
:opt-un [::share-id])) [:share-id {:optional true} [:maybe ::sm/uuid]]])
(sv/defmethod ::create-comment-thread (sv/defmethod ::create-comment-thread
{::doc/added "1.15" {::doc/added "1.15"
::webhooks/event? true} ::webhooks/event? true
::rtry/enabled true
::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]}] [cfg {:keys [::rpc/profile-id ::rpc/request-at file-id page-id share-id position content frame-id]}]
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! conn profile-id file-id share-id)
(let [{:keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)]
(run! (partial quotes/check-quote! conn) (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(list {::quotes/id ::quotes/comment-threads-per-file (files/check-comment-permissions! cfg profile-id file-id share-id)
::quotes/profile-id profile-id (let [{:keys [team-id project-id page-name]} (get-file conn file-id page-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}))
(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 (create-comment-thread conn {:created-at request-at
(assoc ::rtry/when rtry/conflict-exception?) :profile-id profile-id
(assoc ::rtry/label "create-comment-thread") :file-id file-id
(rtry/invoke create-comment-thread {:created-at request-at :page-id page-id
:profile-id profile-id :page-name page-name
:file-id file-id :position position
:page-id page-id :content content
:page-name page-name :frame-id frame-id})))))
:position position
:content content
:frame-id frame-id}))))))
(defn- create-comment-thread (defn- create-comment-thread
[{:keys [::db/conn]} {:keys [profile-id file-id page-id page-name created-at position content frame-id]}] [conn {: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 the whole (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 ;; operation can be retried on conflict, and in this case the new seq shold be
;; retrieved from the database. ;; retrieved from the database.
@ -364,68 +372,72 @@
;; --- COMMAND: Update Comment Thread Status ;; --- COMMAND: Update Comment Thread Status
(s/def ::id ::us/uuid) (def ^:private
(s/def ::share-id (s/nilable ::us/uuid)) schema:update-comment-thread-status
[:map {:title "update-comment-thread-status"}
(s/def ::update-comment-thread-status [:id ::sm/uuid]
(s/keys :req [::rpc/profile-id] [:share-id {:optional true} [:maybe ::sm/uuid]]])
:req-un [::id]
:opt-un [::share-id]))
(sv/defmethod ::update-comment-thread-status (sv/defmethod ::update-comment-thread-status
{::doc/added "1.15"} {::doc/added "1.15"
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}] ::sm/params schema:update-comment-thread-status}
(db/with-atomic [conn pool] [cfg {:keys [::rpc/profile-id id share-id]}]
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)] (db/tx-run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id) (let [{:keys [file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
(upsert-comment-thread-status! conn profile-id id)))) (files/check-comment-permissions! conn profile-id file-id share-id)
(upsert-comment-thread-status! conn profile-id id)))))
;; --- COMMAND: Update Comment Thread ;; --- COMMAND: Update Comment Thread
(s/def ::is-resolved ::us/boolean) (def ^:private
(s/def ::update-comment-thread schema:update-comment-thread
(s/keys :req [::rpc/profile-id] [:map {:title "update-comment-thread"}
:req-un [::id ::is-resolved] [:id ::sm/uuid]
:opt-un [::share-id])) [:is-resolved :boolean]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(sv/defmethod ::update-comment-thread (sv/defmethod ::update-comment-thread
{::doc/added "1.15"} {::doc/added "1.15"
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id is-resolved share-id] :as params}] ::sm/params schema:update-comment-thread}
(db/with-atomic [conn pool] [cfg {:keys [::rpc/profile-id id is-resolved share-id]}]
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)] (db/tx-run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id) (let [{:keys [file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
(db/update! conn :comment-thread (files/check-comment-permissions! conn profile-id file-id share-id)
{:is-resolved is-resolved} (db/update! conn :comment-thread
{:id id}) {:is-resolved is-resolved}
nil))) {:id id})
nil))))
;; --- COMMAND: Add Comment ;; --- COMMAND: Add Comment
(declare ^:private get-comment-thread) (declare ^:private get-comment-thread)
(s/def ::create-comment (def ^:private
(s/keys :req [::rpc/profile-id] schema:create-comment
:req-un [::thread-id ::content] [:map {:title "create-comment"}
:opt-un [::share-id])) [:thread-id ::sm/uuid]
[:content :string]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(sv/defmethod ::create-comment (sv/defmethod ::create-comment
{::doc/added "1.15" {::doc/added "1.15"
::webhooks/event? true} ::webhooks/event? true
::sm/params schema:create-comment}
[cfg {:keys [::rpc/profile-id ::rpc/request-at thread-id share-id content]}] [cfg {:keys [::rpc/profile-id ::rpc/request-at thread-id share-id content]}]
(db/tx-run! cfg (db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}] (fn [{:keys [::db/conn] :as cfg}]
(let [{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::sql/for-update true) (let [{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::sql/for-update true)
{:keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)] {:keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)]
(files/check-comment-permissions! conn profile-id (:id file) share-id) (files/check-comment-permissions! conn profile-id file-id share-id)
(quotes/check-quote! conn (quotes/check-quote! conn
{::quotes/id ::quotes/comments-per-file {::quotes/id ::quotes/comments-per-file
::quotes/profile-id profile-id ::quotes/profile-id profile-id
::quotes/team-id team-id ::quotes/team-id team-id
::quotes/project-id project-id ::quotes/project-id project-id
::quotes/file-id (:id file)}) ::quotes/file-id file-id})
;; Update the page-name cached attribute on comment thread table. ;; Update the page-name cached attribute on comment thread table.
(when (not= page-name (:page-name thread)) (when (not= page-name (:page-name thread))
@ -461,15 +473,17 @@
;; --- COMMAND: Update Comment ;; --- COMMAND: Update Comment
(s/def ::update-comment (def ^:private
(s/keys :req [::rpc/profile-id] schema:update-comment
:req-un [::id ::content] [:map {:title "update-comment"}
:opt-un [::share-id])) [:id ::sm/uuid]
[:content :string]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(sv/defmethod ::update-comment (sv/defmethod ::update-comment
{::doc/added "1.15"} {::doc/added "1.15"
::sm/params schema:update-comment}
[cfg {:keys [::rpc/profile-id ::rpc/request-at id share-id content]}] [cfg {:keys [::rpc/profile-id ::rpc/request-at id share-id content]}]
(db/tx-run! cfg (db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}] (fn [{:keys [::db/conn] :as cfg}]
(let [{:keys [thread-id owner-id] :as comment} (get-comment conn id ::sql/for-update true) (let [{:keys [thread-id owner-id] :as comment} (get-comment conn id ::sql/for-update true)
@ -482,7 +496,7 @@
(ex/raise :type :validation (ex/raise :type :validation
:code :not-allowed)) :code :not-allowed))
(let [{:keys [page-name] :as file} (get-file cfg file-id page-id)] (let [{:keys [page-name]} (get-file cfg file-id page-id)]
(db/update! conn :comment (db/update! conn :comment
{:content content {:content content
:modified-at request-at} :modified-at request-at}
@ -496,79 +510,90 @@
;; --- COMMAND: Delete Comment Thread ;; --- COMMAND: Delete Comment Thread
(s/def ::delete-comment-thread (def ^:private
(s/keys :req [::rpc/profile-id] schema:delete-comment-thread
:req-un [::id] [:map {:title "delete-comment-thread"}
:opt-un [::share-id])) [:id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(sv/defmethod ::delete-comment-thread (sv/defmethod ::delete-comment-thread
{::doc/added "1.15"} {::doc/added "1.15"
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id share-id]}] ::sm/params schema:delete-comment-thread}
(db/with-atomic [conn pool] [cfg {:keys [::rpc/profile-id id share-id]}]
(let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)] (db/tx-run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id) (let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
(when-not (= owner-id profile-id) (files/check-comment-permissions! conn profile-id file-id share-id)
(ex/raise :type :validation (when-not (= owner-id profile-id)
:code :not-allowed)) (ex/raise :type :validation
:code :not-allowed))
(db/delete! conn :comment-thread {:id id}) (db/delete! conn :comment-thread {:id id})
nil))) nil))))
;; --- COMMAND: Delete comment ;; --- COMMAND: Delete comment
(s/def ::delete-comment (def ^:private
(s/keys :req [::rpc/profile-id] schema:delete-comment
:req-un [::id] [:map {:title "delete-comment"}
:opt-un [::share-id])) [:id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(sv/defmethod ::delete-comment (sv/defmethod ::delete-comment
{::doc/added "1.15"} {::doc/added "1.15"
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}] ::sm/params schema:delete-comment}
(db/with-atomic [conn pool] [cfg {:keys [::rpc/profile-id id share-id]}]
(let [{:keys [owner-id thread-id] :as comment} (get-comment conn id ::sql/for-update true) (db/tx-run! cfg (fn [{:keys [::db/conn]}]
{:keys [file-id] :as thread} (get-comment-thread conn thread-id)] (let [{:keys [owner-id thread-id] :as comment} (get-comment conn id ::sql/for-update true)
(files/check-comment-permissions! conn profile-id file-id share-id) {:keys [file-id] :as thread} (get-comment-thread conn thread-id)]
(when-not (= owner-id profile-id) (files/check-comment-permissions! conn profile-id file-id share-id)
(ex/raise :type :validation (when-not (= owner-id profile-id)
:code :not-allowed)) (ex/raise :type :validation
(db/delete! conn :comment {:id id}) :code :not-allowed))
nil))) (db/delete! conn :comment {:id id})
nil))))
;; --- COMMAND: Update comment thread position ;; --- COMMAND: Update comment thread position
(s/def ::update-comment-thread-position (def ^:private
(s/keys :req [::rpc/profile-id] schema:update-comment-thread-position
:req-un [::id ::position ::frame-id] [:map {:title "update-comment-thread-position"}
:opt-un [::share-id])) [:id ::sm/uuid]
[:position ::gpt/point]
[:frame-id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(sv/defmethod ::update-comment-thread-position (sv/defmethod ::update-comment-thread-position
{::doc/added "1.15"} {::doc/added "1.15"
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}] ::sm/params schema:update-comment-thread-position}
(db/with-atomic [conn pool] [cfg {:keys [::rpc/profile-id ::rpc/request-at id position frame-id share-id]}]
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)] (db/tx-run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id) (let [{:keys [file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
(db/update! conn :comment-thread (files/check-comment-permissions! conn profile-id file-id share-id)
{:modified-at request-at (db/update! conn :comment-thread
:position (db/pgpoint position) {:modified-at request-at
:frame-id frame-id} :position (db/pgpoint position)
{:id (:id thread)}) :frame-id frame-id}
nil))) {:id (:id thread)})
nil))))
;; --- COMMAND: Update comment frame ;; --- COMMAND: Update comment frame
(s/def ::update-comment-thread-frame (def ^:private
(s/keys :req [::rpc/profile-id] schema:update-comment-thread-frame
:req-un [::id ::frame-id] [:map {:title "update-comment-thread-frame"}
:opt-un [::share-id])) [:id ::sm/uuid]
[:frame-id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(sv/defmethod ::update-comment-thread-frame (sv/defmethod ::update-comment-thread-frame
{::doc/added "1.15"} {::doc/added "1.15"
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}] ::sm/params schema:update-comment-thread-frame}
(db/with-atomic [conn pool] [cfg {:keys [::rpc/profile-id ::rpc/request-at id frame-id share-id]}]
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)] (db/tx-run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id) (let [{:keys [file-id] :as thread} (get-comment-thread conn id ::sql/for-update true)]
(db/update! conn :comment-thread (files/check-comment-permissions! conn profile-id file-id share-id)
{:modified-at request-at (db/update! conn :comment-thread
:frame-id frame-id} {:modified-at request-at
{:id id}) :frame-id frame-id}
nil))) {:id id})
nil))))

View File

@ -285,26 +285,27 @@
(sv/defmethod ::create-file-object-thumbnail (sv/defmethod ::create-file-object-thumbnail
{::doc/added "1.19" {::doc/added "1.19"
::doc/module :files ::doc/module :files
::climit/id :file-thumbnail-ops ::climit/id [[:file-thumbnail-ops/by-profile ::rpc/profile-id]
::climit/key-fn ::rpc/profile-id [:file-thumbnail-ops/global]]
::rtry/enabled true
::rtry/when rtry/conflict-exception?
::audit/skip true ::audit/skip true
::sm/params schema:create-file-object-thumbnail} ::sm/params schema:create-file-object-thumbnail}
[cfg {:keys [::rpc/profile-id file-id object-id media tag]}] [cfg {:keys [::rpc/profile-id file-id object-id media tag]}]
(media/validate-media-type! media)
(media/validate-media-size! media)
(db/tx-run! cfg (db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}] (fn [{:keys [::db/conn] :as cfg}]
(files/check-edition-permissions! conn profile-id file-id) (files/check-edition-permissions! conn profile-id file-id)
(media/validate-media-type! media)
(media/validate-media-size! media)
(when-not (db/read-only? conn) (when-not (db/read-only? conn)
(let [cfg (-> cfg (let [cfg (-> cfg
(update ::sto/storage media/configure-assets-storage) (update ::sto/storage media/configure-assets-storage)
(assoc ::rtry/when rtry/conflict-exception?) (assoc ::rtry/when rtry/conflict-exception?)
(assoc ::rtry/max-retries 5) (assoc ::rtry/max-retries 5)
(assoc ::rtry/label "create-file-object-thumbnail"))] (assoc ::rtry/label "create-file-object-thumbnail"))]
(rtry/invoke cfg create-file-object-thumbnail! (create-file-object-thumbnail! cfg file-id object-id media (or tag "frame")))))))
file-id object-id media (or tag "frame")))))))
;; --- MUTATION COMMAND: delete-file-object-thumbnail ;; --- MUTATION COMMAND: delete-file-object-thumbnail
@ -329,8 +330,8 @@
{::doc/added "1.19" {::doc/added "1.19"
::doc/module :files ::doc/module :files
::doc/deprecated "1.20" ::doc/deprecated "1.20"
::climit/id :file-thumbnail-ops ::climit/id [[:file-thumbnail-ops/by-profile ::rpc/profile-id]
::climit/key-fn ::rpc/profile-id [:file-thumbnail-ops/global]]
::audit/skip true} ::audit/skip true}
[cfg {:keys [::rpc/profile-id file-id object-id]}] [cfg {:keys [::rpc/profile-id file-id object-id]}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
@ -392,27 +393,29 @@
media)) media))
(def ^:private
schema:create-file-thumbnail
[:map {:title "create-file-thumbnail"}
[:file-id ::sm/uuid]
[:revn :int]
[:media ::media/upload]])
(sv/defmethod ::create-file-thumbnail (sv/defmethod ::create-file-thumbnail
"Creates or updates the file thumbnail. Mainly used for paint the "Creates or updates the file thumbnail. Mainly used for paint the
grid thumbnails." grid thumbnails."
{::doc/added "1.19" {::doc/added "1.19"
::doc/module :files ::doc/module :files
::audit/skip true ::audit/skip true
::climit/id :file-thumbnail-ops ::climit/id [[:file-thumbnail-ops/by-profile ::rpc/profile-id]
::climit/key-fn ::rpc/profile-id [:file-thumbnail-ops/global]]
::sm/params [:map {:title "create-file-thumbnail"} ::rtry/enabled true
[:file-id ::sm/uuid] ::rtry/when rtry/conflict-exception?
[:revn :int] ::sm/params schema:create-file-thumbnail}
[:media ::media/upload]]}
[cfg {:keys [::rpc/profile-id file-id] :as params}] [cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-edition-permissions! conn profile-id file-id) (files/check-edition-permissions! conn profile-id file-id)
(when-not (db/read-only? conn) (when-not (db/read-only? conn)
(let [cfg (-> cfg (let [cfg (update cfg ::sto/storage media/configure-assets-storage)
(update ::sto/storage media/configure-assets-storage) media (create-file-thumbnail! cfg params)]
(assoc ::rtry/when rtry/conflict-exception?)
(assoc ::rtry/max-retries 5)
(assoc ::rtry/label "create-thumbnail"))
media (rtry/invoke cfg create-file-thumbnail! params)]
{:uri (files/resolve-public-uri (:id media))}))))) {:uri (files/resolve-public-uri (:id media))})))))

View File

@ -35,7 +35,8 @@
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as-alias wrk] [app.worker :as-alias wrk]
[clojure.set :as set])) [clojure.set :as set]
[promesa.exec :as px]))
;; --- SCHEMA ;; --- SCHEMA
@ -132,8 +133,8 @@
;; database. ;; database.
(sv/defmethod ::update-file (sv/defmethod ::update-file
{::climit/id :update-file/by-profile {::climit/id [[:update-file/by-profile ::rpc/profile-id]
::climit/key-fn ::rpc/profile-id [:update-file/global]]
::webhooks/event? true ::webhooks/event? true
::webhooks/batch-timeout (dt/duration "2m") ::webhooks/batch-timeout (dt/duration "2m")
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id) ::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
@ -232,13 +233,9 @@
(defn- update-file* (defn- update-file*
[{:keys [::db/conn ::wrk/executor] :as cfg} [{:keys [::db/conn ::wrk/executor] :as cfg}
{:keys [profile-id file changes session-id ::created-at skip-validate] :as params}] {:keys [profile-id file changes session-id ::created-at skip-validate] :as params}]
(let [;; Process the file data in the CLIMIT context; scheduling it (let [;; Process the file data on separated thread for avoid to do
;; to be executed on a separated executor for avoid to do the ;; the CPU intensive operation on vthread.
;; CPU intensive operation on vthread. file (px/invoke! executor (partial update-file-data cfg file changes skip-validate))]
update-fdata-fn (partial update-file-data cfg file changes skip-validate)
file (-> (climit/configure cfg :update-file/global)
(climit/run! update-fdata-fn executor))]
(db/insert! conn :file-change (db/insert! conn :file-change
{:id (uuid/next) {:id (uuid/next)
@ -306,7 +303,6 @@
(fmg/migrate-file)) (fmg/migrate-file))
file) file)
;; WARNING: this ruins performance; maybe we need to find ;; WARNING: this ruins performance; maybe we need to find
;; some other way to do general validation ;; some other way to do general validation
libs (when (and (or (contains? cf/flags :file-validation) libs (when (and (or (contains? cf/flags :file-validation)

View File

@ -16,7 +16,7 @@
[app.loggers.webhooks :as-alias webhooks] [app.loggers.webhooks :as-alias webhooks]
[app.media :as media] [app.media :as media]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.climit :as climit] [app.rpc.climit :as-alias climit]
[app.rpc.commands.files :as files] [app.rpc.commands.files :as files]
[app.rpc.commands.projects :as projects] [app.rpc.commands.projects :as projects]
[app.rpc.commands.teams :as teams] [app.rpc.commands.teams :as teams]
@ -26,7 +26,8 @@
[app.storage :as sto] [app.storage :as sto]
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as-alias wrk])) [app.worker :as-alias wrk]
[promesa.exec :as px]))
(def valid-weight #{100 200 300 400 500 600 700 800 900 950}) (def valid-weight #{100 200 300 400 500 600 700 800 900 950})
(def valid-style #{"normal" "italic"}) (def valid-style #{"normal" "italic"})
@ -87,6 +88,8 @@
(sv/defmethod ::create-font-variant (sv/defmethod ::create-font-variant
{::doc/added "1.18" {::doc/added "1.18"
::climit/id [[:process-font/by-profile ::rpc/profile-id]
[:process-font/global]]
::webhooks/event? true ::webhooks/event? true
::sm/params schema:create-font-variant} ::sm/params schema:create-font-variant}
[cfg {:keys [::rpc/profile-id team-id] :as params}] [cfg {:keys [::rpc/profile-id team-id] :as params}]
@ -100,7 +103,7 @@
(create-font-variant cfg (assoc params :profile-id profile-id)))))) (create-font-variant cfg (assoc params :profile-id profile-id))))))
(defn create-font-variant (defn create-font-variant
[{:keys [::sto/storage ::db/conn] :as cfg} {:keys [data] :as params}] [{:keys [::sto/storage ::db/conn ::wrk/executor]} {:keys [data] :as params}]
(letfn [(generate-missing! [data] (letfn [(generate-missing! [data]
(let [data (media/run {:cmd :generate-fonts :input data})] (let [data (media/run {:cmd :generate-fonts :input data})]
(when (and (not (contains? data "font/otf")) (when (and (not (contains? data "font/otf"))
@ -152,9 +155,7 @@
:otf-file-id (:id otf) :otf-file-id (:id otf)
:ttf-file-id (:id ttf)}))] :ttf-file-id (:id ttf)}))]
(let [data (-> (climit/configure cfg :process-font/global) (let [data (px/invoke! executor (partial generate-missing! data))
(climit/run! (partial generate-missing! data)
(::wrk/executor cfg)))
assets (persist-fonts-files! data) assets (persist-fonts-files! data)
result (insert-font-variant! assets)] result (insert-font-variant! assets)]
(vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys)))))) (vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys))))))

View File

@ -27,7 +27,8 @@
[app.worker :as-alias wrk] [app.worker :as-alias wrk]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
[datoteka.io :as io])) [datoteka.io :as io]
[promesa.exec :as px]))
(def default-max-file-size (def default-max-file-size
(* 1024 1024 10)) ; 10 MiB (* 1024 1024 10)) ; 10 MiB
@ -56,20 +57,25 @@
:opt-un [::id])) :opt-un [::id]))
(sv/defmethod ::upload-file-media-object (sv/defmethod ::upload-file-media-object
{::doc/added "1.17"} {::doc/added "1.17"
::climit/id [[:process-image/by-profile ::rpc/profile-id]
[:process-image/global]]}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)] (let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
(files/check-edition-permissions! pool profile-id file-id) (files/check-edition-permissions! pool profile-id file-id)
(media/validate-media-type! content) (media/validate-media-type! content)
(media/validate-media-size! content) (media/validate-media-size! content)
(let [object (db/run! cfg #(create-file-media-object % params))
props {:name (:name params) (db/run! cfg (fn [cfg]
:file-id file-id (let [object (create-file-media-object cfg params)
:is-local (:is-local params) props {:name (:name params)
:size (:size content) :file-id file-id
:mtype (:mtype content)}] :is-local (:is-local params)
(with-meta object :size (:size content)
{::audit/replace-props props})))) :mtype (:mtype content)}]
(with-meta object
{::audit/replace-props props}))))))
(defn- big-enough-for-thumbnail? (defn- big-enough-for-thumbnail?
"Checks if the provided image info is big enough for "Checks if the provided image info is big enough for
@ -144,12 +150,10 @@
(assoc ::image (process-main-image info))))) (assoc ::image (process-main-image info)))))
(defn create-file-media-object (defn create-file-media-object
[{:keys [::sto/storage ::db/conn ::wrk/executor] :as cfg} [{:keys [::sto/storage ::db/conn ::wrk/executor]}
{:keys [id file-id is-local name content]}] {:keys [id file-id is-local name content]}]
(let [result (-> (climit/configure cfg :process-image/global) (let [result (px/invoke! executor (partial process-image content))
(climit/run! (partial process-image content) executor))
image (sto/put-object! storage (::image result)) image (sto/put-object! storage (::image result))
thumb (when-let [params (::thumb result)] thumb (when-let [params (::thumb result)]
(sto/put-object! storage params))] (sto/put-object! storage params))]
@ -183,7 +187,7 @@
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)] (let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
(files/check-edition-permissions! pool profile-id file-id) (files/check-edition-permissions! pool profile-id file-id)
(db/run! cfg #(create-file-media-object-from-url % params)))) (create-file-media-object-from-url cfg (assoc params :profile-id profile-id))))
(defn download-image (defn download-image
[{:keys [::http/client]} uri] [{:keys [::http/client]} uri]
@ -235,7 +239,16 @@
params (-> params params (-> params
(assoc :content content) (assoc :content content)
(assoc :name (or name (:filename content))))] (assoc :name (or name (:filename content))))]
(create-file-media-object cfg params)))
;; NOTE: we use the climit here in a dynamic invocation because we
;; don't want saturate the process-image limit with IO (download
;; of external image)
(-> cfg
(assoc ::climit/id [[:process-image/by-profile (:profile-id params)]
[:process-image/global]])
(assoc ::climit/profile-id (:profile-id params))
(assoc ::climit/label "create-file-media-object-from-url")
(climit/invoke! db/run! cfg create-file-media-object params))))
;; --- Clone File Media object (Upload and create from url) ;; --- Clone File Media object (Upload and create from url)

View File

@ -28,7 +28,8 @@
[app.util.services :as sv] [app.util.services :as sv]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as-alias wrk] [app.worker :as-alias wrk]
[cuerdas.core :as str])) [cuerdas.core :as str]
[promesa.exec :as px]))
(declare check-profile-existence!) (declare check-profile-existence!)
(declare decode-row) (declare decode-row)
@ -137,25 +138,24 @@
[:old-password {:optional true} [:maybe [::sm/word-string {:max 500}]]]])) [:old-password {:optional true} [:maybe [::sm/word-string {:max 500}]]]]))
(sv/defmethod ::update-profile-password (sv/defmethod ::update-profile-password
{:doc/added "1.0" {::doc/added "1.0"
::sm/params schema:update-profile-password ::sm/params schema:update-profile-password
::sm/result :nil} ::climit/id :auth/global}
[cfg {:keys [::rpc/profile-id password] :as params}]
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id password] :as params}] (db/tx-run! cfg (fn [cfg]
(db/with-atomic [conn pool] (let [profile (validate-password! cfg (assoc params :profile-id profile-id))
(let [cfg (assoc cfg ::db/conn conn) session-id (::session/id params)]
profile (validate-password! cfg (assoc params :profile-id profile-id))
session-id (::session/id params)]
(when (= (str/lower (:email profile)) (when (= (str/lower (:email profile))
(str/lower (:password params))) (str/lower (:password params)))
(ex/raise :type :validation (ex/raise :type :validation
:code :email-as-password :code :email-as-password
:hint "you can't use your email as password")) :hint "you can't use your email as password"))
(update-profile-password! conn (assoc profile :password password)) (update-profile-password! cfg (assoc profile :password password))
(invalidate-profile-session! cfg profile-id session-id) (invalidate-profile-session! cfg profile-id session-id)
nil))) nil))))
(defn- invalidate-profile-session! (defn- invalidate-profile-session!
"Removes all sessions except the current one." "Removes all sessions except the current one."
@ -173,10 +173,10 @@
profile)) profile))
(defn update-profile-password! (defn update-profile-password!
[conn {:keys [id password] :as profile}] [{:keys [::db/conn] :as cfg} {:keys [id password] :as profile}]
(when-not (db/read-only? conn) (when-not (db/read-only? conn)
(db/update! conn :profile (db/update! conn :profile
{:password (auth/derive-password password)} {:password (derive-password cfg password)}
{:id id}) {:id id})
nil)) nil))
@ -203,6 +203,7 @@
(defn update-profile-photo (defn update-profile-photo
[{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id file] :as params}] [{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id file] :as params}]
(let [photo (upload-photo cfg params) (let [photo (upload-photo cfg params)
profile (db/get-by-id pool :profile profile-id ::sql/for-update true)] profile (db/get-by-id pool :profile profile-id ::sql/for-update true)]
@ -241,8 +242,11 @@
(defn upload-photo (defn upload-photo
[{:keys [::sto/storage ::wrk/executor] :as cfg} {:keys [file]}] [{:keys [::sto/storage ::wrk/executor] :as cfg} {:keys [file]}]
(let [params (-> (climit/configure cfg :process-image/global) (let [params (-> cfg
(climit/run! (partial generate-thumbnail! file) executor))] (assoc ::climit/id :process-image/global)
(assoc ::climit/label "upload-photo")
(assoc ::climit/executor executor)
(climit/invoke! generate-thumbnail! file))]
(sto/put-object! storage params))) (sto/put-object! storage params)))
@ -438,17 +442,13 @@
(into {} (filter (fn [[k _]] (simple-ident? k))) props)) (into {} (filter (fn [[k _]] (simple-ident? k))) props))
(defn derive-password (defn derive-password
[cfg password] [{:keys [::wrk/executor]} password]
(when password (when password
(-> (climit/configure cfg :derive-password/global) (px/invoke! executor (partial auth/derive-password password))))
(climit/run! (partial auth/derive-password password)
(::wrk/executor cfg)))))
(defn verify-password (defn verify-password
[cfg password password-data] [{:keys [::wrk/executor]} password password-data]
(-> (climit/configure cfg :derive-password/global) (px/invoke! executor (partial auth/verify-password password password-data)))
(climit/run! (partial auth/verify-password password password-data)
(::wrk/executor cfg))))
(defn decode-row (defn decode-row
[{:keys [props] :as row}] [{:keys [props] :as row}]

View File

@ -7,8 +7,10 @@
(ns app.rpc.quotes (ns app.rpc.quotes
"Penpot resource usage quotes." "Penpot resource usage quotes."
(:require (:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.schema :as sm]
[app.common.spec :as us] [app.common.spec :as us]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
@ -23,21 +25,15 @@
;; PUBLIC API ;; PUBLIC API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(s/def ::conn ::db/pool-or-conn) (def ^:private schema:quote
(s/def ::file-id ::us/uuid) (sm/define
(s/def ::team-id ::us/uuid) [:map {:title "Quote"}
(s/def ::project-id ::us/uuid) [::team-id {:optional true} ::sm/uuid]
(s/def ::profile-id ::us/uuid) [::project-id {:optional true} ::sm/uuid]
(s/def ::incr (s/and int? pos?)) [::file-id {:optional true} ::sm/uuid]
(s/def ::target ::us/string) [::incr {:optional true} [:int {:min 0}]]
[::id :keyword]
(s/def ::quote [::profile-id ::sm/uuid]]))
(s/keys :req [::id ::profile-id]
:opt [::conn
::team-id
::project-id
::file-id
::incr]))
(def ^:private enabled (volatile! true)) (def ^:private enabled (volatile! true))
@ -52,15 +48,22 @@
(vswap! enabled (constantly false))) (vswap! enabled (constantly false)))
(defn check-quote! (defn check-quote!
[conn quote] [ds quote]
(us/assert! ::db/pool-or-conn conn) (dm/assert!
(us/assert! ::quote quote) "expected valid quote map"
(sm/validate schema:quote quote))
(when (contains? cf/flags :quotes) (when (contains? cf/flags :quotes)
(when @enabled (when @enabled
(check-quote (assoc quote ::conn conn ::target (name (::id quote))))))) ;; 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)))))))
(defn- send-notification! (defn- send-notification!
[{:keys [::conn] :as params}] [{:keys [::db/conn] :as params}]
(l/warn :hint "max quote reached" (l/warn :hint "max quote reached"
:target (::target params) :target (::target params)
:profile-id (some-> params ::profile-id str) :profile-id (some-> params ::profile-id str)
@ -93,7 +96,7 @@
:content content}]})))) :content content}]}))))
(defn- generic-check! (defn- generic-check!
[{:keys [::conn ::incr ::quote-sql ::count-sql ::default ::target] :or {incr 1} :as params}] [{:keys [::db/conn ::incr ::quote-sql ::count-sql ::default ::target] :or {incr 1} :as params}]
(let [quote (->> (db/exec! conn quote-sql) (let [quote (->> (db/exec! conn quote-sql)
(map :quote) (map :quote)
(reduce max (- Integer/MAX_VALUE))) (reduce max (- Integer/MAX_VALUE)))
@ -347,7 +350,6 @@
(assoc ::count-sql [sql:get-comments-per-file file-id]) (assoc ::count-sql [sql:get-comments-per-file file-id])
(generic-check!))) (generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: DEFAULT ;; QUOTE: DEFAULT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -6,8 +6,8 @@
(ns app.rpc.retry (ns app.rpc.retry
(:require (:require
[app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
[app.db :as db]
[app.util.services :as sv]) [app.util.services :as sv])
(:import (:import
org.postgresql.util.PSQLException)) org.postgresql.util.PSQLException))
@ -15,12 +15,29 @@
(defn conflict-exception? (defn conflict-exception?
"Check if exception matches a insertion conflict on postgresql." "Check if exception matches a insertion conflict on postgresql."
[e] [e]
(and (instance? PSQLException e) (when-let [cause (ex/instance? PSQLException e)]
(= "23505" (.getSQLState ^PSQLException e)))) (= "23505" (.getSQLState ^PSQLException cause))))
(def ^:private always-false (def ^:private always-false
(constantly false)) (constantly false))
(defn invoke!
[{:keys [::max-retries] :or {max-retries 3} :as cfg} f & args]
(loop [rnum 1]
(let [match? (get cfg ::when always-false)
result (try
(apply f cfg args)
(catch Throwable cause
(if (and (match? cause) (<= rnum max-retries))
::retry
(throw cause))))]
(if (= ::retry result)
(let [label (get cfg ::label "anonymous")]
(l/warn :hint "retrying operation" :label label :retry rnum)
(recur (inc rnum)))
result))))
(defn wrap-retry (defn wrap-retry
[_ f {:keys [::sv/name] :as mdata}] [_ f {:keys [::sv/name] :as mdata}]
@ -29,36 +46,10 @@
matches? (get mdata ::when always-false)] matches? (get mdata ::when always-false)]
(l/dbg :hint "wrapping retry" :name name :max-retries max-retries) (l/dbg :hint "wrapping retry" :name name :max-retries max-retries)
(fn [cfg params] (fn [cfg params]
((fn recursive-invoke [retry] (-> cfg
(try (assoc ::max-retries max-retries)
(f cfg params) (assoc ::when matches?)
(catch Throwable cause (assoc ::label name)
(if (matches? cause) (invoke! f params))))
(let [current-retry (inc retry)]
(l/wrn :hint "retrying operation" :retry current-retry :service name)
(if (<= current-retry max-retries)
(recursive-invoke current-retry)
(throw cause)))
(throw cause))))) 1)))
f)) f))
(defn invoke
[{:keys [::db/conn ::max-retries] :or {max-retries 3} :as cfg} f & args]
(assert (db/connection? conn) "invalid database connection")
(loop [rnum 1]
(let [match? (get cfg ::when always-false)
result (let [spoint (db/savepoint conn)]
(try
(let [result (apply f cfg args)]
(db/release! conn spoint)
result)
(catch Throwable cause
(db/rollback! conn spoint)
(if (and (match? cause) (<= rnum max-retries))
::retry
(throw cause)))))]
(if (= ::retry result)
(let [label (get cfg ::label "anonymous")]
(l/warn :hint "retrying operation" :label label :retry rnum)
(recur (inc rnum)))
result))))

View File

@ -71,6 +71,7 @@
:enable-email-verification :enable-email-verification
:enable-smtp :enable-smtp
:enable-quotes :enable-quotes
:enable-rpc-climit
:enable-feature-fdata-pointer-map :enable-feature-fdata-pointer-map
:enable-feature-fdata-objets-map :enable-feature-fdata-objets-map
:enable-feature-components-v2 :enable-feature-components-v2

View File

@ -74,10 +74,9 @@
[class cause] [class cause]
(loop [cause cause] (loop [cause cause]
(if (c/instance? class cause) (if (c/instance? class cause)
true cause
(if-let [cause (ex-cause cause)] (when-let [cause (ex-cause cause)]
(recur cause) (recur cause))))))
false)))))
;; NOTE: idea for a macro for error handling ;; NOTE: idea for a macro for error handling
;; (pu/try-let [cause (p/await (get-object-data backend object))] ;; (pu/try-let [cause (p/await (get-object-data backend object))]

View File

@ -111,6 +111,7 @@
shadow-width shadow-width
(->> (:shadow shape) (->> (:shadow shape)
(remove :hidden)
(map #(case (:style % :drop-shadow) (map #(case (:style % :drop-shadow)
:drop-shadow (+ (mth/abs (:offset-x %)) (* (:spread %) 2) (* (:blur %) 2) 10) :drop-shadow (+ (mth/abs (:offset-x %)) (* (:spread %) 2) (* (:blur %) 2) 10)
0)) 0))
@ -118,6 +119,7 @@
shadow-height shadow-height
(->> (:shadow shape) (->> (:shadow shape)
(remove :hidden)
(map #(case (:style % :drop-shadow) (map #(case (:style % :drop-shadow)
:drop-shadow (+ (mth/abs (:offset-y %)) (* (:spread %) 2) (* (:blur %) 2) 10) :drop-shadow (+ (mth/abs (:offset-y %)) (* (:spread %) 2) (* (:blur %) 2) 10)
0)) 0))

View File

@ -340,30 +340,43 @@
(grc/points->rect points)))) (grc/points->rect points))))
(defn content->selrect [content] (defn content->selrect [content]
(let [calc-extremities (let [extremities
(fn [command prev] (loop [points #{}
(case (:command command) from-p nil
:move-to [(command->point command)] move-p nil
content (seq content)]
(if content
(let [command (first content)
to-p (command->point command)
;; If it's a line we add the beginning point and endpoint [from-p move-p command-pts]
:line-to [(command->point prev) (case (:command command)
(command->point command)] :move-to [to-p to-p (when to-p [to-p])]
:close-path [move-p move-p (when move-p [move-p])]
:line-to [to-p move-p (when (and from-p to-p) [from-p to-p])]
:curve-to [to-p move-p
(let [c1 (command->point command :c1)
c2 (command->point command :c2)
curve [from-p to-p c1 c2]]
(when (and from-p to-p c1 c2)
(into [from-p to-p]
(->> (curve-extremities curve)
(map #(curve-values curve %))))))]
[to-p move-p []])]
;; We return the bezier extremities (recur (apply conj points command-pts) from-p move-p (next content)))
:curve-to (into [(command->point prev) points))
(command->point command)]
(let [curve [(command->point prev)
(command->point command)
(command->point command :c1)
(command->point command :c2)]]
(->> (curve-extremities curve)
(map #(curve-values curve %)))))
[]))
extremities (mapcat calc-extremities ;; We haven't found any extremes so we turn the commands to points
content extremities
(concat [nil] content))] (if (empty? extremities)
(grc/points->rect extremities))) (->> content (keep command->point))
extremities)]
;; If no points are returned we return an empty rect.
(if (d/not-empty? extremities)
(grc/points->rect extremities)
(grc/make-rect))))
(defn move-content [content move-vec] (defn move-content [content move-vec]
(let [dx (:x move-vec) (let [dx (:x move-vec)

View File

@ -140,6 +140,28 @@
(gmt/translate (gpt/negate shape-center))))) (gmt/translate (gpt/negate shape-center)))))
(defn inverse-transform-matrix
([shape]
(inverse-transform-matrix shape nil))
([shape params]
(inverse-transform-matrix shape params (or (gco/shape->center shape) (gpt/point 0 0))))
([{:keys [flip-x flip-y transform-inverse] :as shape} {:keys [no-flip]} shape-center]
(-> (gmt/matrix)
(gmt/translate shape-center)
(cond-> (and flip-x no-flip)
(gmt/scale (gpt/point -1 1)))
(cond-> (and flip-y no-flip)
(gmt/scale (gpt/point 1 -1)))
(cond-> (some? transform-inverse)
(gmt/multiply transform-inverse))
(gmt/translate (gpt/negate shape-center)))))
(defn transform-str (defn transform-str
([shape] ([shape]
(transform-str shape nil)) (transform-str shape nil))
@ -152,21 +174,6 @@
(dm/str (transform-matrix shape params)) (dm/str (transform-matrix shape params))
""))) "")))
;; FIXME: performance
(defn inverse-transform-matrix
([shape]
(let [shape-center (or (gco/shape->center shape)
(gpt/point 0 0))]
(inverse-transform-matrix shape shape-center)))
([{:keys [flip-x flip-y] :as shape} center]
(-> (gmt/matrix)
(gmt/translate center)
(cond->
flip-x (gmt/scale (gpt/point -1 1))
flip-y (gmt/scale (gpt/point 1 -1)))
(gmt/multiply (:transform-inverse shape (gmt/matrix)))
(gmt/translate (gpt/negate center)))))
;; FIXME: move to geom rect? ;; FIXME: move to geom rect?
(defn transform-rect (defn transform-rect
"Transform a rectangles and changes its attributes" "Transform a rectangles and changes its attributes"

View File

@ -17,7 +17,11 @@
[id objects] [id objects]
(->> (tree-seq (->> (tree-seq
#(d/not-empty? (dm/get-in objects [% :shapes])) #(d/not-empty? (dm/get-in objects [% :shapes]))
#(dm/get-in objects [% :shapes]) (fn [id]
(let [shape (get objects id)]
(cond->> (:shapes shape)
(and (ctl/flex-layout? shape) (ctl/reverse? shape))
(reverse))))
id) id)
(map #(get objects %)))) (map #(get objects %))))

View File

@ -15,15 +15,16 @@
(defn rect->snap-points (defn rect->snap-points
[rect] [rect]
(let [x (dm/get-prop rect :x) (when (some? rect)
y (dm/get-prop rect :y) (let [x (dm/get-prop rect :x)
w (dm/get-prop rect :width) y (dm/get-prop rect :y)
h (dm/get-prop rect :height)] w (dm/get-prop rect :width)
#{(gpt/point x y) h (dm/get-prop rect :height)]
(gpt/point (+ x w) y) #{(gpt/point x y)
(gpt/point (+ x w) (+ y h)) (gpt/point (+ x w) y)
(gpt/point x (+ y h)) (gpt/point (+ x w) (+ y h))
(grc/rect->center rect)})) (gpt/point x (+ y h))
(grc/rect->center rect)})))
(defn- frame->snap-points (defn- frame->snap-points
[frame] [frame]

View File

@ -319,6 +319,12 @@
::message (delay ~message)}) ::message (delay ~message)})
nil))) nil)))
(defmacro log
[level & params]
`(do
(log! ::logger ~(str *ns*) ::level ~level ~@params)
nil))
(defmacro info (defmacro info
[& params] [& params]
`(do `(do

View File

@ -35,8 +35,18 @@
(def tags-to-remove #{:linearGradient :radialGradient :metadata :mask :clipPath :filter :title}) (def tags-to-remove #{:linearGradient :radialGradient :metadata :mask :clipPath :filter :title})
(defn- camelize
[s]
(when (string? s)
(let [vendor? (str/starts-with? s "-")
result #?(:cljs (js* "~{}.replace(\":\", \"-\").replace(/-./g, x=>x[1].toUpperCase())", s)
:clj (str/camel s))]
(if ^boolean vendor?
(str/capital result)
result))))
;; https://www.w3.org/TR/SVG11/eltindex.html ;; https://www.w3.org/TR/SVG11/eltindex.html
(def svg-tags-list (def svg-tags
#{:a #{:a
:altGlyph :altGlyph
:altGlyphDef :altGlyphDef
@ -118,7 +128,7 @@
:vkern}) :vkern})
;; https://www.w3.org/TR/SVG11/attindex.html ;; https://www.w3.org/TR/SVG11/attindex.html
(def svg-attr-list (def svg-attrs
#{:accent-height #{:accent-height
:accumulate :accumulate
:additive :additive
@ -212,26 +222,6 @@
:name :name
:numOctaves :numOctaves
:offset :offset
;; We don't support events
;;:onabort
;;:onactivate
;;:onbegin
;;:onclick
;;:onend
;;:onerror
;;:onfocusin
;;:onfocusout
;;:onload
;;:onmousedown
;;:onmousemove
;;:onmouseout
;;:onmouseover
;;:onmouseup
;;:onrepeat
;;:onresize
;;:onscroll
;;:onunload
;;:onzoom
:operator :operator
:order :order
:orient :orient
@ -336,7 +326,8 @@
:z :z
:zoomAndPan}) :zoomAndPan})
(def svg-present-list (def svg-presentation-attrs
"A set of presentation SVG attributes as per SVG spec."
#{:alignment-baseline #{:alignment-baseline
:baseline-shift :baseline-shift
:clip-path :clip-path
@ -399,52 +390,52 @@
:mask-type}) :mask-type})
(def inheritable-props (def inheritable-props
[:style #{:style
:clip-rule :clip-rule
:color :color
:color-interpolation :color-interpolation
:color-interpolation-filters :color-interpolation-filters
:color-profile :color-profile
:color-rendering :color-rendering
:cursor :cursor
:direction :direction
:dominant-baseline :dominant-baseline
:fill :fill
:fill-opacity :fill-opacity
:fill-rule :fill-rule
:font :font
:font-family :font-family
:font-size :font-size
:font-size-adjust :font-size-adjust
:font-stretch :font-stretch
:font-style :font-style
:font-variant :font-variant
:font-weight :font-weight
:glyph-orientation-horizontal :glyph-orientation-horizontal
:glyph-orientation-vertical :glyph-orientation-vertical
:image-rendering :image-rendering
:letter-spacing :letter-spacing
:marker :marker
:marker-end :marker-end
:marker-mid :marker-mid
:marker-start :marker-start
:paint-order :paint-order
:pointer-events :pointer-events
:shape-rendering :shape-rendering
:stroke :stroke
:stroke-dasharray :stroke-dasharray
:stroke-dashoffset :stroke-dashoffset
:stroke-linecap :stroke-linecap
:stroke-linejoin :stroke-linejoin
:stroke-miterlimit :stroke-miterlimit
:stroke-opacity :stroke-opacity
:stroke-width :stroke-width
:text-anchor :text-anchor
:text-rendering :text-rendering
:transform :transform
:visibility :visibility
:word-spacing :word-spacing
:writing-mode]) :writing-mode})
(def gradient-tags (def gradient-tags
#{:linearGradient #{:linearGradient
@ -517,9 +508,28 @@
:text :text
:view}) :view})
;; Props not supported by react we need to keep them lowercase (defn prop-key
(def non-react-props "Convert an attr key to a react compatible prop key. Returns nil if key is empty or invalid"
#{:mask-type}) [k]
(let [kn (cond
(string? k) k
(keyword? k) (name k))]
(case kn
("" nil) nil
"class" :className
"for" :htmlFor
(let [kn1 (subs kn 0 1)]
(if (= kn1 (str/upper kn1))
(-> kn camelize str/capital keyword)
(-> kn camelize keyword))))))
(def svg-props
"A set of all attrs (including the presentation) converted to
camelCase for make it React compatible."
(let [xf (map prop-key)]
(-> #{}
(into xf svg-attrs)
(into xf svg-presentation-attrs))))
;; Defaults for some tags per spec https://www.w3.org/TR/SVG11/single-page.html ;; Defaults for some tags per spec https://www.w3.org/TR/SVG11/single-page.html
;; they are basically the defaults that can be percents and we need to replace because ;; they are basically the defaults that can be percents and we need to replace because
@ -564,16 +574,6 @@
:else :else
num-str)) num-str))
(defn- camelize
[s]
(when (string? s)
(let [vendor? (str/starts-with? s "-")
result #?(:cljs (js* "~{}.replace(\":\", \"-\").replace(/-./g, x=>x[1].toUpperCase())", s)
:clj (str/camel s))]
(if ^boolean vendor?
(str/capital result)
result))))
(defn parse-style (defn parse-style
[style] [style]
(reduce (fn [res item] (reduce (fn [res item]
@ -604,15 +604,13 @@
([attrs whitelist?] ([attrs whitelist?]
(reduce-kv (fn [res k v] (reduce-kv (fn [res k v]
(if (or (not whitelist?) (let [k (prop-key k)]
(contains? svg-attr-list k)
(contains? svg-present-list k))
(cond (cond
(nil? v) (nil? k)
res res
(= k :class) (nil? v)
(assoc res :className v) res
(= k :style) (= k :style)
(let [v (if (string? v) (parse-style v) v) (let [v (if (string? v) (parse-style v) v)
@ -622,11 +620,10 @@
res)) res))
:else :else
(let [k (if (contains? non-react-props k) (if (or (not whitelist?) (contains? svg-props k))
k (let [v (if (string? v) (str/trim v) v)]
(-> k d/name camelize keyword))] (assoc res k v))
(assoc res k v))) res))))
res))
{} {}
attrs))) attrs)))
@ -674,7 +671,7 @@
(let [remove-node? (fn [{:keys [tag]}] (and (some? tag) (let [remove-node? (fn [{:keys [tag]}] (and (some? tag)
(or (contains? tags-to-remove tag) (or (contains? tags-to-remove tag)
(not (contains? svg-tags-list tag))))) (not (contains? svg-tags tag)))))
rec-result (->> (:content node) (map extract-defs)) rec-result (->> (:content node) (map extract-defs))
node (assoc node :content (->> rec-result (map second) (filterv (comp not remove-node?)))) node (assoc node :content (->> rec-result (map second) (filterv (comp not remove-node?))))

View File

@ -53,6 +53,9 @@
(def text-transform-attrs (def text-transform-attrs
[:text-transform]) [:text-transform])
(def text-fills
[:fills])
(def shape-attrs (def shape-attrs
[:grow-type]) [:grow-type])
@ -70,7 +73,8 @@
text-font-attrs text-font-attrs
text-spacing-attrs text-spacing-attrs
text-decoration-attrs text-decoration-attrs
text-transform-attrs)) text-transform-attrs
text-fills))
(def text-all-attrs (d/concat-set shape-attrs root-attrs paragraph-attrs text-node-attrs)) (def text-all-attrs (d/concat-set shape-attrs root-attrs paragraph-attrs text-node-attrs))
@ -240,6 +244,21 @@
(run! #(.appendCodePoint sb (int %)) (subvec cpoints start end)) (run! #(.appendCodePoint sb (int %)) (subvec cpoints start end))
(.toString sb)))) (.toString sb))))
(defn- fix-gradients
"Conversion from draft doesn't convert correctly the fills gradient types. This
function change the type from string to keyword of the gradient type"
[data]
(letfn [(fix-type [type]
(cond-> type
(string? type) keyword))
(update-fill [fill]
(d/update-in-when fill [:fill-color-gradient :type] fix-type))
(update-all-fills [fills]
(mapv update-fill fills))]
(d/update-when data :fills update-all-fills)))
(defn convert-from-draft (defn convert-from-draft
[content] [content]
(letfn [(extract-text [cpoints part] (letfn [(extract-text [cpoints part]
@ -247,7 +266,9 @@
end (inc (first (last part))) end (inc (first (last part)))
text (code-points->text cpoints start end) text (code-points->text cpoints start end)
attrs (second (first part))] attrs (second (first part))]
(assoc attrs :text text))) (-> attrs
(fix-gradients)
(assoc :text text))))
(split-texts [text styles] (split-texts [text styles]
(let [cpoints (text->code-points text) (let [cpoints (text->code-points text)
@ -264,7 +285,8 @@
(let [key (get block :key) (let [key (get block :key)
text (get block :text) text (get block :text)
styles (get block :inlineStyleRanges) styles (get block :inlineStyleRanges)
data (get block :data)] data (->> (get block :data)
fix-gradients)]
(-> data (-> data
(assoc :key key) (assoc :key key)
(assoc :type "paragraph") (assoc :type "paragraph")

View File

@ -756,6 +756,9 @@
(ctl/flex-layout? shape) (ctl/flex-layout? shape)
(ctl/update-flex-scale value) (ctl/update-flex-scale value)
(ctl/grid-layout? shape)
(ctl/update-grid-scale value)
:always :always
(ctl/update-flex-child value)))] (ctl/update-flex-child value)))]

View File

@ -601,6 +601,16 @@
(d/update-in-when [:layout-padding :p3] * scale) (d/update-in-when [:layout-padding :p3] * scale)
(d/update-in-when [:layout-padding :p4] * scale))) (d/update-in-when [:layout-padding :p4] * scale)))
(defn update-grid-scale
[shape scale]
(letfn [(scale-track [track]
(cond-> track
(= (:type track) :fixed)
(update :value * scale)))]
(-> shape
(update :layout-grid-columns #(mapv scale-track %))
(update :layout-grid-rows #(mapv scale-track %)))))
(defn update-flex-child (defn update-flex-child
[shape scale] [shape scale]
(-> shape (-> shape

View File

@ -400,9 +400,21 @@
:parent-id parent-id :parent-id parent-id
:frame-id frame-id) :frame-id frame-id)
:always
;; Store in the meta the old id so we can do a remap afterwards
;; in the parent
(with-meta {::old-id (:id shape)})
(some? (:shapes shape)) (some? (:shapes shape))
(assoc :shapes (mapv :id new-direct-children))) (assoc :shapes (mapv :id new-direct-children)))
;; For a GRID layout remap the cells shapes' old-id to the new id given in the clone
new-shape
(if (ctl/grid-layout? new-shape)
(let [ids-map (into {} (map #(vector (-> % meta ::old-id) (:id %))) new-children)]
(ctl/remap-grid-cells new-shape ids-map))
new-shape)
new-shape (update-new-shape new-shape shape) new-shape (update-new-shape new-shape shape)
new-shapes (into [new-shape] new-children) new-shapes (into [new-shape] new-children)

View File

@ -29,7 +29,8 @@
} }
::selection { ::selection {
background-color: var(--color-accent-primary-muted); background: var(--text-editor-selection-background-color);
color: var(--text-editor-selection-foreground-color);
} }
} }

View File

@ -27,6 +27,7 @@
--da-secondary: #bb97d8; --da-secondary: #bb97d8;
--da-tertiary: #00d1b8; --da-tertiary: #00d1b8;
--da-tertiary-10: #{color.change(#00d1b8, $alpha: 0.1)}; --da-tertiary-10: #{color.change(#00d1b8, $alpha: 0.1)};
--da-tertiary-70: #{color.change(#00d1b8, $alpha: 0.7)};
--da-quaternary: #ff6fe0; --da-quaternary: #ff6fe0;
// LIGHT // LIGHT
@ -50,6 +51,7 @@
--la-secondary: #1345aa; --la-secondary: #1345aa;
--la-tertiary: #8c33eb; --la-tertiary: #8c33eb;
--la-tertiary-10: #{color.change(#8c33eb, $alpha: 0.1)}; --la-tertiary-10: #{color.change(#8c33eb, $alpha: 0.1)};
--la-tertiary-70: #{color.change(#8c33eb, $alpha: 0.7)};
--la-quaternary: #ff6fe0; --la-quaternary: #ff6fe0;
// STATUS COLOR // STATUS COLOR

View File

@ -357,6 +357,14 @@
--viewer-thumbnails-control-foreground-color: var(--color-foreground-secondary); --viewer-thumbnails-control-foreground-color: var(--color-foreground-secondary);
--viewer-thumbnail-border-color: var(--color-accent-primary); --viewer-thumbnail-border-color: var(--color-accent-primary);
--viewer-thumbnail-background-color-selected: var(--color-accent-primary-muted); --viewer-thumbnail-background-color-selected: var(--color-accent-primary-muted);
// TEXT SELECTION
--text-editor-selection-background-color: var(--da-tertiary-70);
--text-editor-selection-foreground-color: var(--app-white);
// NEW TEAM BUTTON
// TODO: we should not put these functional tokens here, but rather in the components they belong to
--new-team-button-background-color: var(--color-background-primary);
} }
#app { #app {
@ -383,4 +391,6 @@
--assets-item-name-background-color: var(--color-background-primary); --assets-item-name-background-color: var(--color-background-primary);
--assets-item-name-foreground-color: var(--color-foreground-primary); --assets-item-name-foreground-color: var(--color-foreground-primary);
--text-editor-selection-background-color: var(--la-tertiary-70);
} }

View File

@ -10,6 +10,7 @@
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.data.workspace.persistence :as dwp] [app.main.data.workspace.persistence :as dwp]
[app.main.data.workspace.state-helpers :as wsh] [app.main.data.workspace.state-helpers :as wsh]
[app.main.refs :as refs]
[app.main.repo :as rp] [app.main.repo :as rp]
[app.main.store :as st] [app.main.store :as st]
[app.util.dom :as dom] [app.util.dom :as dom]
@ -166,6 +167,13 @@
:wait true}] :wait true}]
(rx/concat (rx/concat
(rx/of ::dwp/force-persist) (rx/of ::dwp/force-persist)
;; Wait the persist to be succesfull
(->> (rx/from-atom refs/persistence-state {:emit-current-value? true})
(rx/filter #(or (nil? %) (= :saved %)))
(rx/first)
(rx/timeout 400 (rx/empty)))
(->> (rp/cmd! :export params) (->> (rp/cmd! :export params)
(rx/mapcat (fn [{:keys [id filename]}] (rx/mapcat (fn [{:keys [id filename]}]
(->> (rp/cmd! :export {:cmd :get-resource :blob? true :id id}) (->> (rp/cmd! :export {:cmd :get-resource :blob? true :id id})

View File

@ -1013,20 +1013,21 @@
(d/ordered-set)))] (d/ordered-set)))]
(rx/of (dws/select-shapes shapes-to-select))) (rx/of (dws/select-shapes shapes-to-select)))
(let [{:keys [id type shapes]} (get objects (first selected))] (when (d/not-empty? selected)
(case type (let [{:keys [id type shapes]} (get objects (first selected))]
:text (case type
(rx/of (dwe/start-edition-mode id)) :text
(rx/of (dwe/start-edition-mode id))
(:group :bool :frame) (:group :bool :frame)
(let [shapes-ids (into (d/ordered-set) shapes)] (let [shapes-ids (into (d/ordered-set) shapes)]
(rx/of (dws/select-shapes shapes-ids))) (rx/of (dws/select-shapes shapes-ids)))
:svg-raw :svg-raw
nil nil
(rx/of (dwe/start-edition-mode id) (rx/of (dwe/start-edition-mode id)
(dwdp/start-path-edit id))))))))) (dwdp/start-path-edit id))))))))))
(defn select-parent-layer (defn select-parent-layer
[] []

View File

@ -81,7 +81,8 @@
:page-id page-id :page-id page-id
:rect (:selrect shape) :rect (:selrect shape)
:include-frames? true :include-frames? true
:full-frame? true}) :full-frame? true
:using-selrect? true})
(rx/map #(cfh/clean-loops objects %)) (rx/map #(cfh/clean-loops objects %))
(rx/map #(dwsh/move-shapes-into-frame (:id shape) %))) (rx/map #(dwsh/move-shapes-into-frame (:id shape) %)))
(rx/of (dwu/commit-undo-transaction (:id shape)))) (rx/of (dwu/commit-undo-transaction (:id shape))))

View File

@ -36,6 +36,7 @@
[app.main.data.workspace.specialized-panel :as dwsp] [app.main.data.workspace.specialized-panel :as dwsp]
[app.main.data.workspace.state-helpers :as wsh] [app.main.data.workspace.state-helpers :as wsh]
[app.main.data.workspace.thumbnails :as dwt] [app.main.data.workspace.thumbnails :as dwt]
[app.main.data.workspace.transforms :as dwtr]
[app.main.data.workspace.undo :as dwu] [app.main.data.workspace.undo :as dwu]
[app.main.features :as features] [app.main.features :as features]
[app.main.features.pointer-map :as fpmap] [app.main.features.pointer-map :as fpmap]
@ -534,34 +535,38 @@
(defn instantiate-component (defn instantiate-component
"Create a new shape in the current page, from the component with the given id "Create a new shape in the current page, from the component with the given id
in the given file library. Then selects the newly created instance." in the given file library. Then selects the newly created instance."
[file-id component-id position] ([file-id component-id position]
(dm/assert! (uuid? file-id)) (instantiate-component file-id component-id position nil))
(dm/assert! (uuid? component-id)) ([file-id component-id position {:keys [start-move? initial-point]}]
(dm/assert! (gpt/point? position)) (dm/assert! (uuid? file-id))
(ptk/reify ::instantiate-component (dm/assert! (uuid? component-id))
ptk/WatchEvent (dm/assert! (gpt/point? position))
(watch [it state _] (ptk/reify ::instantiate-component
(let [page (wsh/lookup-page state) ptk/WatchEvent
libraries (wsh/get-libraries state) (watch [it state _]
(let [page (wsh/lookup-page state)
libraries (wsh/get-libraries state)
objects (:objects page) objects (:objects page)
changes (-> (pcb/empty-changes it (:id page)) changes (-> (pcb/empty-changes it (:id page))
(pcb/with-objects objects)) (pcb/with-objects objects))
[new-shape changes] [new-shape changes]
(dwlh/generate-instantiate-component changes (dwlh/generate-instantiate-component changes
objects objects
file-id file-id
component-id component-id
position position
page page
libraries) libraries)
undo-id (js/Symbol)] undo-id (js/Symbol)]
(rx/of (dwu/start-undo-transaction undo-id) (rx/of (dwu/start-undo-transaction undo-id)
(dch/commit-changes changes) (dch/commit-changes changes)
(ptk/data-event :layout/update [(:id new-shape)]) (ptk/data-event :layout/update [(:id new-shape)])
(dws/select-shapes (d/ordered-set (:id new-shape))) (dws/select-shapes (d/ordered-set (:id new-shape)))
(dwu/commit-undo-transaction undo-id)))))) (when start-move?
(dwtr/start-move initial-point #{(:id new-shape)}))
(dwu/commit-undo-transaction undo-id)))))))
(defn detach-component (defn detach-component
"Remove all references to components in the shape with the given id, "Remove all references to components in the shape with the given id,

View File

@ -159,7 +159,9 @@
component component
(:data library) (:data library)
position position
components-v2) components-v2
;; The position can generate a frame calculation inside the base component so we force the frame-id
{:force-frame-id frame-id})
first-shape (cond-> (first new-shapes) first-shape (cond-> (first new-shapes)
(not (nil? parent-id)) (not (nil? parent-id))

View File

@ -83,6 +83,7 @@
;; position changes. ;; position changes.
(->> stream (->> stream
(rx/filter mse/pointer-event?) (rx/filter mse/pointer-event?)
(rx/filter #(= :viewport (mse/get-pointer-source %)))
(rx/pipe (rxs/throttle 100)) (rx/pipe (rxs/throttle 100))
(rx/map #(handle-pointer-send file-id (:pt %))))) (rx/map #(handle-pointer-send file-id (:pt %)))))

View File

@ -496,7 +496,7 @@
(when-let [node (dom/get-element-by-class "ghost-outline")] (when-let [node (dom/get-element-by-class "ghost-outline")]
(dom/set-property! node "transform" (gmt/translate-matrix move-vector)))))) (dom/set-property! node "transform" (gmt/translate-matrix move-vector))))))
(defn- start-move (defn start-move
([from-position] (start-move from-position nil)) ([from-position] (start-move from-position nil))
([from-position ids] ([from-position ids]
(ptk/reify ::start-move (ptk/reify ::start-move

View File

@ -591,3 +591,6 @@
(def updating-library (def updating-library
(l/derived :updating-library st/state)) (l/derived :updating-library st/state))
(def persistence-state
(l/derived (comp :status :workspace-persistence) st/state))

View File

@ -57,6 +57,7 @@
width: 100%; width: 100%;
height: fit-content; height: fit-content;
text-align: left; text-align: left;
border: 1px solid transparent;
.icon-btn { .icon-btn {
position: absolute; position: absolute;
display: flex; display: flex;

View File

@ -21,6 +21,7 @@
on-clear (unchecked-get props "clear-action") on-clear (unchecked-get props "clear-action")
placeholder (unchecked-get props "placeholder") placeholder (unchecked-get props "placeholder")
icon (unchecked-get props "icon") icon (unchecked-get props "icon")
autofocus (unchecked-get props "auto-focus")
handle-change handle-change
(mf/use-fn (mf/use-fn
@ -52,6 +53,7 @@
icon icon
[:input {:on-change handle-change [:input {:on-change handle-change
:value value :value value
:auto-focus autofocus
:placeholder placeholder :placeholder placeholder
:on-key-down handle-key-down}] :on-key-down handle-key-down}]
(when (not= "" value) (when (not= "" value)

View File

@ -30,6 +30,7 @@
background-color: var(--input-background-color); background-color: var(--input-background-color);
font-size: $fs-12; font-size: $fs-12;
color: var(--input-foreground-color); color: var(--input-foreground-color);
border-radius: $br-8;
&:focus { &:focus {
outline: none; outline: none;
} }

View File

@ -12,15 +12,21 @@
(:require (:require
[app.main.ui.icons :as i] [app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.object :as obj]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(def pin-icon (icon-xref :pin-refactor (stl/css :icon))) (def ^:private pin-icon
(icon-xref :pin-refactor (stl/css :icon)))
(mf/defc pin-button* (mf/defc pin-button*
{::mf/props :obj} {::mf/props :obj}
[{:keys [aria-label is-pinned class] :as props}] [{:keys [aria-label is-pinned class] :as props}]
(let [aria-label (or aria-label (tr "dashboard.pin-unpin")) (let [aria-label (or aria-label (tr "dashboard.pin-unpin"))
class (dm/str (or class "") " " (stl/css-case :button true :button-active is-pinned)) class (dm/str (or class "") " " (stl/css-case :button true :button-active is-pinned))
props (mf/spread-props props {:class class
:aria-label aria-label})] props (-> (obj/clone props)
(obj/unset! "isPinned")
(obj/set! "className" class)
(obj/set! "aria-label" aria-label))]
[:> "button" props pin-icon])) [:> "button" props pin-icon]))

View File

@ -232,13 +232,10 @@
width: $s-168; width: $s-168;
} }
.new-team {
background-color: $db-quaternary;
}
&.action { &.action {
.team-icon { .team-icon {
background-color: #2e3434; background-color: #2e3434;
background-color: var(--new-team-button-background-color);
border-radius: 50%; border-radius: 50%;
height: $s-24; height: $s-24;
margin-right: $s-12; margin-right: $s-12;

View File

@ -121,9 +121,9 @@
pill-height (/ fcc/flex-display-pill-height zoom) pill-height (/ fcc/flex-display-pill-height zoom)
workspace-modifiers (mf/deref refs/workspace-modifiers) workspace-modifiers (mf/deref refs/workspace-modifiers)
gap-selected (mf/deref refs/workspace-gap-selected) gap-selected (mf/deref refs/workspace-gap-selected)
hover (mf/use-var nil) hover (mf/use-state nil)
hover-value (mf/use-var 0) hover-value (mf/use-state 0)
mouse-pos (mf/use-var nil) mouse-pos (mf/use-state nil)
padding (:layout-padding frame) padding (:layout-padding frame)
gap (:layout-gap frame) gap (:layout-gap frame)
{:keys [width height x1 y1]} (:selrect frame) {:keys [width height x1 y1]} (:selrect frame)

View File

@ -89,9 +89,9 @@
pill-width (/ fcc/flex-display-pill-width zoom) pill-width (/ fcc/flex-display-pill-width zoom)
pill-height (/ fcc/flex-display-pill-height zoom) pill-height (/ fcc/flex-display-pill-height zoom)
margins-selected (mf/deref refs/workspace-margins-selected) margins-selected (mf/deref refs/workspace-margins-selected)
hover-value (mf/use-var 0) hover-value (mf/use-state 0)
mouse-pos (mf/use-var nil) mouse-pos (mf/use-state nil)
hover (mf/use-var nil) hover (mf/use-state nil)
hover-all? (and (not (nil? @hover)) alt?) hover-all? (and (not (nil? @hover)) alt?)
hover-v? (and (or (= @hover :m1) (= @hover :m3)) shift?) hover-v? (and (or (= @hover :m1) (= @hover :m3)) shift?)
hover-h? (and (or (= @hover :m2) (= @hover :m4)) shift?) hover-h? (and (or (= @hover :m2) (= @hover :m4)) shift?)

View File

@ -77,7 +77,6 @@
:y (:y rect-data) :y (:y rect-data)
:width (max 0 (:width rect-data)) :width (max 0 (:width rect-data))
:height (max 0 (:height rect-data)) :height (max 0 (:height rect-data))
:on-pointer-enter on-pointer-enter :on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave :on-pointer-leave on-pointer-leave
:on-pointer-move on-pointer-move :on-pointer-move on-pointer-move
@ -115,9 +114,9 @@
[{:keys [frame zoom alt? shift? on-move-selected on-context-menu]}] [{:keys [frame zoom alt? shift? on-move-selected on-context-menu]}]
(let [frame-id (:id frame) (let [frame-id (:id frame)
paddings-selected (mf/deref refs/workspace-paddings-selected) paddings-selected (mf/deref refs/workspace-paddings-selected)
hover-value (mf/use-var 0) hover-value (mf/use-state 0)
mouse-pos (mf/use-var nil) mouse-pos (mf/use-state nil)
hover (mf/use-var nil) hover (mf/use-state nil)
hover-all? (and (not (nil? @hover)) alt?) hover-all? (and (not (nil? @hover)) alt?)
hover-v? (and (or (= @hover :p1) (= @hover :p3)) shift?) hover-v? (and (or (= @hover :p1) (= @hover :p3)) shift?)
hover-h? (and (or (= @hover :p2) (= @hover :p4)) shift?) hover-h? (and (or (= @hover :p2) (= @hover :p4)) shift?)

View File

@ -50,13 +50,6 @@
(fn [] (fn []
(st/emit! (dsc/pop-shortcuts key)))))) (st/emit! (dsc/pop-shortcuts key))))))
(defn invisible-image
[]
(let [img (js/Image.)
imd "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="]
(set! (.-src img) imd)
img))
(defn- set-timer (defn- set-timer
[state ms func] [state ms func]
(assoc state :timer (ts/schedule ms func))) (assoc state :timer (ts/schedule ms func)))
@ -128,7 +121,7 @@
(do (do
(dom/stop-propagation event) (dom/stop-propagation event)
(dnd/set-data! event data-type data) (dnd/set-data! event data-type data)
(dnd/set-drag-image! event (invisible-image)) (dnd/set-drag-image! event (dnd/invisible-image))
(dnd/set-allowed-effect! event "move") (dnd/set-allowed-effect! event "move")
(when (fn? on-drag) (when (fn? on-drag)
(on-drag data))))) (on-drag data)))))

View File

@ -8,7 +8,10 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.geom.shapes.text :as gst]
[app.config :as cf] [app.config :as cf]
[app.main.ui.shapes.attrs :as attrs] [app.main.ui.shapes.attrs :as attrs]
[app.main.ui.shapes.gradients :as grad] [app.main.ui.shapes.gradients :as grad]
@ -28,7 +31,12 @@
fills (get shape :fills []) fills (get shape :fills [])
selrect (dm/get-prop shape :selrect) selrect (dm/get-prop shape :selrect)
bounds (when (cfh/text-shape? shape)
(gst/shape->rect shape))
metadata (get shape :metadata) metadata (get shape :metadata)
x (dm/get-prop selrect :x) x (dm/get-prop selrect :x)
y (dm/get-prop selrect :y) y (dm/get-prop selrect :y)
width (dm/get-prop selrect :width) width (dm/get-prop selrect :width)
@ -62,32 +70,50 @@
(obj/set! pat-props "patternTransform" transform) (obj/set! pat-props "patternTransform" transform)
pat-props)] pat-props)]
(for [[shape-index shape] (d/enumerate (or (:position-data shape) [shape]))] (for [[obj-index obj] (d/enumerate (or (:position-data shape) [shape]))]
[:* {:key (dm/str shape-index)} [:* {:key (dm/str obj-index)}
(for [[fill-index value] (reverse (d/enumerate (get shape :fills [])))] (for [[fill-index value] (reverse (d/enumerate (get obj :fills [])))]
(when (some? (:fill-color-gradient value)) (when (some? (:fill-color-gradient value))
(let [gradient (:fill-color-gradient value) (let [gradient (:fill-color-gradient value)
from-p (-> (gpt/point (+ x (* width (:start-x gradient)))
(+ y (* height (:start-y gradient)))))
to-p (-> (gpt/point (+ x (* width (:end-x gradient)))
(+ y (* height (:end-y gradient)))))
gradient
(cond-> gradient
(some? bounds)
(assoc
:start-x (/ (- (:x from-p) (:x bounds)) (:width bounds))
:start-y (/ (- (:y from-p) (:y bounds)) (:height bounds))
:end-x (/ (- (:x to-p) (:x bounds)) (:width bounds))
:end-y (/ (- (:y to-p) (:y bounds)) (:height bounds))))
props #js {:id (dm/str "fill-color-gradient-" render-id "-" fill-index) props #js {:id (dm/str "fill-color-gradient-" render-id "-" fill-index)
:key (dm/str fill-index) :key (dm/str fill-index)
:gradient gradient :gradient gradient
:shape shape}] :shape obj}]
(case (:type gradient) (case (d/name (:type gradient))
:linear [:> grad/linear-gradient props] "linear" [:> grad/linear-gradient props]
:radial [:> grad/radial-gradient props])))) "radial" [:> grad/radial-gradient props]))))
(let [fill-id (dm/str "fill-" shape-index "-" render-id)] (let [fill-id (dm/str "fill-" obj-index "-" render-id)]
[:> :pattern (-> (obj/clone pat-props) [:> :pattern (-> (obj/clone pat-props)
(obj/set! "id" fill-id) (obj/set! "id" fill-id)
(cond-> has-image? (cond-> (and has-image? (nil? bounds))
(-> (obj/set! "width" (* width no-repeat-padding)) (-> (obj/set! "width" (* width no-repeat-padding))
(obj/set! "height" (* height no-repeat-padding))))) (obj/set! "height" (* height no-repeat-padding))))
(cond-> (some? bounds)
(-> (obj/set! "width" (:width bounds))
(obj/set! "height" (:height bounds)))))
[:g [:g
(for [[fill-index value] (reverse (d/enumerate (get shape :fills [])))] (for [[fill-index value] (reverse (d/enumerate (get obj :fills [])))]
(let [style (attrs/get-fill-style value fill-index render-id type) (let [style (attrs/get-fill-style value fill-index render-id type)
props #js {:key (dm/str fill-index) props #js {:key (dm/str fill-index)
:width width :width (d/nilv (:width bounds) width)
:height height :height (d/nilv (:height bounds) height)
:style style}] :style style}]
(if (:fill-image value) (if (:fill-image value)
(let [uri (cf/resolve-file-media (:fill-image value)) (let [uri (cf/resolve-file-media (:fill-image value))

View File

@ -104,7 +104,7 @@
svg-root? (and (map? content) (= tag :svg)) svg-root? (and (map? content) (= tag :svg))
svg-tag? (map? content) svg-tag? (map? content)
svg-leaf? (string? content) svg-leaf? (string? content)
valid-tag? (contains? csvg/svg-tags-list tag)] valid-tag? (contains? csvg/svg-tags tag)]
(cond (cond
^boolean svg-root? ^boolean svg-root?

View File

@ -93,7 +93,7 @@
:textTransform text-transform :textTransform text-transform
:color (if (and show-text? (not gradient?)) text-color "transparent") :color (if (and show-text? (not gradient?)) text-color "transparent")
:background (when (and show-text? gradient?) text-color) :background (when (and show-text? gradient?) text-color)
:caretColor (or text-color "black") :caretColor (if (and (not gradient?) text-color) text-color "black")
:overflowWrap "initial" :overflowWrap "initial"
:lineBreak "auto" :lineBreak "auto"
:whiteSpace "break-spaces" :whiteSpace "break-spaces"

View File

@ -79,11 +79,9 @@
[:span {:class (stl/css-case :color-value-wrapper true [:span {:class (stl/css-case :color-value-wrapper true
:gradient-name (:gradient color))} :gradient-name (:gradient color))}
(if (:gradient color) (if (:gradient color)
[:& cbn/color-name {:color color [:& cbn/color-name {:color color :size 80}]
:size 80}]
(case format (case format
:hex [:& cbn/color-name {:color color :hex [:& cbn/color-name {:color color}]
:size 80}]
:rgba (let [[r g b a] (cc/hex->rgba (:color color) (:opacity color))] :rgba (let [[r g b a] (cc/hex->rgba (:color color) (:opacity color))]
[:* (str/fmt "%s, %s, %s, %s" r g b a)]) [:* (str/fmt "%s, %s, %s, %s" r g b a)])
:hsla (let [[h s l a] (cc/hex->hsla (:color color) (:opacity color)) :hsla (let [[h s l a] (cc/hex->hsla (:color color) (:opacity color))
@ -105,8 +103,7 @@
[:span {:class (stl/css-case :color-value-wrapper true [:span {:class (stl/css-case :color-value-wrapper true
:gradient-name (:gradient color))} :gradient-name (:gradient color))}
(if (:gradient color) (if (:gradient color)
[:& cbn/color-name {:color color [:& cbn/color-name {:color color}]
:size 80}]
(case format (case format
:hex [:& cbn/color-name {:color color :hex [:& cbn/color-name {:color color
:size 80}] :size 80}]

View File

@ -58,6 +58,11 @@
} }
} }
.name-opacity {
display: flex;
align-items: baseline;
}
.color-name-wrapper { .color-name-wrapper {
@include titleTipography; @include titleTipography;
@include flexColumn; @include flexColumn;
@ -113,8 +118,11 @@
.color-value-wrapper { .color-value-wrapper {
@include inspectValue; @include inspectValue;
text-transform: uppercase; text-transform: uppercase;
max-width: $s-124; max-width: $s-80;
padding-right: $s-8; padding-right: $s-8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.gradient-name { &.gradient-name {
text-transform: none; text-transform: none;
} }

View File

@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl]) (:require-macros [app.main.style :as stl])
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm]
[app.main.ui.components.copy-button :refer [copy-button]] [app.main.ui.components.copy-button :refer [copy-button]]
[app.main.ui.components.title-bar :refer [title-bar]] [app.main.ui.components.title-bar :refer [title-bar]]
[app.util.code-gen.style-css :as css] [app.util.code-gen.style-css :as css]
@ -19,9 +20,10 @@
(mf/defc geometry-block (mf/defc geometry-block
[{:keys [objects shape]}] [{:keys [objects shape]}]
[:* [:*
(for [property properties] (for [[idx property] (d/enumerate properties)]
(when-let [value (css/get-css-value objects shape property)] (when-let [value (css/get-css-value objects shape property)]
[:div {:class (stl/css :geometry-row)} [:div {:key (dm/str "block-" idx "-" (d/name property))
:class (stl/css :geometry-row)}
[:div {:class (stl/css :global/attr-label)} (d/name property)] [:div {:class (stl/css :global/attr-label)} (d/name property)]
[:div {:class (stl/css :global/attr-value)} [:div {:class (stl/css :global/attr-value)}
[:& copy-button {:data (css/get-css-property objects shape property)} [:& copy-button {:data (css/get-css-property objects shape property)}

View File

@ -30,7 +30,6 @@
} }
.attributes-content-row { .attributes-content-row {
width: $s-252;
max-width: $s-252; max-width: $s-252;
min-height: calc($s-2 + $s-32); min-height: calc($s-2 + $s-32);
border-radius: $br-8; border-radius: $br-8;
@ -39,7 +38,7 @@
.content { .content {
@include titleTipography; @include titleTipography;
width: 100%; width: 100%;
padding: 0; padding: $s-4 0;
color: var(--color-foreground-secondary); color: var(--color-foreground-secondary);
} }

View File

@ -8,727 +8,28 @@
(:require-macros [app.main.style :as stl]) (:require-macros [app.main.style :as stl])
(:require (:require
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.events :as ev]
[app.main.data.exports :as de]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.data.shortcuts :as scd]
[app.main.data.workspace :as dw] [app.main.data.workspace :as dw]
[app.main.data.workspace.colors :as dc] [app.main.data.workspace.colors :as dc]
[app.main.data.workspace.common :as dwc]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.shortcuts :as sc]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu dropdown-menu-item*]]
[app.main.ui.context :as ctx] [app.main.ui.context :as ctx]
[app.main.ui.hooks.resize :as r]
[app.main.ui.icons :as i] [app.main.ui.icons :as i]
[app.main.ui.workspace.main-menu :as main-menu]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd] [app.util.keyboard :as kbd]
[app.util.router :as rt] [app.util.router :as rt]
[cuerdas.core :as str] [cuerdas.core :as str]
[potok.v2.core :as ptk]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
;; --- Header menu and submenus
(mf/defc help-info-menu
{::mf/wrap-props false
::mf/wrap [mf/memo]}
[{:keys [layout on-close]}]
(let [nav-to-helpc-center
(mf/use-fn #(dom/open-new-window "https://help.penpot.app"))
nav-to-community
(mf/use-fn #(dom/open-new-window "https://community.penpot.app"))
nav-to-youtube
(mf/use-fn #(dom/open-new-window "https://www.youtube.com/c/Penpot"))
nav-to-templates
(mf/use-fn #(dom/open-new-window "https://penpot.app/libraries-templates"))
nav-to-github
(mf/use-fn #(dom/open-new-window "https://github.com/penpot/penpot"))
nav-to-terms
(mf/use-fn #(dom/open-new-window "https://penpot.app/terms"))
nav-to-feedback
(mf/use-fn #(st/emit! (rt/nav-new-window* {:rname :settings-feedback})))
show-shortcuts
(mf/use-fn
(mf/deps layout)
(fn []
(when (contains? layout :collapse-left-sidebar)
(st/emit! (dw/toggle-layout-flag :collapse-left-sidebar)))
(st/emit!
(-> (dw/toggle-layout-flag :shortcuts)
(vary-meta assoc ::ev/origin "workspace-header")))))
show-release-notes
(mf/use-fn
(fn [event]
(let [version (:main cf/version)]
(st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version}))
(if (and (kbd/alt? event) (kbd/mod? event))
(st/emit! (modal/show {:type :onboarding}))
(st/emit! (modal/show {:type :release-notes :version version}))))))]
[:& dropdown-menu {:show true
:on-close on-close
:list-class (stl/css-case :sub-menu true
:help-info true)}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-helpc-center
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-helpc-center event)))
:id "file-menu-help-center"}
[:span {:class (stl/css :item-name)} (tr "labels.help-center")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-community
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-community event)))
:id "file-menu-community"}
[:span {:class (stl/css :item-name)} (tr "labels.community")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-youtube
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-youtube event)))
:id "file-menu-youtube"}
[:span {:class (stl/css :item-name)} (tr "labels.tutorials")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click show-release-notes
:on-key-down (fn [event]
(when (kbd/enter? event)
(show-release-notes event)))
:id "file-menu-release-notes"}
[:span {:class (stl/css :item-name)} (tr "labels.release-notes")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-templates
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-templates event)))
:id "file-menu-templates"}
[:span {:class (stl/css :item-name)} (tr "labels.libraries-and-templates")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-github
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-github event)))
:id "file-menu-github"}
[:span {:class (stl/css :item-name)} (tr "labels.github-repo")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-terms
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-terms event)))
:id "file-menu-terms"}
[:span {:class (stl/css :item-name)} (tr "auth.terms-of-service")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click show-shortcuts
:on-key-down (fn [event]
(when (kbd/enter? event)
(show-shortcuts event)))
:id "file-menu-shortcuts"}
[:span {:class (stl/css :item-name)} (tr "label.shortcuts")]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :show-shortcuts))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
(when (contains? cf/flags :user-feedback)
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-feedback
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-feedback event)))
:id "file-menu-feedback"}
[:span {:class (stl/css-case :feedback true
:item-name true)} (tr "labels.give-feedback")]])]))
(mf/defc preferences-menu
{::mf/wrap-props false
::mf/wrap [mf/memo]}
[{:keys [layout toggle-flag on-close]}]
(let [show-nudge-options (mf/use-fn #(modal/show! {:type :nudge-option}))]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true
:preferences true)
:on-close on-close}
[:> dropdown-menu-item* {:on-click toggle-flag
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "scale-text"
:id "file-menu-scale-text"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :scale-text)
(tr "workspace.header.menu.disable-scale-content")
(tr "workspace.header.menu.enable-scale-content"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-scale-text))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:on-click toggle-flag
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "snap-guides"
:id "file-menu-snap-guides"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :snap-guides)
(tr "workspace.header.menu.disable-snap-guides")
(tr "workspace.header.menu.enable-snap-guides"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-snap-guide))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:on-click toggle-flag
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "snap-grid"
:id "file-menu-snap-grid"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :snap-grid)
(tr "workspace.header.menu.disable-snap-grid")
(tr "workspace.header.menu.enable-snap-grid"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-snap-grid))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:on-click toggle-flag
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "dynamic-alignment"
:id "file-menu-dynamic-alignment"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :dynamic-alignment)
(tr "workspace.header.menu.disable-dynamic-alignment")
(tr "workspace.header.menu.enable-dynamic-alignment"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-alignment))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:on-click toggle-flag
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "snap-pixel-grid"
:id "file-menu-pixel-grid"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :snap-pixel-grid)
(tr "workspace.header.menu.disable-snap-pixel-grid")
(tr "workspace.header.menu.enable-snap-pixel-grid"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :snap-pixel-grid))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:on-click show-nudge-options
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
(show-nudge-options event)))
:data-test "snap-pixel-grid"
:id "file-menu-nudge"}
[:span {:class (stl/css :item-name)} (tr "modals.nudge-title")]]]))
(mf/defc view-menu
{::mf/wrap-props false
::mf/wrap [mf/memo]}
[{:keys [layout toggle-flag on-close]}]
(let [read-only? (mf/use-ctx ctx/workspace-read-only?)
toggle-color-palette
(mf/use-fn
(fn []
(r/set-resize-type! :bottom)
(st/emit! (dw/remove-layout-flag :textpalette)
(-> (dw/toggle-layout-flag :colorpalette)
(vary-meta assoc ::ev/origin "workspace-menu")))))
toggle-text-palette
(mf/use-fn
(fn []
(r/set-resize-type! :bottom)
(st/emit! (dw/remove-layout-flag :colorpalette)
(-> (dw/toggle-layout-flag :textpalette)
(vary-meta assoc ::ev/origin "workspace-menu")))))]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true
:view true)
:on-close on-close}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-flag
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "rules"
:id "file-menu-rules"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :rules)
(tr "workspace.header.menu.hide-rules")
(tr "workspace.header.menu.show-rules"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-rules))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-flag
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "display-grid"
:id "file-menu-grid"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :display-grid)
(tr "workspace.header.menu.hide-grid")
(tr "workspace.header.menu.show-grid"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-grid))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
(when-not ^boolean read-only?
[:*
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-color-palette
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-color-palette event)))
:id "file-menu-color-palette"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :colorpalette)
(tr "workspace.header.menu.hide-palette")
(tr "workspace.header.menu.show-palette"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-colorpalette))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-text-palette
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-text-palette event)))
:id "file-menu-text-palette"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :textpalette)
(tr "workspace.header.menu.hide-textpalette")
(tr "workspace.header.menu.show-textpalette"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-textpalette))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]])
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-flag
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "display-artboard-names"
:id "file-menu-artboards"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :display-artboard-names)
(tr "workspace.header.menu.hide-artboard-names")
(tr "workspace.header.menu.show-artboard-names"))]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-flag
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "show-pixel-grid"
:id "file-menu-pixel-grid"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :show-pixel-grid)
(tr "workspace.header.menu.hide-pixel-grid")
(tr "workspace.header.menu.show-pixel-grid"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :show-pixel-grid))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-flag
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "hide-ui"
:id "file-menu-hide-ui"}
[:span {:class (stl/css :item-name)}
(tr "workspace.shape.menu.hide-ui")]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :hide-ui))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]]))
(mf/defc edit-menu
{::mf/wrap-props false
::mf/wrap [mf/memo]}
[{:keys [on-close]}]
(let [select-all (mf/use-fn #(st/emit! (dw/select-all)))
undo (mf/use-fn #(st/emit! dwc/undo))
redo (mf/use-fn #(st/emit! dwc/redo))]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true
:edit true)
:on-close on-close}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click select-all
:on-key-down (fn [event]
(when (kbd/enter? event)
(select-all event)))
:id "file-menu-select-all"}
[:span {:class (stl/css :item-name)}
(tr "workspace.header.menu.select-all")]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :select-all))]
[:span {:class (stl/css :shortcut-key)
:key sc}
sc])]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click undo
:on-key-down (fn [event]
(when (kbd/enter? event)
(undo event)))
:id "file-menu-undo"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.undo")]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :undo))]
[:span {:class (stl/css :shortcut-key)
:key sc}
sc])]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click redo
:on-key-down (fn [event]
(when (kbd/enter? event)
(redo event)))
:id "file-menu-redo"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.redo")]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :redo))]
[:span {:class (stl/css :shortcut-key)
:key sc}
sc])]]]))
(mf/defc file-menu
{::mf/wrap-props false}
[{:keys [on-close file]}]
(let [file-id (:id file)
shared? (:is-shared file)
objects (mf/deref refs/workspace-page-objects)
frames (->> (cfh/get-immediate-children objects uuid/zero)
(filterv cfh/frame-shape?))
on-remove-shared
(mf/use-fn
(mf/deps file-id)
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(modal/show!
{:type :delete-shared-libraries
:origin :unpublish
:ids #{file-id}
:on-accept #(st/emit! (dwl/set-file-shared file-id false))
:count-libraries 1})))
on-remove-shared-key-down
(mf/use-fn
(mf/deps on-remove-shared)
(fn [event]
(when (kbd/enter? event)
(on-remove-shared event))))
on-add-shared
(mf/use-fn
(mf/deps file-id)
(fn [_event]
(let [on-accept #(st/emit! (dwl/set-file-shared file-id true))]
(st/emit! (dcm/show-shared-dialog file-id on-accept)))))
on-add-shared-key-down
(mf/use-fn
(mf/deps on-add-shared)
(fn [event]
(when (kbd/enter? event)
(on-add-shared event))))
on-export-shapes
(mf/use-fn #(st/emit! (de/show-workspace-export-dialog)))
on-export-shapes-key-down
(mf/use-fn
(mf/deps on-export-shapes)
(fn [event]
(when (kbd/enter? event)
(on-export-shapes event))))
on-export-file
(mf/use-fn
(mf/deps file)
(fn [event]
(let [target (dom/get-current-target event)
binary? (= (dom/get-data target "binary") "true")
evname (if binary?
"export-binary-files"
"export-standard-files")]
(st/emit!
(ptk/event ::ev/event {::ev/name evname
::ev/origin "workspace"
:num-files 1})
(dcm/export-files [file] binary?)))))
on-export-file-key-down
(mf/use-fn
(mf/deps on-export-file)
(fn [event]
(when (kbd/enter? event)
(on-export-file event))))
on-export-frames
(mf/use-fn
(mf/deps frames)
(fn [_]
(st/emit! (de/show-workspace-export-frames-dialog (reverse frames)))))
on-export-frames-key-down
(mf/use-fn
(mf/deps on-export-frames)
(fn [event]
(when (kbd/enter? event)
(on-export-frames event))))]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true
:file true)
:on-close on-close}
(if ^boolean shared?
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-remove-shared
:on-key-down on-remove-shared-key-down
:id "file-menu-remove-shared"}
[:span {:class (stl/css :item-name)} (tr "dashboard.unpublish-shared")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-add-shared
:on-key-down on-add-shared-key-down
:id "file-menu-add-shared"}
[:span {:class (stl/css :item-name)} (tr "dashboard.add-shared")]])
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-export-shapes
:on-key-down on-export-shapes-key-down
:id "file-menu-export-shapes"}
[:span {:class (stl/css :item-name)} (tr "dashboard.export-shapes")]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :export-shapes))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-export-file
:on-key-down on-export-file-key-down
:data-binary true
:id "file-menu-binary-file"}
[:span {:class (stl/css :item-name)}
(tr "dashboard.download-binary-file")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-export-file
:on-key-down on-export-file-key-down
:data-binary false
:id "file-menu-standard-file"}
[:span {:class (stl/css :item-name)}
(tr "dashboard.download-standard-file")]]
(when (seq frames)
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-export-frames
:on-key-down on-export-frames-key-down
:id "file-menu-export-frames"}
[:span {:class (stl/css :item-name)}
(tr "dashboard.export-frames")]])]))
(mf/defc menu
{::mf/wrap-props false}
[{:keys [layout file]}]
(let [show-menu* (mf/use-state false)
show-menu? (deref show-menu*)
sub-menu* (mf/use-state false)
sub-menu (deref sub-menu*)
open-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! show-menu* true)))
close-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! show-menu* false)))
close-sub-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! sub-menu* nil)))
on-menu-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(let [menu (-> (dom/get-current-target event)
(dom/get-data "test")
(keyword))]
(reset! sub-menu* menu))))
toggle-flag
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(let [flag (-> (dom/get-current-target event)
(dom/get-data "test")
(keyword))]
(st/emit!
(-> (dw/toggle-layout-flag flag)
(vary-meta assoc ::ev/origin "workspace-menu")))
(reset! show-menu* false)
(reset! sub-menu* nil))))]
[:*
[:div {:on-click open-menu
:class (stl/css :menu-btn)} i/menu-refactor]
[:& dropdown-menu {:show show-menu?
:on-close close-menu
:list-class (stl/css :menu)}
[:> dropdown-menu-item* {:class (stl/css :menu-item)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:data-test "file"
:id "file-menu-file"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.file")]
[:span {:class (stl/css :open-arrow)} i/arrow-refactor]]
[:> dropdown-menu-item* {:class (stl/css :menu-item)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:data-test "edit"
:id "file-menu-edit"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.edit")]
[:span {:class (stl/css :open-arrow)} i/arrow-refactor]]
[:> dropdown-menu-item* {:class (stl/css :menu-item)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:data-test "view"
:id "file-menu-view"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.view")]
[:span {:class (stl/css :open-arrow)} i/arrow-refactor]]
[:> dropdown-menu-item* {:class (stl/css :menu-item)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:data-test "preferences"
:id "file-menu-preferences"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.preferences")]
[:span {:class (stl/css :open-arrow)} i/arrow-refactor]]
[:div {:class (stl/css :separator)}]
[:> dropdown-menu-item* {:class (stl/css-case :menu-item true)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:data-test "help-info"
:id "file-menu-help-info"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.help-info")]
[:span {:class (stl/css :open-arrow)} i/arrow-refactor]]]
(case sub-menu
:file
[:& file-menu
{:file file
:on-close close-sub-menu}]
:edit
[:& edit-menu
{:on-close close-sub-menu}]
:view
[:& view-menu
{:layout layout
:toggle-flag toggle-flag
:on-close close-sub-menu}]
:preferences
[:& preferences-menu
{:layout layout
:toggle-flag toggle-flag
:on-close close-sub-menu}]
:help-info
[:& help-info-menu
{:layout layout
:on-close close-sub-menu}]
nil)]))
;; --- Header Component ;; --- Header Component
(mf/defc left-header (mf/defc left-header
{::mf/wrap-props false} {::mf/wrap-props false}
[{:keys [file layout project page-id class]}] [{:keys [file layout project page-id class]}]
(let [file-id (:id file) (let [profile (mf/deref refs/profile)
file-id (:id file)
file-name (:name file) file-name (:name file)
project-id (:id project) project-id (:id project)
team-id (:team-id project) team-id (:team-id project)
@ -809,9 +110,10 @@
(when ^boolean shared? (when ^boolean shared?
[:span {:class (stl/css :shared-badge)} i/library-refactor]) [:span {:class (stl/css :shared-badge)} i/library-refactor])
[:div {:class (stl/css :menu-section)} [:div {:class (stl/css :menu-section)}
[:& menu {:layout layout [:& main-menu/menu
:file file {:layout layout
:read-only? read-only? :file file
:team-id team-id :profile profile
:page-id page-id}]]])) :read-only? read-only?
:team-id team-id
:page-id page-id}]]]))

View File

@ -77,97 +77,3 @@
width: $s-16; width: $s-16;
} }
} }
.menu-btn {
@extend .button-tertiary;
height: $s-32;
width: calc($s-24 + $s-4);
padding: 0;
border-radius: $br-8;
svg {
@extend .button-icon;
stroke: var(--icon-foreground);
}
}
.menu {
@extend .menu-dropdown;
top: $s-48;
left: calc(var(--width, $s-256) - $s-16);
width: $s-192;
margin: 0;
}
.menu-item {
@extend .menu-item-base;
cursor: pointer;
.open-arrow {
@include flexCenter;
svg {
@extend .button-icon;
stroke: var(--icon-foreground);
}
}
&:hover {
color: var(--menu-foreground-color-hover);
.open-arrow {
svg {
stroke: var(--menu-foreground-color-hover);
}
}
.shortcut-key {
color: var(--menu-shortcut-foreground-color-hover);
}
}
}
.separator {
margin-top: $s-8;
height: $s-4;
border-top: $s-1 solid $db-secondary;
}
.shortcut {
@extend .shortcut-base;
}
.shortcut-key {
@extend .shortcut-key-base;
}
.sub-menu {
@extend .menu-dropdown;
left: calc(var(--width, $s-256) + $s-180);
width: $s-192;
min-width: calc($s-272 - $s-2);
width: 110%;
.submenu-item {
@extend .menu-item-base;
&:hover {
color: var(--menu-foreground-color-hover);
.shortcut-key {
color: var(--menu-shortcut-foreground-color-hover);
}
}
}
&.file {
top: $s-48;
}
&.edit {
top: $s-76;
}
&.view {
top: $s-116;
}
&.preferences {
top: $s-148;
}
&.help-info {
top: $s-196;
}
}

View File

@ -123,11 +123,12 @@
shared-libraries shared-libraries
(mf/with-memo [shared-libraries linked-libraries file-id search-term] (mf/with-memo [shared-libraries linked-libraries file-id search-term]
(->> shared-libraries (when shared-libraries
(remove #(= (:id %) file-id)) (->> shared-libraries
(remove #(contains? linked-libraries (:id %))) (remove #(= (:id %) file-id))
(filter #(matches-search (:name %) search-term)) (remove #(contains? linked-libraries (:id %)))
(sort-by (comp str/lower :name)))) (filter #(matches-search (:name %) search-term))
(sort-by (comp str/lower :name)))))
linked-libraries linked-libraries
(mf/with-memo [linked-libraries] (mf/with-memo [linked-libraries]
@ -275,12 +276,17 @@
:on-click link-library} :on-click link-library}
i/add-refactor]])] i/add-refactor]])]
[:div {:class (stl/css :section-list-empty)} (when (empty? shared-libraries)
(if (nil? shared-libraries) [:div {:class (stl/css :section-list-empty)}
i/loader-pencil (cond
(if (str/empty? search-term) (nil? shared-libraries)
(tr "workspace.libraries.loading")
(str/empty? search-term)
(tr "workspace.libraries.no-shared-libraries-available") (tr "workspace.libraries.no-shared-libraries-available")
(tr "workspace.libraries.no-matches-for" search-term)))])]]))
:else
(tr "workspace.libraries.no-matches-for" search-term))]))]]))
(defn- extract-assets (defn- extract-assets
[file-data library summary?] [file-data library summary?]
@ -519,4 +525,3 @@
[:& updates-tab {:file-id file-id [:& updates-tab {:file-id file-id
:file-data file-data :file-data file-data
:libraries libraries}]]]]]]]])) :libraries libraries}]]]]]]]]))

View File

@ -0,0 +1,747 @@
;; 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.main-menu
(:require-macros [app.main.style :as stl])
(:require
[app.common.files.helpers :as cfh]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.events :as ev]
[app.main.data.exports :as de]
[app.main.data.modal :as modal]
[app.main.data.shortcuts :as scd]
[app.main.data.users :as du]
[app.main.data.workspace :as dw]
[app.main.data.workspace.common :as dwc]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.shortcuts :as sc]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu dropdown-menu-item*]]
[app.main.ui.context :as ctx]
[app.main.ui.hooks.resize :as r]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.router :as rt]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
;; --- Header menu and submenus
(mf/defc help-info-menu
{::mf/wrap-props false
::mf/wrap [mf/memo]}
[{:keys [layout on-close]}]
(let [nav-to-helpc-center
(mf/use-fn #(dom/open-new-window "https://help.penpot.app"))
nav-to-community
(mf/use-fn #(dom/open-new-window "https://community.penpot.app"))
nav-to-youtube
(mf/use-fn #(dom/open-new-window "https://www.youtube.com/c/Penpot"))
nav-to-templates
(mf/use-fn #(dom/open-new-window "https://penpot.app/libraries-templates"))
nav-to-github
(mf/use-fn #(dom/open-new-window "https://github.com/penpot/penpot"))
nav-to-terms
(mf/use-fn #(dom/open-new-window "https://penpot.app/terms"))
nav-to-feedback
(mf/use-fn #(st/emit! (rt/nav-new-window* {:rname :settings-feedback})))
show-shortcuts
(mf/use-fn
(mf/deps layout)
(fn []
(when (contains? layout :collapse-left-sidebar)
(st/emit! (dw/toggle-layout-flag :collapse-left-sidebar)))
(st/emit!
(-> (dw/toggle-layout-flag :shortcuts)
(vary-meta assoc ::ev/origin "workspace-header")))))
show-release-notes
(mf/use-fn
(fn [event]
(let [version (:main cf/version)]
(st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version}))
(if (and (kbd/alt? event) (kbd/mod? event))
(st/emit! (modal/show {:type :onboarding}))
(st/emit! (modal/show {:type :release-notes :version version}))))))]
[:& dropdown-menu {:show true
:on-close on-close
:list-class (stl/css-case :sub-menu true
:help-info true)}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-helpc-center
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-helpc-center event)))
:id "file-menu-help-center"}
[:span {:class (stl/css :item-name)} (tr "labels.help-center")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-community
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-community event)))
:id "file-menu-community"}
[:span {:class (stl/css :item-name)} (tr "labels.community")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-youtube
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-youtube event)))
:id "file-menu-youtube"}
[:span {:class (stl/css :item-name)} (tr "labels.tutorials")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click show-release-notes
:on-key-down (fn [event]
(when (kbd/enter? event)
(show-release-notes event)))
:id "file-menu-release-notes"}
[:span {:class (stl/css :item-name)} (tr "labels.release-notes")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-templates
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-templates event)))
:id "file-menu-templates"}
[:span {:class (stl/css :item-name)} (tr "labels.libraries-and-templates")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-github
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-github event)))
:id "file-menu-github"}
[:span {:class (stl/css :item-name)} (tr "labels.github-repo")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-terms
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-terms event)))
:id "file-menu-terms"}
[:span {:class (stl/css :item-name)} (tr "auth.terms-of-service")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click show-shortcuts
:on-key-down (fn [event]
(when (kbd/enter? event)
(show-shortcuts event)))
:id "file-menu-shortcuts"}
[:span {:class (stl/css :item-name)} (tr "label.shortcuts")]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :show-shortcuts))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
(when (contains? cf/flags :user-feedback)
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click nav-to-feedback
:on-key-down (fn [event]
(when (kbd/enter? event)
(nav-to-feedback event)))
:id "file-menu-feedback"}
[:span {:class (stl/css-case :feedback true
:item-name true)} (tr "labels.give-feedback")]])]))
(mf/defc preferences-menu
{::mf/wrap-props false
::mf/wrap [mf/memo]}
[{:keys [layout profile toggle-flag on-close toggle-theme]}]
(let [show-nudge-options (mf/use-fn #(modal/show! {:type :nudge-option}))]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true
:preferences true)
:on-close on-close}
[:> dropdown-menu-item* {:on-click toggle-flag
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "scale-text"
:id "file-menu-scale-text"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :scale-text)
(tr "workspace.header.menu.disable-scale-content")
(tr "workspace.header.menu.enable-scale-content"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-scale-text))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:on-click toggle-flag
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "snap-guides"
:id "file-menu-snap-guides"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :snap-guides)
(tr "workspace.header.menu.disable-snap-guides")
(tr "workspace.header.menu.enable-snap-guides"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-snap-guide))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:on-click toggle-flag
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "snap-grid"
:id "file-menu-snap-grid"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :snap-grid)
(tr "workspace.header.menu.disable-snap-grid")
(tr "workspace.header.menu.enable-snap-grid"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-snap-grid))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:on-click toggle-flag
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "dynamic-alignment"
:id "file-menu-dynamic-alignment"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :dynamic-alignment)
(tr "workspace.header.menu.disable-dynamic-alignment")
(tr "workspace.header.menu.enable-dynamic-alignment"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-alignment))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:on-click toggle-flag
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "snap-pixel-grid"
:id "file-menu-pixel-grid"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :snap-pixel-grid)
(tr "workspace.header.menu.disable-snap-pixel-grid")
(tr "workspace.header.menu.enable-snap-pixel-grid"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :snap-pixel-grid))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:on-click show-nudge-options
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
(show-nudge-options event)))
:data-test "snap-pixel-grid"
:id "file-menu-nudge"}
[:span {:class (stl/css :item-name)} (tr "modals.nudge-title")]]
[:> dropdown-menu-item* {:on-click toggle-theme
:class (stl/css :submenu-item)
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-theme event)))
:data-test "toggle-theme"
:id "file-menu-toggle-theme"}
[:span {:class (stl/css :item-name)}
(if (= (:theme profile) "default")
(tr "workspace.header.menu.toggle-light-theme")
(tr "workspace.header.menu.toggle-dark-theme"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-theme))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]]))
(mf/defc view-menu
{::mf/wrap-props false
::mf/wrap [mf/memo]}
[{:keys [layout toggle-flag on-close]}]
(let [read-only? (mf/use-ctx ctx/workspace-read-only?)
toggle-color-palette
(mf/use-fn
(fn []
(r/set-resize-type! :bottom)
(st/emit! (dw/remove-layout-flag :textpalette)
(-> (dw/toggle-layout-flag :colorpalette)
(vary-meta assoc ::ev/origin "workspace-menu")))))
toggle-text-palette
(mf/use-fn
(fn []
(r/set-resize-type! :bottom)
(st/emit! (dw/remove-layout-flag :colorpalette)
(-> (dw/toggle-layout-flag :textpalette)
(vary-meta assoc ::ev/origin "workspace-menu")))))]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true
:view true)
:on-close on-close}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-flag
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "rules"
:id "file-menu-rules"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :rules)
(tr "workspace.header.menu.hide-rules")
(tr "workspace.header.menu.show-rules"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-rules))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-flag
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "display-grid"
:id "file-menu-grid"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :display-grid)
(tr "workspace.header.menu.hide-grid")
(tr "workspace.header.menu.show-grid"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-grid))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
(when-not ^boolean read-only?
[:*
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-color-palette
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-color-palette event)))
:id "file-menu-color-palette"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :colorpalette)
(tr "workspace.header.menu.hide-palette")
(tr "workspace.header.menu.show-palette"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-colorpalette))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-text-palette
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-text-palette event)))
:id "file-menu-text-palette"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :textpalette)
(tr "workspace.header.menu.hide-textpalette")
(tr "workspace.header.menu.show-textpalette"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :toggle-textpalette))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]])
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-flag
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "display-artboard-names"
:id "file-menu-artboards"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :display-artboard-names)
(tr "workspace.header.menu.hide-artboard-names")
(tr "workspace.header.menu.show-artboard-names"))]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-flag
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "show-pixel-grid"
:id "file-menu-pixel-grid"}
[:span {:class (stl/css :item-name)}
(if (contains? layout :show-pixel-grid)
(tr "workspace.header.menu.hide-pixel-grid")
(tr "workspace.header.menu.show-pixel-grid"))]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :show-pixel-grid))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click toggle-flag
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-flag event)))
:data-test "hide-ui"
:id "file-menu-hide-ui"}
[:span {:class (stl/css :item-name)}
(tr "workspace.shape.menu.hide-ui")]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :hide-ui))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]]))
(mf/defc edit-menu
{::mf/wrap-props false
::mf/wrap [mf/memo]}
[{:keys [on-close]}]
(let [select-all (mf/use-fn #(st/emit! (dw/select-all)))
undo (mf/use-fn #(st/emit! dwc/undo))
redo (mf/use-fn #(st/emit! dwc/redo))]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true
:edit true)
:on-close on-close}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click select-all
:on-key-down (fn [event]
(when (kbd/enter? event)
(select-all event)))
:id "file-menu-select-all"}
[:span {:class (stl/css :item-name)}
(tr "workspace.header.menu.select-all")]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :select-all))]
[:span {:class (stl/css :shortcut-key)
:key sc}
sc])]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click undo
:on-key-down (fn [event]
(when (kbd/enter? event)
(undo event)))
:id "file-menu-undo"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.undo")]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :undo))]
[:span {:class (stl/css :shortcut-key)
:key sc}
sc])]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click redo
:on-key-down (fn [event]
(when (kbd/enter? event)
(redo event)))
:id "file-menu-redo"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.redo")]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :redo))]
[:span {:class (stl/css :shortcut-key)
:key sc}
sc])]]]))
(mf/defc file-menu
{::mf/wrap-props false}
[{:keys [on-close file]}]
(let [file-id (:id file)
shared? (:is-shared file)
objects (mf/deref refs/workspace-page-objects)
frames (->> (cfh/get-immediate-children objects uuid/zero)
(filterv cfh/frame-shape?))
on-remove-shared
(mf/use-fn
(mf/deps file-id)
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(modal/show!
{:type :delete-shared-libraries
:origin :unpublish
:ids #{file-id}
:on-accept #(st/emit! (dwl/set-file-shared file-id false))
:count-libraries 1})))
on-remove-shared-key-down
(mf/use-fn
(mf/deps on-remove-shared)
(fn [event]
(when (kbd/enter? event)
(on-remove-shared event))))
on-add-shared
(mf/use-fn
(mf/deps file-id)
(fn [_event]
(let [on-accept #(st/emit! (dwl/set-file-shared file-id true))]
(st/emit! (dcm/show-shared-dialog file-id on-accept)))))
on-add-shared-key-down
(mf/use-fn
(mf/deps on-add-shared)
(fn [event]
(when (kbd/enter? event)
(on-add-shared event))))
on-export-shapes
(mf/use-fn #(st/emit! (de/show-workspace-export-dialog)))
on-export-shapes-key-down
(mf/use-fn
(mf/deps on-export-shapes)
(fn [event]
(when (kbd/enter? event)
(on-export-shapes event))))
on-export-file
(mf/use-fn
(mf/deps file)
(fn [event]
(let [target (dom/get-current-target event)
binary? (= (dom/get-data target "binary") "true")
evname (if binary?
"export-binary-files"
"export-standard-files")]
(st/emit!
(ptk/event ::ev/event {::ev/name evname
::ev/origin "workspace"
:num-files 1})
(dcm/export-files [file] binary?)))))
on-export-file-key-down
(mf/use-fn
(mf/deps on-export-file)
(fn [event]
(when (kbd/enter? event)
(on-export-file event))))
on-export-frames
(mf/use-fn
(mf/deps frames)
(fn [_]
(st/emit! (de/show-workspace-export-frames-dialog (reverse frames)))))
on-export-frames-key-down
(mf/use-fn
(mf/deps on-export-frames)
(fn [event]
(when (kbd/enter? event)
(on-export-frames event))))]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true
:file true)
:on-close on-close}
(if ^boolean shared?
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-remove-shared
:on-key-down on-remove-shared-key-down
:id "file-menu-remove-shared"}
[:span {:class (stl/css :item-name)} (tr "dashboard.unpublish-shared")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-add-shared
:on-key-down on-add-shared-key-down
:id "file-menu-add-shared"}
[:span {:class (stl/css :item-name)} (tr "dashboard.add-shared")]])
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-export-shapes
:on-key-down on-export-shapes-key-down
:id "file-menu-export-shapes"}
[:span {:class (stl/css :item-name)} (tr "dashboard.export-shapes")]
[:span {:class (stl/css :shortcut)}
(for [sc (scd/split-sc (sc/get-tooltip :export-shapes))]
[:span {:class (stl/css :shortcut-key) :key sc} sc])]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-export-file
:on-key-down on-export-file-key-down
:data-binary true
:id "file-menu-binary-file"}
[:span {:class (stl/css :item-name)}
(tr "dashboard.download-binary-file")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-export-file
:on-key-down on-export-file-key-down
:data-binary false
:id "file-menu-standard-file"}
[:span {:class (stl/css :item-name)}
(tr "dashboard.download-standard-file")]]
(when (seq frames)
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-export-frames
:on-key-down on-export-frames-key-down
:id "file-menu-export-frames"}
[:span {:class (stl/css :item-name)}
(tr "dashboard.export-frames")]])]))
(mf/defc menu
{::mf/wrap-props false}
[{:keys [layout file profile]}]
(let [show-menu* (mf/use-state false)
show-menu? (deref show-menu*)
sub-menu* (mf/use-state false)
sub-menu (deref sub-menu*)
open-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! show-menu* true)))
close-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! show-menu* false)))
close-sub-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! sub-menu* nil)))
on-menu-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(let [menu (-> (dom/get-current-target event)
(dom/get-data "test")
(keyword))]
(reset! sub-menu* menu))))
toggle-flag
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(let [flag (-> (dom/get-current-target event)
(dom/get-data "test")
(keyword))]
(st/emit!
(-> (dw/toggle-layout-flag flag)
(vary-meta assoc ::ev/origin "workspace-menu")))
(reset! show-menu* false)
(reset! sub-menu* nil))))
toggle-theme
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(st/emit! (du/toggle-theme))))]
[:*
[:div {:on-click open-menu
:class (stl/css :menu-btn)} i/menu-refactor]
[:& dropdown-menu {:show show-menu?
:on-close close-menu
:list-class (stl/css :menu)}
[:> dropdown-menu-item* {:class (stl/css :menu-item)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:data-test "file"
:id "file-menu-file"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.file")]
[:span {:class (stl/css :open-arrow)} i/arrow-refactor]]
[:> dropdown-menu-item* {:class (stl/css :menu-item)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:data-test "edit"
:id "file-menu-edit"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.edit")]
[:span {:class (stl/css :open-arrow)} i/arrow-refactor]]
[:> dropdown-menu-item* {:class (stl/css :menu-item)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:data-test "view"
:id "file-menu-view"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.view")]
[:span {:class (stl/css :open-arrow)} i/arrow-refactor]]
[:> dropdown-menu-item* {:class (stl/css :menu-item)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:data-test "preferences"
:id "file-menu-preferences"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.preferences")]
[:span {:class (stl/css :open-arrow)} i/arrow-refactor]]
[:div {:class (stl/css :separator)}]
[:> dropdown-menu-item* {:class (stl/css-case :menu-item true)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:data-test "help-info"
:id "file-menu-help-info"}
[:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.help-info")]
[:span {:class (stl/css :open-arrow)} i/arrow-refactor]]]
(case sub-menu
:file
[:& file-menu
{:file file
:on-close close-sub-menu}]
:edit
[:& edit-menu
{:on-close close-sub-menu}]
:view
[:& view-menu
{:layout layout
:toggle-flag toggle-flag
:on-close close-sub-menu}]
:preferences
[:& preferences-menu
{:layout layout
:profile profile
:toggle-flag toggle-flag
:toggle-theme toggle-theme
:on-close close-sub-menu}]
:help-info
[:& help-info-menu
{:layout layout
:on-close close-sub-menu}]
nil)]))

View File

@ -0,0 +1,101 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@import "refactor/common-refactor.scss";
.menu-btn {
@extend .button-tertiary;
height: $s-32;
width: calc($s-24 + $s-4);
padding: 0;
border-radius: $br-8;
svg {
@extend .button-icon;
stroke: var(--icon-foreground);
}
}
.menu {
@extend .menu-dropdown;
top: $s-48;
left: calc(var(--width, $s-256) - $s-16);
width: $s-192;
margin: 0;
}
.menu-item {
@extend .menu-item-base;
cursor: pointer;
.open-arrow {
@include flexCenter;
svg {
@extend .button-icon;
stroke: var(--icon-foreground);
}
}
&:hover {
color: var(--menu-foreground-color-hover);
.open-arrow {
svg {
stroke: var(--menu-foreground-color-hover);
}
}
.shortcut-key {
color: var(--menu-shortcut-foreground-color-hover);
}
}
}
.separator {
margin-top: $s-8;
height: $s-4;
border-top: $s-1 solid $db-secondary;
}
.shortcut {
@extend .shortcut-base;
}
.shortcut-key {
@extend .shortcut-key-base;
}
.sub-menu {
@extend .menu-dropdown;
left: calc(var(--width, $s-256) + $s-180);
width: $s-192;
min-width: calc($s-272 - $s-2);
width: 110%;
.submenu-item {
@extend .menu-item-base;
&:hover {
color: var(--menu-foreground-color-hover);
.shortcut-key {
color: var(--menu-shortcut-foreground-color-hover);
}
}
}
&.file {
top: $s-48;
}
&.edit {
top: $s-76;
}
&.view {
top: $s-116;
}
&.preferences {
top: $s-148;
}
&.help-info {
top: $s-196;
}
}

View File

@ -7,6 +7,7 @@
(ns app.main.ui.workspace.shapes.text.editor (ns app.main.ui.workspace.shapes.text.editor
(:require (:require
["draft-js" :as draft] ["draft-js" :as draft]
[app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
@ -48,7 +49,7 @@
(let [children (obj/get props "children")] (let [children (obj/get props "children")]
[:span {:style {:background "#ccc" :display "inline-block"}} children])) [:span {:style {:background "#ccc" :display "inline-block"}} children]))
(defn render-block (defn- render-block
[block shape] [block shape]
(let [type (ted/get-editor-block-type block)] (let [type (ted/get-editor-block-type block)]
(case type (case type
@ -59,7 +60,7 @@
:shape shape}} :shape shape}}
nil))) nil)))
(defn styles-fn [shape styles content] (defn- styles-fn [shape styles content]
(let [data (if (= (.getText content) "") (let [data (if (= (.getText content) "")
(-> (.getData content) (-> (.getData content)
(.toJS) (.toJS)
@ -73,19 +74,27 @@
(def empty-editor-state (def empty-editor-state
(ted/create-editor-state nil default-decorator)) (ted/create-editor-state nil default-decorator))
(defn get-blocks-to-setup [block-changes] (defn- get-blocks-to-setup [block-changes]
(->> block-changes (->> block-changes
(filter (fn [[_ v]] (filter (fn [[_ v]]
(nil? (:old v)))) (nil? (:old v))))
(mapv first))) (mapv first)))
(defn get-blocks-to-add-styles (defn- get-blocks-to-add-styles
[block-changes] [block-changes]
(->> block-changes (->> block-changes
(filter (fn [[_ v]] (filter (fn [[_ v]]
(and (not= (:old v) (:new v)) (= (:old v) "")))) (and (not= (:old v) (:new v)) (= (:old v) ""))))
(mapv first))) (mapv first)))
(defn- shape->justify
[{:keys [content]}]
(case (d/nilv (:vertical-align content) "top")
"center" "center"
"top" "flex-start"
"bottom" "flex-end"
nil))
(mf/defc text-shape-edit-html (mf/defc text-shape-edit-html
{::mf/wrap [mf/memo] {::mf/wrap [mf/memo]
::mf/wrap-props false ::mf/wrap-props false
@ -247,7 +256,8 @@
:custom-style-fn (partial styles-fn shape) :custom-style-fn (partial styles-fn shape)
:block-renderer-fn #(render-block % shape) :block-renderer-fn #(render-block % shape)
:ref on-editor :ref on-editor
:editor-state state}]])) :editor-state state
:style #js {:border "1px solid red"}}]]))
(defn translate-point-from-viewport (defn translate-point-from-viewport
"Translate a point in the viewport into client coordinates" "Translate a point in the viewport into client coordinates"
@ -303,7 +313,19 @@
(dm/get-prop shape :height)) (dm/get-prop shape :height))
style style
(cond-> #js {:pointer-events "all"} (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) (cf/check-browser? :safari-16)
(obj/merge! (obj/merge!
#js {:position "fixed" #js {:position "fixed"

View File

@ -472,13 +472,26 @@
(mf/use-fn (mf/use-fn
(mf/deps file-id) (mf/deps file-id)
(fn [component event] (fn [component event]
;; dnd api only allow to acces to the dataTransfer data on on-drop (https://html.spec.whatwg.org/dev/dnd.html#concept-dnd-p)
;; We need to know if the dragged element is from the local library on on-drag-enter, so we need to keep the info elsewhere
(set-drag-data! {:local? local?})
(dnd/set-data! event "penpot/component" {:file-id file-id (let [file-data
:component component}) (d/nilv (dm/get-in @refs/workspace-libraries [file-id :data]) @refs/workspace-data)
(dnd/set-allowed-effect! event "move")))
shape-main
(ctf/get-component-root file-data component)]
;; dnd api only allow to acces to the dataTransfer data on on-drop (https://html.spec.whatwg.org/dev/dnd.html#concept-dnd-p)
;; We need to know if the dragged element is from the local library on on-drag-enter, so we need to keep the info elsewhere
(set-drag-data! {:file-id file-id
:component component
:shape shape-main
:local? local?})
(dnd/set-data! event "penpot/component" true)
;; Remove the ghost image for componentes because we're going to instantiate it on the viewport
(dnd/set-drag-image! event (dnd/invisible-image))
(dnd/set-allowed-effect! event "move"))))
on-show-main on-show-main
(mf/use-fn (mf/use-fn
@ -569,4 +582,3 @@
{:option-name (tr "workspace.shape.menu.show-main") {:option-name (tr "workspace.shape.menu.show-main")
:id "assets-show-main-component" :id "assets-show-main-component"
:option-handler on-show-main})]}]]])) :option-handler on-show-main})]}]]]))

View File

@ -11,7 +11,7 @@
} }
.asset-grid { .asset-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax($s-112, 1fr)); grid-template-columns: repeat(auto-fill, minmax($s-96, 1fr));
grid-auto-rows: $s-112; grid-auto-rows: $s-112;
max-width: 100%; max-width: 100%;
gap: $s-4; gap: $s-4;

View File

@ -61,7 +61,8 @@
:transform :matrix-render :transform :matrix-render
:transform-inverse :matrix-render :transform-inverse :matrix-render
:selrect :rect-render :selrect :rect-render
:points :points-render}) :points :points-render
:layout-grid-cells :cells-render})
(mf/defc shape-link (mf/defc shape-link
[{:keys [id objects]}] [{:keys [id objects]}]
@ -69,6 +70,25 @@
:on-click #(st/emit! (dw/select-shape id))} :on-click #(st/emit! (dw/select-shape id))}
(dm/str (dm/get-in objects [id :name]) " #" id)]) (dm/str (dm/get-in objects [id :name]) " #" id)])
(mf/defc cells-render
[{:keys [cells objects]}]
[:div {:class (stl/css :cells-render)}
(for [[id cell] cells]
[:div {:key (dm/str "cell-" id)
:class (stl/css :cell-container)}
[:div {:class (stl/css :cell-position)}
(dm/fmt "(%, %) -> (%, %)"
(:row cell)
(:column cell)
(+ (:row cell) (dec (:row-span cell)))
(+ (:column cell) (dec (:column-span cell))))]
[:div {:class (stl/css :cell-shape)}
(if (empty? (:shapes cell))
[:div "<empty>"]
[:& shape-link {:id (first (:shapes cell)) :objects objects}])]])])
(mf/defc debug-shape-attr (mf/defc debug-shape-attr
[{:keys [attr value objects]}] [{:keys [attr value objects]}]
@ -79,7 +99,8 @@
:shape-list :shape-list
[:div {:class (stl/css :shape-list)} [:div {:class (stl/css :shape-list)}
(for [id value] (for [id value]
[:& shape-link {:id id :objects objects}])] [:& shape-link {:key (dm/str "child-" id)
:id id :objects objects}])]
:matrix-render :matrix-render
[:div (dm/str (gmt/format-precision value 2))] [:div (dm/str (gmt/format-precision value 2))]
@ -89,8 +110,11 @@
:points-render :points-render
[:div {:class (stl/css :point-list)} [:div {:class (stl/css :point-list)}
(for [point value] (for [[idx point] (d/enumerate value)]
[:div (dm/fmt "(%, %)" (:x point) (:y point))])] [:div {:key (dm/str "point-" idx)} (dm/fmt "(%, %)" (:x point) (:y point))])]
:cells-render
[:& cells-render {:cells value :objects objects}]
[:div {:class (stl/css :attrs-container-value)} (str value)])) [:div {:class (stl/css :attrs-container-value)} (str value)]))

View File

@ -10,7 +10,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: var(--panel-background-color); background-color: var(--panel-background-color);
color: white; color: $df-primary;
font-size: $fs-12; font-size: $fs-12;
user-select: text; user-select: text;
} }
@ -97,3 +97,8 @@
display: flex; display: flex;
gap: $s-8; gap: $s-8;
} }
.cell-container {
display: grid;
grid-template-columns: 100px 1fr;
}

View File

@ -84,7 +84,7 @@
(dom/focus! textarea)))) (dom/focus! textarea))))
on-delete-annotation on-delete-annotation
(mf/use-callback (mf/use-callback
(mf/deps shape) (mf/deps (:id shape))
(fn [event] (fn [event]
(dom/stop-propagation event) (dom/stop-propagation event)
(st/emit! (modal/show (st/emit! (modal/show
@ -98,7 +98,7 @@
(dw/update-component-annotation component-id nil)))}))))] (dw/update-component-annotation component-id nil)))}))))]
(mf/use-effect (mf/use-effect
(mf/deps shape) (mf/deps (:id shape))
(fn [] (fn []
(initialize) (initialize)
(when (and (not creating?) (:id-for-create workspace-annotations)) ;; cleanup set-annotations-id-for-create if we aren't on the marked component (when (and (not creating?) (:id-for-create workspace-annotations)) ;; cleanup set-annotations-id-for-create if we aren't on the marked component

View File

@ -176,6 +176,7 @@
[:div {:class (stl/css :header)} [:div {:class (stl/css :header)}
[:& search-bar {:on-change on-filter-change [:& search-bar {:on-change on-filter-change
:value (:term @state) :value (:term @state)
:auto-focus true
:placeholder (tr "workspace.options.search-font")}] :placeholder (tr "workspace.options.search-font")}]
(when (and recent-fonts show-recent) (when (and recent-fonts show-recent)
[:section {:class (stl/css :show-recent)} [:section {:class (stl/css :show-recent)}

View File

@ -247,14 +247,13 @@
.text-options { .text-options {
@include flexColumn; @include flexColumn;
margin-bottom: $s-8;
&:not(.text-options-full-size) { &:not(.text-options-full-size) {
position: relative; position: relative;
} }
.font-option { .font-option {
@include titleTipography; @include titleTipography;
@extend .asset-element; @extend .asset-element;
padding-right: 0; padding: $s-8 0 $s-8 $s-8;
cursor: pointer; cursor: pointer;
.name { .name {
flex-grow: 1; flex-grow: 1;
@ -292,6 +291,7 @@
padding: 0; padding: 0;
.numeric-input { .numeric-input {
@extend .input-base; @extend .input-base;
padding-inline-start: $s-8;
} }
} }
@ -375,39 +375,40 @@
padding: $s-12; padding: $s-12;
} }
} }
}
.font-wrapper { .font-wrapper {
padding-bottom: $s-4; padding-bottom: $s-4;
cursor: pointer; cursor: pointer;
.font-item { }
@extend .asset-element;
margin-bottom: $s-4;
border-radius: $br-8;
display: flex;
.icon {
@include flexCenter;
height: $s-28;
width: $s-28;
svg {
@extend .button-icon-small;
stroke: var(--icon-foreground);
}
}
&.selected {
color: var(--assets-item-name-foreground-color-hover);
.icon {
svg {
stroke: var(--assets-item-name-foreground-color-hover);
}
}
}
.label { .font-item {
@include titleTipography; @extend .asset-element;
flex-grow: 1; margin-bottom: $s-4;
border-radius: $br-8;
display: flex;
.icon {
@include flexCenter;
height: $s-28;
width: $s-28;
svg {
@extend .button-icon-small;
stroke: var(--icon-foreground);
}
}
&.selected {
color: var(--assets-item-name-foreground-color-hover);
.icon {
svg {
stroke: var(--assets-item-name-foreground-color-hover);
} }
} }
} }
.label {
@include titleTipography;
flex-grow: 1;
}
} }
.font-selector-dropdown-full-size { .font-selector-dropdown-full-size {
@ -418,7 +419,6 @@
} }
.fonts-list { .fonts-list {
@include menuShadow;
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -439,4 +439,10 @@
border-start-start-radius: 0; border-start-start-radius: 0;
border-start-end-radius: 0; border-start-end-radius: 0;
border: $s-1 solid var(--color-background-quaternary); border: $s-1 solid var(--color-background-quaternary);
// TODO: this should belong to typography-entry , but atm we don't have a clear
// way of accessing whether we are in fullsize mode or not
.selected {
padding-inline-end: 0;
}
} }

View File

@ -56,10 +56,6 @@
:values measure-values :values measure-values
:shape shape}] :shape shape}]
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& layout-container-menu [:& layout-container-menu
{:type type {:type type
:ids [(:id shape)] :ids [(:id shape)]
@ -81,6 +77,10 @@
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:shape shape}]) :shape shape}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& fill-menu {:ids ids [:& fill-menu {:ids ids
:type type :type type
:values (select-keys shape fill-attrs)}] :values (select-keys shape fill-attrs)}]

View File

@ -58,10 +58,6 @@
:values measure-values :values measure-values
:shape shape}] :shape shape}]
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& layout-container-menu [:& layout-container-menu
{:type type {:type type
:ids [(:id shape)] :ids [(:id shape)]
@ -83,6 +79,10 @@
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:shape shape}]) :shape shape}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& fill-menu {:ids ids [:& fill-menu {:ids ids
:type type :type type
:values (select-keys shape fill-attrs)}] :values (select-keys shape fill-attrs)}]

View File

@ -65,10 +65,6 @@
[:& component-menu {:shapes [shape]}] [:& component-menu {:shapes [shape]}]
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& layout-container-menu [:& layout-container-menu
{:type type {:type type
:ids [(:id shape)] :ids [(:id shape)]
@ -91,6 +87,9 @@
:is-layout-container? is-layout-container? :is-layout-container? is-layout-container?
:shape shape}]) :shape shape}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& fill-menu {:ids ids [:& fill-menu {:ids ids
:type type :type type

View File

@ -72,9 +72,6 @@
[:& measures-menu {:type type :ids measure-ids :values measure-values :shape shape}] [:& measures-menu {:type type :ids measure-ids :values measure-values :shape shape}]
[:& component-menu {:shapes [shape]}] ;;remove this in components-v2 [:& component-menu {:shapes [shape]}] ;;remove this in components-v2
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids constraint-ids :values constraint-values}])
[:& layout-container-menu [:& layout-container-menu
{:type type {:type type
:ids [(:id shape)] :ids [(:id shape)]
@ -96,6 +93,9 @@
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:values layout-item-values}]) :values layout-item-values}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids constraint-ids :values constraint-values}])
(when-not (empty? fill-ids) (when-not (empty? fill-ids)
[:& fill-menu {:type type :ids fill-ids :values fill-values}]) [:& fill-menu {:type type :ids fill-ids :values fill-values}])

View File

@ -58,10 +58,6 @@
:values measure-values :values measure-values
:shape shape}] :shape shape}]
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& layout-container-menu [:& layout-container-menu
{:type type {:type type
:ids [(:id shape)] :ids [(:id shape)]
@ -83,6 +79,10 @@
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:shape shape}]) :shape shape}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& fill-menu {:ids ids [:& fill-menu {:ids ids
:type type :type type
:values fill-values}] :values fill-values}]

View File

@ -364,9 +364,6 @@
(when-not (empty? components) (when-not (empty? components)
[:& component-menu {:shapes components}]) [:& component-menu {:shapes components}])
(when-not (or (empty? constraint-ids) ^boolean is-layout-child?)
[:& constraints-menu {:ids constraint-ids :values constraint-values}])
[:& layout-container-menu [:& layout-container-menu
{:type type {:type type
:ids layout-container-ids :ids layout-container-ids
@ -383,6 +380,9 @@
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:values layout-item-values}]) :values layout-item-values}])
(when-not (or (empty? constraint-ids) ^boolean is-layout-child?)
[:& constraints-menu {:ids constraint-ids :values constraint-values}])
(when-not (empty? text-ids) (when-not (empty? text-ids)
[:& ot/text-menu {:type type :ids text-ids :values text-values}]) [:& ot/text-menu {:type type :ids text-ids :values text-values}])

View File

@ -57,10 +57,6 @@
:values measure-values :values measure-values
:shape shape}] :shape shape}]
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& layout-container-menu [:& layout-container-menu
{:type type {:type type
:ids [(:id shape)] :ids [(:id shape)]
@ -82,6 +78,10 @@
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:shape shape}]) :shape shape}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& fill-menu {:ids ids [:& fill-menu {:ids ids
:type type :type type
:values (select-keys shape fill-attrs)}] :values (select-keys shape fill-attrs)}]

View File

@ -60,10 +60,6 @@
:values measure-values :values measure-values
:shape shape}] :shape shape}]
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& layout-container-menu [:& layout-container-menu
{:type type {:type type
:ids ids :ids ids
@ -85,6 +81,10 @@
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:shape shape}]) :shape shape}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& fill-menu {:ids ids [:& fill-menu {:ids ids
:type type :type type
:values fill-values}] :values fill-values}]

View File

@ -129,10 +129,6 @@
:values measure-values :values measure-values
:shape shape}] :shape shape}]
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& layout-container-menu [:& layout-container-menu
{:type type {:type type
:ids [(:id shape)] :ids [(:id shape)]
@ -154,6 +150,10 @@
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:shape shape}]) :shape shape}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& fill-menu {:ids ids [:& fill-menu {:ids ids
:type type :type type
:values fill-values}] :values fill-values}]

View File

@ -93,11 +93,6 @@
:values (select-keys shape measure-attrs) :values (select-keys shape measure-attrs)
:shape shape}] :shape shape}]
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu
{:ids ids
:values (select-keys shape constraint-attrs)}])
[:& layout-container-menu [:& layout-container-menu
{:type type {:type type
:ids [(:id shape)] :ids [(:id shape)]
@ -119,6 +114,11 @@
:is-grid-parent? is-grid-parent? :is-grid-parent? is-grid-parent?
:shape shape}]) :shape shape}])
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu
{:ids ids
:values (select-keys shape constraint-attrs)}])
[:& text-menu [:& text-menu
{:ids ids {:ids ids
:type type :type type

View File

@ -174,9 +174,12 @@
on-click (actions/on-click hover selected edition drawing-path? drawing-tool space? selrect z?) on-click (actions/on-click hover selected edition drawing-path? drawing-tool space? selrect z?)
on-context-menu (actions/on-context-menu hover hover-ids workspace-read-only?) on-context-menu (actions/on-context-menu hover hover-ids workspace-read-only?)
on-double-click (actions/on-double-click hover hover-ids hover-top-frame-id drawing-path? base-objects edition drawing-tool z? workspace-read-only?) on-double-click (actions/on-double-click hover hover-ids hover-top-frame-id drawing-path? base-objects edition drawing-tool z? workspace-read-only?)
on-drag-enter (actions/on-drag-enter)
comp-inst-ref (mf/use-ref false)
on-drag-enter (actions/on-drag-enter comp-inst-ref)
on-drag-over (actions/on-drag-over move-stream) on-drag-over (actions/on-drag-over move-stream)
on-drop (actions/on-drop file) on-drag-end (actions/on-drag-over comp-inst-ref)
on-drop (actions/on-drop file comp-inst-ref)
on-pointer-down (actions/on-pointer-down @hover selected edition drawing-tool text-editing? node-editing? grid-editing? on-pointer-down (actions/on-pointer-down @hover selected edition drawing-tool text-editing? node-editing? grid-editing?
drawing-path? create-comment? space? panning z? workspace-read-only?) drawing-path? create-comment? space? panning z? workspace-read-only?)
@ -365,6 +368,7 @@
:on-double-click on-double-click :on-double-click on-double-click
:on-drag-enter on-drag-enter :on-drag-enter on-drag-enter
:on-drag-over on-drag-over :on-drag-over on-drag-over
:on-drag-end on-drag-end
:on-drop on-drop :on-drop on-drop
:on-pointer-down on-pointer-down :on-pointer-down on-pointer-down
:on-pointer-enter on-pointer-enter :on-pointer-enter on-pointer-enter

View File

@ -21,6 +21,7 @@
[app.main.data.workspace.specialized-panel :as-alias dwsp] [app.main.data.workspace.specialized-panel :as-alias dwsp]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.workspace.sidebar.assets.components :as wsac]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv] [app.main.ui.workspace.viewport.viewport-ref :as uwvv]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.dom.dnd :as dnd] [app.util.dom.dnd :as dnd]
@ -28,7 +29,8 @@
[app.util.keyboard :as kbd] [app.util.keyboard :as kbd]
[app.util.mouse :as mse] [app.util.mouse :as mse]
[app.util.object :as obj] [app.util.object :as obj]
[app.util.timers :as timers] [app.util.rxops :refer [throttle-fn]]
[app.util.timers :as ts]
[app.util.webapi :as wapi] [app.util.webapi :as wapi]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[cuerdas.core :as str] [cuerdas.core :as str]
@ -216,7 +218,7 @@
(st/emit! (mse/->MouseEvent :double-click ctrl? shift? alt? meta?)) (st/emit! (mse/->MouseEvent :double-click ctrl? shift? alt? meta?))
;; Emit asynchronously so the double click to exit shapes won't break ;; Emit asynchronously so the double click to exit shapes won't break
(timers/schedule (ts/schedule
(fn [] (fn []
(when (and (not drawing-path?) shape) (when (and (not drawing-path?) shape)
(cond (cond
@ -244,7 +246,7 @@
workspace-read-only?) workspace-read-only?)
(let [position (dom/get-client-position event)] (let [position (dom/get-client-position event)]
;; Delayed callback because we need to wait to the previous context menu to be closed ;; Delayed callback because we need to wait to the previous context menu to be closed
(timers/schedule (ts/schedule
#(st/emit! #(st/emit!
(if (some? @hover) (if (some? @hover)
(dw/show-shape-context-menu {:position position (dw/show-shape-context-menu {:position position
@ -290,7 +292,7 @@
;; We store this so in Firefox the middle button won't do a paste of the content ;; We store this so in Firefox the middle button won't do a paste of the content
(reset! disable-paste true) (reset! disable-paste true)
(timers/schedule #(reset! disable-paste false))) (ts/schedule #(reset! disable-paste false)))
(st/emit! (dw/finish-panning) (st/emit! (dw/finish-panning)
(dw/finish-zooming)))))) (dw/finish-zooming))))))
@ -400,9 +402,28 @@
(st/emit! (dw/update-viewport-position {:x #(+ % (/ delta-x zoom)) (st/emit! (dw/update-viewport-position {:x #(+ % (/ delta-x zoom))
:y #(+ % (/ delta-y zoom))})))))))))) :y #(+ % (/ delta-y zoom))}))))))))))
(defn on-drag-enter [] (defn on-drag-enter
[comp-inst-ref]
(mf/use-callback (mf/use-callback
(fn [e] (fn [e]
(let [component-inst? (mf/ref-val comp-inst-ref)]
(when (and (dnd/has-type? e "penpot/component")
(dom/class? (dom/get-target e) "viewport-controls")
(not component-inst?))
(let [point (gpt/point (.-clientX e) (.-clientY e))
viewport-coord (uwvv/point->viewport point)
{:keys [component file-id shape]} @wsac/drag-data*
;; shape (get-in component [:objects (:id component)])
final-x (- (:x viewport-coord) (/ (:width shape) 2))
final-y (- (:y viewport-coord) (/ (:height shape) 2))]
(mf/set-ref-val! comp-inst-ref true)
(st/emit! (dwl/instantiate-component
file-id
(:id component)
(gpt/point final-x final-y)
{:start-move? true :initial-point viewport-coord})))))
(when (or (dnd/has-type? e "penpot/shape") (when (or (dnd/has-type? e "penpot/shape")
(dnd/has-type? e "penpot/component") (dnd/has-type? e "penpot/component")
(dnd/has-type? e "Files") (dnd/has-type? e "Files")
@ -410,8 +431,19 @@
(dnd/has-type? e "text/asset-id")) (dnd/has-type? e "text/asset-id"))
(dom/prevent-default e))))) (dom/prevent-default e)))))
(defn on-drag-end
[comp-inst-ref]
(mf/use-callback
(fn []
(mf/set-ref-val! comp-inst-ref false))))
(defn on-drag-over [move-stream] (defn on-drag-over [move-stream]
(let [on-pointer-move (on-pointer-move move-stream)] (let [on-pointer-move (on-pointer-move move-stream)
;; Drag-over is not the same as pointer-move. Drag over is fired less frequently so we need
;; to create a throttle so the events that cannot be processed at a certain path are
;; discarded.
on-pointer-move (throttle-fn 50 (fn [e] (ts/raf #(on-pointer-move e))))]
(mf/use-callback (mf/use-callback
(fn [e] (fn [e]
(when (or (dnd/has-type? e "penpot/shape") (when (or (dnd/has-type? e "penpot/shape")
@ -423,7 +455,7 @@
(dom/prevent-default e)))))) (dom/prevent-default e))))))
(defn on-drop (defn on-drop
[file] [file comp-inst-ref]
(mf/use-fn (mf/use-fn
(fn [event] (fn [event]
(dom/prevent-default event) (dom/prevent-default event)
@ -443,13 +475,13 @@
(assoc :y final-y))))) (assoc :y final-y)))))
(dnd/has-type? event "penpot/component") (dnd/has-type? event "penpot/component")
(let [{:keys [component file-id]} (dnd/get-data event "penpot/component") (let [event (.-nativeEvent event)
shape (get-in component [:objects (:id component)]) ctrl? (kbd/ctrl? event)
final-x (- (:x viewport-coord) (/ (:width shape) 2)) shift? (kbd/shift? event)
final-y (- (:y viewport-coord) (/ (:height shape) 2))] alt? (kbd/alt? event)
(st/emit! (dwl/instantiate-component file-id meta? (kbd/meta? event)]
(:id component) (st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?))
(gpt/point final-x final-y)))) (mf/set-ref-val! comp-inst-ref false))
;; Will trigger when the user drags an image from a browser ;; Will trigger when the user drags an image from a browser
;; to the viewport (firefox and chrome do it a bit different ;; to the viewport (firefox and chrome do it a bit different
@ -517,4 +549,3 @@
(not @disable-paste) (not @disable-paste)
(not workspace-read-only?)) (not workspace-read-only?))
(st/emit! (dw/paste-from-event event @in-viewport?))))))) (st/emit! (dw/paste-from-event event @in-viewport?)))))))

View File

@ -118,7 +118,7 @@
:on-pointer-up on-pointer-up}]]) :on-pointer-up on-pointer-up}]])
(mf/defc gradient-handler-transformed (mf/defc gradient-handler-transformed
[{:keys [from-p to-p width-p from-color to-color zoom editing transform [{:keys [from-p to-p width-p from-color to-color zoom editing
on-change-start on-change-finish on-change-width]}] on-change-start on-change-finish on-change-width]}]
(let [moving-point (mf/use-var nil) (let [moving-point (mf/use-var nil)
angle (+ 90 (gpt/angle from-p to-p)) angle (+ 90 (gpt/angle from-p to-p))
@ -151,7 +151,7 @@
(reset! moving-point nil))] (reset! moving-point nil))]
(mf/use-effect (mf/use-effect
(mf/deps @moving-point from-p to-p width-p transform) (mf/deps @moving-point from-p to-p width-p)
(fn [] (fn []
(let [subs (->> st/stream (let [subs (->> st/stream
(rx/filter mse/pointer-event?) (rx/filter mse/pointer-event?)
@ -159,18 +159,17 @@
(rx/map mse/get-pointer-position) (rx/map mse/get-pointer-position)
(rx/subs! (rx/subs!
(fn [pt] (fn [pt]
(let [pt (gpt/transform pt transform)] (case @moving-point
(case @moving-point :from-p (when on-change-start (on-change-start pt))
:from-p (when on-change-start (on-change-start pt)) :to-p (when on-change-finish (on-change-finish pt))
:to-p (when on-change-finish (on-change-finish pt)) :width-p (when on-change-width
:width-p (when on-change-width (let [width-v (gpt/unit (gpt/to-vec from-p width-p))
(let [width-v (gpt/unit (gpt/to-vec from-p width-p)) distance (gpt/point-line-distance pt from-p to-p)
distance (gpt/point-line-distance pt from-p to-p) new-width-p (gpt/add
new-width-p (gpt/add from-p
from-p (gpt/multiply width-v (gpt/point distance)))]
(gpt/multiply width-v (gpt/point distance)))] (on-change-width new-width-p)))
(on-change-width new-width-p))) nil))))]
nil)))))]
(fn [] (rx/dispose! subs))))) (fn [] (rx/dispose! subs)))))
[:g.gradient-handlers [:g.gradient-handlers
[:defs [:defs
@ -296,7 +295,6 @@
:width-p (when (= :radial (:type gradient)) width-p) :width-p (when (= :radial (:type gradient)) width-p)
:from-color {:value start-color :opacity start-opacity} :from-color {:value start-color :opacity start-opacity}
:to-color {:value end-color :opacity end-opacity} :to-color {:value end-color :opacity end-opacity}
:transform transform
:zoom zoom :zoom zoom
:on-change-start on-change-start :on-change-start on-change-start
:on-change-finish on-change-finish :on-change-finish on-change-finish

View File

@ -62,6 +62,13 @@
(.setData dt data-type data)) (.setData dt data-type data))
e))) e)))
(defn invisible-image
[]
(let [img (js/Image.)
imd "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="]
(set! (.-src img) imd)
img))
(defn set-drag-image! (defn set-drag-image!
([e image] ([e image]
(set-drag-image! e image 0 0)) (set-drag-image! e image 0 0))
@ -108,11 +115,13 @@
([e] ([e]
(get-data e "penpot/data")) (get-data e "penpot/data"))
([e data-type] ([e data-type]
(let [dt (.-dataTransfer e)] (let [dt (.-dataTransfer e)
(if (or (str/starts-with? data-type "penpot") data (.getData dt data-type)]
(= data-type "application/json")) (cond-> data
(t/decode-str (.getData dt data-type)) (and (some? data) (not= data "")
(.getData dt data-type))))) (or (str/starts-with? data-type "penpot")
(= data-type "application/json")))
(t/decode-str)))))
(defn get-files (defn get-files
[e] [e]

View File

@ -8,7 +8,7 @@
(:require (:require
[beicon.v2.core :as rx])) [beicon.v2.core :as rx]))
(defn- throttle-fn (defn throttle-fn
[delay f] [delay f]
(let [state (let [state
#js {:lastExecTime 0 #js {:lastExecTime 0

View File

@ -3442,6 +3442,12 @@ msgstr "Show fonts palette"
msgid "workspace.header.menu.undo" msgid "workspace.header.menu.undo"
msgstr "Undo" msgstr "Undo"
msgid "workspace.header.menu.toggle-light-theme"
msgstr "Switch to light theme"
msgid "workspace.header.menu.toggle-dark-theme"
msgstr "Switch to dark theme"
#: src/app/main/ui/workspace/header.cljs #: src/app/main/ui/workspace/header.cljs
msgid "workspace.header.reset-zoom" msgid "workspace.header.reset-zoom"
msgstr "Reset" msgstr "Reset"
@ -3593,6 +3599,10 @@ msgstr "Search shared libraries"
msgid "workspace.libraries.shared-libraries" msgid "workspace.libraries.shared-libraries"
msgstr "SHARED LIBRARIES" msgstr "SHARED LIBRARIES"
#: src/app/main/ui/workspace/libraries.cljs
msgid "workspace.libraries.loading"
msgstr "Loading…"
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs #: src/app/main/ui/workspace/sidebar/options/menus/text.cljs
msgid "workspace.libraries.text.multiple-typography" msgid "workspace.libraries.text.multiple-typography"
msgstr "Multiple typographies" msgstr "Multiple typographies"

View File

@ -3504,6 +3504,12 @@ msgstr "Mostrar paleta de textos"
msgid "workspace.header.menu.undo" msgid "workspace.header.menu.undo"
msgstr "Deshacer" msgstr "Deshacer"
msgid "workspace.header.menu.toggle-light-theme"
msgstr "Cambiar a tema claro"
msgid "workspace.header.menu.toggle-dark-theme"
msgstr "Cambiar a tema oscuro"
#: src/app/main/ui/workspace/header.cljs #: src/app/main/ui/workspace/header.cljs
msgid "workspace.header.reset-zoom" msgid "workspace.header.reset-zoom"
msgstr "Restablecer" msgstr "Restablecer"