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: timeout, ommited means no timeout
;; Note: queue and timeout are excluding
{:update-file/by-profile
{:update-file/global {:permits 20}
:update-file/by-profile
{: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/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
{:permits 2}

View File

@ -129,6 +129,9 @@
:id (str team-id)
: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* bfc/collect-storage-objects fonts)

View File

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

View File

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

View File

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

View File

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

View File

@ -21,26 +21,31 @@
[app.worker :as-alias wrk]
[clojure.edn :as edn]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[integrant.core :as ig]
[promesa.core :as p]
[promesa.exec :as px]
[promesa.exec.bulkhead :as pbh])
(:import
clojure.lang.ExceptionInfo))
clojure.lang.ExceptionInfo
java.util.concurrent.atomic.AtomicLong))
(set! *warn-on-reflection* true)
(defn- id->str
[id]
(-> (str id)
(subs 1)))
([id]
(-> (str id)
(subs 1)))
([id key]
(if key
(str (-> (str id) (subs 1)) "/" key)
(id->str id))))
(defn- create-cache
[{:keys [::wrk/executor]}]
(letfn [(on-remove [key _ cause]
(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
:on-remove on-remove
:keepalive "5m")))
@ -81,132 +86,179 @@
(defn- create-limiter
[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))
:queue (or (:queue config) (:queue-size config))
:timeout (:timeout config)
:type :semaphore))
(defn- invoke!
[config cache metrics id key f]
(if-let [limiter (cache/get cache [id key] (partial create-limiter config))]
(let [tpoint (dt/tpoint)
labels (into-array String [(id->str id)])
wrapped (fn []
(let [elapsed (tpoint)
stats (pbh/get-stats limiter)]
(l/trc :hint "acquired"
: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))
(defmacro ^:private measure-and-log!
[metrics mlabels stats id action limit-id limit-label profile-id elapsed]
`(let [mpermits# (:max-permits ~stats)
mqueue# (:max-queue ~stats)
permits# (:permits ~stats)
queue# (:queue ~stats)
queue# (- queue# mpermits#)
queue# (if (neg? queue#) 0 queue#)
level# (if (pos? queue#) :warn :trace)]
(mtx/run! metrics
:id :rpc-climit-timing
:val (inst-ms elapsed)
:labels labels)
(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))]
(mtx/run! ~metrics
:id :rpc-climit-queue
:val queue#
:labels ~mlabels)
(try
(let [stats (pbh/get-stats limiter)]
(measure! stats)
(l/trc :hint "enqueued"
:id (id->str id)
:key key
:permits (:permits stats)
:queue (:queue stats)
:max-permits (:max-permits stats)
:max-queue (:max-queue stats))
(px/invoke! limiter wrapped))
(catch ExceptionInfo cause
(let [{:keys [type code]} (ex-data cause)]
(if (= :bulkhead-error type)
(mtx/run! ~metrics
:id :rpc-climit-permits
:val permits#
:labels ~mlabels)
(l/log level#
:hint ~action
:req ~id
:id ~limit-id
:label ~limit-label
:profile-id (str ~profile-id)
:permits permits#
:queue queue#
: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
:code code
:hint "concurrency limit reached")
(throw cause))))
:hint "concurrency limit reached"
:cause cause))
(throw cause))))
(finally
(measure! (pbh/get-stats limiter)))))
(do
(l/wrn :hint "no limiter found" :id (id->str id))
(f))))
(finally
(let [elapsed (tpoint)
stats (pbh/get-stats limiter)]
(measure-and-log! metrics mlabels stats id "finished" limit-id limit-label profile-id elapsed))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; 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
[{:keys [::rpc/climit ::mtx/metrics]} f {:keys [::id ::key-fn] :or {key-fn noop-fn} :as mdata}]
(if (and (some? climit) (some? id))
(let [cache (::cache climit)
config (::config climit)]
(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))
[{:keys [::rpc/climit ::mtx/metrics]} handler mdata]
(let [cache (::cache climit)
config (::config climit)
label (::sv/name mdata)]
(fn [cfg params]
(invoke! config cache metrics id (key-fn params) (partial f cfg params))))
(reduce (fn [handler [limit-id key-fn]]
(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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn configure
[{:keys [::rpc/climit]} id]
(us/assert! ::rpc/climit climit)
(assoc climit ::id id))
(defn- build-exec-chain
[{:keys [::label ::profile-id ::rpc/climit ::mtx/metrics] :as cfg} f]
(let [config (get climit ::config)
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.
Intended to be used in virtual threads."
([{:keys [::id ::cache ::config ::mtx/metrics]} f]
(if-let [config (get config id)]
(invoke! config cache metrics id nil f)
(f)))
([{:keys [::id ::cache ::config ::mtx/metrics]} f executor]
(let [f #(p/await! (px/submit! executor f))]
(if-let [config (get config id)]
(invoke! config cache metrics id nil f)
(f)))))
[{:keys [::executor] :as cfg} f & params]
(let [f (if (some? executor)
(fn [& params] (px/await! (px/submit! executor (fn [] (apply f params)))))
f)
f (build-exec-chain cfg f)]
(apply f params)))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,7 +27,8 @@
[app.worker :as-alias wrk]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[datoteka.io :as io]))
[datoteka.io :as io]
[promesa.exec :as px]))
(def default-max-file-size
(* 1024 1024 10)) ; 10 MiB
@ -56,20 +57,25 @@
:opt-un [::id]))
(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}]
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
(files/check-edition-permissions! pool profile-id file-id)
(media/validate-media-type! content)
(media/validate-media-size! content)
(let [object (db/run! cfg #(create-file-media-object % params))
props {:name (:name params)
:file-id file-id
:is-local (:is-local params)
:size (:size content)
:mtype (:mtype content)}]
(with-meta object
{::audit/replace-props props}))))
(db/run! cfg (fn [cfg]
(let [object (create-file-media-object cfg params)
props {:name (:name params)
:file-id file-id
:is-local (:is-local params)
:size (:size content)
:mtype (:mtype content)}]
(with-meta object
{::audit/replace-props props}))))))
(defn- big-enough-for-thumbnail?
"Checks if the provided image info is big enough for
@ -144,12 +150,10 @@
(assoc ::image (process-main-image info)))))
(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]}]
(let [result (-> (climit/configure cfg :process-image/global)
(climit/run! (partial process-image content) executor))
(let [result (px/invoke! executor (partial process-image content))
image (sto/put-object! storage (::image result))
thumb (when-let [params (::thumb result)]
(sto/put-object! storage params))]
@ -183,7 +187,7 @@
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
(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
[{:keys [::http/client]} uri]
@ -235,7 +239,16 @@
params (-> params
(assoc :content 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)

View File

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

View File

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

View File

@ -6,8 +6,8 @@
(ns app.rpc.retry
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.db :as db]
[app.util.services :as sv])
(:import
org.postgresql.util.PSQLException))
@ -15,12 +15,29 @@
(defn conflict-exception?
"Check if exception matches a insertion conflict on postgresql."
[e]
(and (instance? PSQLException e)
(= "23505" (.getSQLState ^PSQLException e))))
(when-let [cause (ex/instance? PSQLException e)]
(= "23505" (.getSQLState ^PSQLException cause))))
(def ^:private always-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
[_ f {:keys [::sv/name] :as mdata}]
@ -29,36 +46,10 @@
matches? (get mdata ::when always-false)]
(l/dbg :hint "wrapping retry" :name name :max-retries max-retries)
(fn [cfg params]
((fn recursive-invoke [retry]
(try
(f cfg params)
(catch Throwable cause
(if (matches? cause)
(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)))
(-> cfg
(assoc ::max-retries max-retries)
(assoc ::when matches?)
(assoc ::label name)
(invoke! f params))))
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-smtp
:enable-quotes
:enable-rpc-climit
:enable-feature-fdata-pointer-map
:enable-feature-fdata-objets-map
:enable-feature-components-v2

View File

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

View File

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

View File

@ -340,30 +340,43 @@
(grc/points->rect points))))
(defn content->selrect [content]
(let [calc-extremities
(fn [command prev]
(case (:command command)
:move-to [(command->point command)]
(let [extremities
(loop [points #{}
from-p nil
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
:line-to [(command->point prev)
(command->point command)]
[from-p move-p command-pts]
(case (:command 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
:curve-to (into [(command->point prev)
(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 %)))))
[]))
(recur (apply conj points command-pts) from-p move-p (next content)))
points))
extremities (mapcat calc-extremities
content
(concat [nil] content))]
(grc/points->rect extremities)))
;; We haven't found any extremes so we turn the commands to points
extremities
(if (empty? 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]
(let [dx (:x move-vec)

View File

@ -140,6 +140,28 @@
(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
([shape]
(transform-str shape nil))
@ -152,21 +174,6 @@
(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?
(defn transform-rect
"Transform a rectangles and changes its attributes"

View File

@ -17,7 +17,11 @@
[id objects]
(->> (tree-seq
#(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)
(map #(get objects %))))

View File

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

View File

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

View File

@ -35,8 +35,18 @@
(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
(def svg-tags-list
(def svg-tags
#{:a
:altGlyph
:altGlyphDef
@ -118,7 +128,7 @@
:vkern})
;; https://www.w3.org/TR/SVG11/attindex.html
(def svg-attr-list
(def svg-attrs
#{:accent-height
:accumulate
:additive
@ -212,26 +222,6 @@
:name
:numOctaves
: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
:order
:orient
@ -336,7 +326,8 @@
:z
:zoomAndPan})
(def svg-present-list
(def svg-presentation-attrs
"A set of presentation SVG attributes as per SVG spec."
#{:alignment-baseline
:baseline-shift
:clip-path
@ -399,52 +390,52 @@
:mask-type})
(def inheritable-props
[:style
:clip-rule
:color
:color-interpolation
:color-interpolation-filters
:color-profile
:color-rendering
:cursor
:direction
:dominant-baseline
:fill
:fill-opacity
:fill-rule
:font
:font-family
:font-size
:font-size-adjust
:font-stretch
:font-style
:font-variant
:font-weight
:glyph-orientation-horizontal
:glyph-orientation-vertical
:image-rendering
:letter-spacing
:marker
:marker-end
:marker-mid
:marker-start
:paint-order
:pointer-events
:shape-rendering
:stroke
:stroke-dasharray
:stroke-dashoffset
:stroke-linecap
:stroke-linejoin
:stroke-miterlimit
:stroke-opacity
:stroke-width
:text-anchor
:text-rendering
:transform
:visibility
:word-spacing
:writing-mode])
#{:style
:clip-rule
:color
:color-interpolation
:color-interpolation-filters
:color-profile
:color-rendering
:cursor
:direction
:dominant-baseline
:fill
:fill-opacity
:fill-rule
:font
:font-family
:font-size
:font-size-adjust
:font-stretch
:font-style
:font-variant
:font-weight
:glyph-orientation-horizontal
:glyph-orientation-vertical
:image-rendering
:letter-spacing
:marker
:marker-end
:marker-mid
:marker-start
:paint-order
:pointer-events
:shape-rendering
:stroke
:stroke-dasharray
:stroke-dashoffset
:stroke-linecap
:stroke-linejoin
:stroke-miterlimit
:stroke-opacity
:stroke-width
:text-anchor
:text-rendering
:transform
:visibility
:word-spacing
:writing-mode})
(def gradient-tags
#{:linearGradient
@ -517,9 +508,28 @@
:text
:view})
;; Props not supported by react we need to keep them lowercase
(def non-react-props
#{:mask-type})
(defn prop-key
"Convert an attr key to a react compatible prop key. Returns nil if key is empty or invalid"
[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
;; they are basically the defaults that can be percents and we need to replace because
@ -564,16 +574,6 @@
:else
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
[style]
(reduce (fn [res item]
@ -604,15 +604,13 @@
([attrs whitelist?]
(reduce-kv (fn [res k v]
(if (or (not whitelist?)
(contains? svg-attr-list k)
(contains? svg-present-list k))
(let [k (prop-key k)]
(cond
(nil? v)
(nil? k)
res
(= k :class)
(assoc res :className v)
(nil? v)
res
(= k :style)
(let [v (if (string? v) (parse-style v) v)
@ -622,11 +620,10 @@
res))
:else
(let [k (if (contains? non-react-props k)
k
(-> k d/name camelize keyword))]
(assoc res k v)))
res))
(if (or (not whitelist?) (contains? svg-props k))
(let [v (if (string? v) (str/trim v) v)]
(assoc res k v))
res))))
{}
attrs)))
@ -674,7 +671,7 @@
(let [remove-node? (fn [{:keys [tag]}] (and (some? 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))
node (assoc node :content (->> rec-result (map second) (filterv (comp not remove-node?))))

View File

@ -53,6 +53,9 @@
(def text-transform-attrs
[:text-transform])
(def text-fills
[:fills])
(def shape-attrs
[:grow-type])
@ -70,7 +73,8 @@
text-font-attrs
text-spacing-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))
@ -240,6 +244,21 @@
(run! #(.appendCodePoint sb (int %)) (subvec cpoints start end))
(.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
[content]
(letfn [(extract-text [cpoints part]
@ -247,7 +266,9 @@
end (inc (first (last part)))
text (code-points->text cpoints start end)
attrs (second (first part))]
(assoc attrs :text text)))
(-> attrs
(fix-gradients)
(assoc :text text))))
(split-texts [text styles]
(let [cpoints (text->code-points text)
@ -264,7 +285,8 @@
(let [key (get block :key)
text (get block :text)
styles (get block :inlineStyleRanges)
data (get block :data)]
data (->> (get block :data)
fix-gradients)]
(-> data
(assoc :key key)
(assoc :type "paragraph")

View File

@ -756,6 +756,9 @@
(ctl/flex-layout? shape)
(ctl/update-flex-scale value)
(ctl/grid-layout? shape)
(ctl/update-grid-scale value)
:always
(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 :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
[shape scale]
(-> shape

View File

@ -400,9 +400,21 @@
:parent-id parent-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))
(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-shapes (into [new-shape] new-children)

View File

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

View File

@ -357,6 +357,14 @@
--viewer-thumbnails-control-foreground-color: var(--color-foreground-secondary);
--viewer-thumbnail-border-color: var(--color-accent-primary);
--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 {
@ -383,4 +391,6 @@
--assets-item-name-background-color: var(--color-background-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.workspace.persistence :as dwp]
[app.main.data.workspace.state-helpers :as wsh]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.store :as st]
[app.util.dom :as dom]
@ -166,6 +167,13 @@
:wait true}]
(rx/concat
(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)
(rx/mapcat (fn [{:keys [id filename]}]
(->> (rp/cmd! :export {:cmd :get-resource :blob? true :id id})

View File

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

View File

@ -81,7 +81,8 @@
:page-id page-id
:rect (:selrect shape)
:include-frames? true
:full-frame? true})
:full-frame? true
:using-selrect? true})
(rx/map #(cfh/clean-loops objects %))
(rx/map #(dwsh/move-shapes-into-frame (: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.state-helpers :as wsh]
[app.main.data.workspace.thumbnails :as dwt]
[app.main.data.workspace.transforms :as dwtr]
[app.main.data.workspace.undo :as dwu]
[app.main.features :as features]
[app.main.features.pointer-map :as fpmap]
@ -534,34 +535,38 @@
(defn instantiate-component
"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."
[file-id component-id position]
(dm/assert! (uuid? file-id))
(dm/assert! (uuid? component-id))
(dm/assert! (gpt/point? position))
(ptk/reify ::instantiate-component
ptk/WatchEvent
(watch [it state _]
(let [page (wsh/lookup-page state)
libraries (wsh/get-libraries state)
([file-id component-id position]
(instantiate-component file-id component-id position nil))
([file-id component-id position {:keys [start-move? initial-point]}]
(dm/assert! (uuid? file-id))
(dm/assert! (uuid? component-id))
(dm/assert! (gpt/point? position))
(ptk/reify ::instantiate-component
ptk/WatchEvent
(watch [it state _]
(let [page (wsh/lookup-page state)
libraries (wsh/get-libraries state)
objects (:objects page)
changes (-> (pcb/empty-changes it (:id page))
(pcb/with-objects objects))
objects (:objects page)
changes (-> (pcb/empty-changes it (:id page))
(pcb/with-objects objects))
[new-shape changes]
(dwlh/generate-instantiate-component changes
objects
file-id
component-id
position
page
libraries)
undo-id (js/Symbol)]
(rx/of (dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(ptk/data-event :layout/update [(:id new-shape)])
(dws/select-shapes (d/ordered-set (:id new-shape)))
(dwu/commit-undo-transaction undo-id))))))
[new-shape changes]
(dwlh/generate-instantiate-component changes
objects
file-id
component-id
position
page
libraries)
undo-id (js/Symbol)]
(rx/of (dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(ptk/data-event :layout/update [(:id new-shape)])
(dws/select-shapes (d/ordered-set (:id new-shape)))
(when start-move?
(dwtr/start-move initial-point #{(:id new-shape)}))
(dwu/commit-undo-transaction undo-id)))))))
(defn detach-component
"Remove all references to components in the shape with the given id,

View File

@ -159,7 +159,9 @@
component
(:data library)
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)
(not (nil? parent-id))

View File

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

View File

@ -496,7 +496,7 @@
(when-let [node (dom/get-element-by-class "ghost-outline")]
(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 ids]
(ptk/reify ::start-move

View File

@ -591,3 +591,6 @@
(def updating-library
(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%;
height: fit-content;
text-align: left;
border: 1px solid transparent;
.icon-btn {
position: absolute;
display: flex;

View File

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

View File

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

View File

@ -12,15 +12,21 @@
(:require
[app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [tr]]
[app.util.object :as obj]
[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/props :obj}
[{:keys [aria-label is-pinned class] :as props}]
(let [aria-label (or aria-label (tr "dashboard.pin-unpin"))
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})]
[:> "button" props pin-icon]))
props (-> (obj/clone props)
(obj/unset! "isPinned")
(obj/set! "className" class)
(obj/set! "aria-label" aria-label))]
[:> "button" props pin-icon]))

View File

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

View File

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

View File

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

View File

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

View File

@ -50,13 +50,6 @@
(fn []
(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
[state ms func]
(assoc state :timer (ts/schedule ms func)))
@ -128,7 +121,7 @@
(do
(dom/stop-propagation event)
(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")
(when (fn? on-drag)
(on-drag data)))))

View File

@ -8,7 +8,10 @@
(:require
[app.common.data :as d]
[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.text :as gst]
[app.config :as cf]
[app.main.ui.shapes.attrs :as attrs]
[app.main.ui.shapes.gradients :as grad]
@ -28,7 +31,12 @@
fills (get shape :fills [])
selrect (dm/get-prop shape :selrect)
bounds (when (cfh/text-shape? shape)
(gst/shape->rect shape))
metadata (get shape :metadata)
x (dm/get-prop selrect :x)
y (dm/get-prop selrect :y)
width (dm/get-prop selrect :width)
@ -62,32 +70,50 @@
(obj/set! pat-props "patternTransform" transform)
pat-props)]
(for [[shape-index shape] (d/enumerate (or (:position-data shape) [shape]))]
[:* {:key (dm/str shape-index)}
(for [[fill-index value] (reverse (d/enumerate (get shape :fills [])))]
(for [[obj-index obj] (d/enumerate (or (:position-data shape) [shape]))]
[:* {:key (dm/str obj-index)}
(for [[fill-index value] (reverse (d/enumerate (get obj :fills [])))]
(when (some? (: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)
:key (dm/str fill-index)
:gradient gradient
:shape shape}]
(case (:type gradient)
:linear [:> grad/linear-gradient props]
:radial [:> grad/radial-gradient props]))))
:shape obj}]
(case (d/name (:type gradient))
"linear" [:> grad/linear-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)
(obj/set! "id" fill-id)
(cond-> has-image?
(cond-> (and has-image? (nil? bounds))
(-> (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
(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)
props #js {:key (dm/str fill-index)
:width width
:height height
:width (d/nilv (:width bounds) width)
:height (d/nilv (:height bounds) height)
:style style}]
(if (: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-tag? (map? content)
svg-leaf? (string? content)
valid-tag? (contains? csvg/svg-tags-list tag)]
valid-tag? (contains? csvg/svg-tags tag)]
(cond
^boolean svg-root?

View File

@ -93,7 +93,7 @@
:textTransform text-transform
:color (if (and show-text? (not gradient?)) text-color "transparent")
: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"
:lineBreak "auto"
:whiteSpace "break-spaces"

View File

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

View File

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

View File

@ -8,6 +8,7 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.main.ui.components.copy-button :refer [copy-button]]
[app.main.ui.components.title-bar :refer [title-bar]]
[app.util.code-gen.style-css :as css]
@ -19,9 +20,10 @@
(mf/defc geometry-block
[{:keys [objects shape]}]
[:*
(for [property properties]
(for [[idx property] (d/enumerate properties)]
(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-value)}
[:& copy-button {:data (css/get-css-property objects shape property)}

View File

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

View File

@ -8,727 +8,28 @@
(:require-macros [app.main.style :as stl])
(:require
[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.shortcuts :as scd]
[app.main.data.workspace :as dw]
[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.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.main.ui.workspace.main-menu :as main-menu]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.router :as rt]
[cuerdas.core :as str]
[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 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
(mf/defc left-header
{::mf/wrap-props false}
[{: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)
project-id (:id project)
team-id (:team-id project)
@ -809,9 +110,10 @@
(when ^boolean shared?
[:span {:class (stl/css :shared-badge)} i/library-refactor])
[:div {:class (stl/css :menu-section)}
[:& menu {:layout layout
:file file
:read-only? read-only?
:team-id team-id
:page-id page-id}]]]))
[:& main-menu/menu
{:layout layout
:file file
:profile profile
:read-only? read-only?
:team-id team-id
:page-id page-id}]]]))

View File

@ -77,97 +77,3 @@
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
(mf/with-memo [shared-libraries linked-libraries file-id search-term]
(->> shared-libraries
(remove #(= (:id %) file-id))
(remove #(contains? linked-libraries (:id %)))
(filter #(matches-search (:name %) search-term))
(sort-by (comp str/lower :name))))
(when shared-libraries
(->> shared-libraries
(remove #(= (:id %) file-id))
(remove #(contains? linked-libraries (:id %)))
(filter #(matches-search (:name %) search-term))
(sort-by (comp str/lower :name)))))
linked-libraries
(mf/with-memo [linked-libraries]
@ -275,12 +276,17 @@
:on-click link-library}
i/add-refactor]])]
[:div {:class (stl/css :section-list-empty)}
(if (nil? shared-libraries)
i/loader-pencil
(if (str/empty? search-term)
(when (empty? shared-libraries)
[:div {:class (stl/css :section-list-empty)}
(cond
(nil? shared-libraries)
(tr "workspace.libraries.loading")
(str/empty? search-term)
(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
[file-data library summary?]
@ -519,4 +525,3 @@
[:& updates-tab {:file-id file-id
:file-data file-data
: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
(:require
["draft-js" :as draft]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
@ -48,7 +49,7 @@
(let [children (obj/get props "children")]
[:span {:style {:background "#ccc" :display "inline-block"}} children]))
(defn render-block
(defn- render-block
[block shape]
(let [type (ted/get-editor-block-type block)]
(case type
@ -59,7 +60,7 @@
:shape shape}}
nil)))
(defn styles-fn [shape styles content]
(defn- styles-fn [shape styles content]
(let [data (if (= (.getText content) "")
(-> (.getData content)
(.toJS)
@ -73,19 +74,27 @@
(def empty-editor-state
(ted/create-editor-state nil default-decorator))
(defn get-blocks-to-setup [block-changes]
(defn- get-blocks-to-setup [block-changes]
(->> block-changes
(filter (fn [[_ v]]
(nil? (:old v))))
(mapv first)))
(defn get-blocks-to-add-styles
(defn- get-blocks-to-add-styles
[block-changes]
(->> block-changes
(filter (fn [[_ v]]
(and (not= (:old v) (:new v)) (= (:old v) ""))))
(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/wrap [mf/memo]
::mf/wrap-props false
@ -247,7 +256,8 @@
:custom-style-fn (partial styles-fn shape)
:block-renderer-fn #(render-block % shape)
:ref on-editor
:editor-state state}]]))
:editor-state state
:style #js {:border "1px solid red"}}]]))
(defn translate-point-from-viewport
"Translate a point in the viewport into client coordinates"
@ -303,7 +313,19 @@
(dm/get-prop shape :height))
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)
(obj/merge!
#js {:position "fixed"

View File

@ -472,13 +472,26 @@
(mf/use-fn
(mf/deps file-id)
(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
:component component})
(dnd/set-allowed-effect! event "move")))
(let [file-data
(d/nilv (dm/get-in @refs/workspace-libraries [file-id :data]) @refs/workspace-data)
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
(mf/use-fn
@ -569,4 +582,3 @@
{:option-name (tr "workspace.shape.menu.show-main")
:id "assets-show-main-component"
:option-handler on-show-main})]}]]]))

View File

@ -11,7 +11,7 @@
}
.asset-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;
max-width: 100%;
gap: $s-4;

View File

@ -61,7 +61,8 @@
:transform :matrix-render
:transform-inverse :matrix-render
:selrect :rect-render
:points :points-render})
:points :points-render
:layout-grid-cells :cells-render})
(mf/defc shape-link
[{:keys [id objects]}]
@ -69,6 +70,25 @@
:on-click #(st/emit! (dw/select-shape 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
[{:keys [attr value objects]}]
@ -79,7 +99,8 @@
:shape-list
[:div {:class (stl/css :shape-list)}
(for [id value]
[:& shape-link {:id id :objects objects}])]
[:& shape-link {:key (dm/str "child-" id)
:id id :objects objects}])]
:matrix-render
[:div (dm/str (gmt/format-precision value 2))]
@ -89,8 +110,11 @@
:points-render
[:div {:class (stl/css :point-list)}
(for [point value]
[:div (dm/fmt "(%, %)" (:x point) (:y point))])]
(for [[idx point] (d/enumerate value)]
[: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)]))

View File

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

View File

@ -84,7 +84,7 @@
(dom/focus! textarea))))
on-delete-annotation
(mf/use-callback
(mf/deps shape)
(mf/deps (:id shape))
(fn [event]
(dom/stop-propagation event)
(st/emit! (modal/show
@ -98,7 +98,7 @@
(dw/update-component-annotation component-id nil)))}))))]
(mf/use-effect
(mf/deps shape)
(mf/deps (:id shape))
(fn []
(initialize)
(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)}
[:& search-bar {:on-change on-filter-change
:value (:term @state)
:auto-focus true
:placeholder (tr "workspace.options.search-font")}]
(when (and recent-fonts show-recent)
[:section {:class (stl/css :show-recent)}

View File

@ -247,14 +247,13 @@
.text-options {
@include flexColumn;
margin-bottom: $s-8;
&:not(.text-options-full-size) {
position: relative;
}
.font-option {
@include titleTipography;
@extend .asset-element;
padding-right: 0;
padding: $s-8 0 $s-8 $s-8;
cursor: pointer;
.name {
flex-grow: 1;
@ -292,6 +291,7 @@
padding: 0;
.numeric-input {
@extend .input-base;
padding-inline-start: $s-8;
}
}
@ -375,39 +375,40 @@
padding: $s-12;
}
}
}
.font-wrapper {
padding-bottom: $s-4;
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);
}
}
}
.font-wrapper {
padding-bottom: $s-4;
cursor: pointer;
}
.label {
@include titleTipography;
flex-grow: 1;
.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 {
@include titleTipography;
flex-grow: 1;
}
}
.font-selector-dropdown-full-size {
@ -418,7 +419,6 @@
}
.fonts-list {
@include menuShadow;
position: relative;
display: flex;
flex-direction: column;
@ -439,4 +439,10 @@
border-start-start-radius: 0;
border-start-end-radius: 0;
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
:shape shape}]
(when (or (not ^boolean is-layout-child?) ^boolean is-layout-child-absolute?)
[:& constraints-menu {:ids ids
:values constraint-values}])
[:& layout-container-menu
{:type type
:ids [(:id shape)]
@ -81,6 +77,10 @@
:is-grid-parent? is-grid-parent?
: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
:type type
:values (select-keys shape fill-attrs)}]

View File

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

View File

@ -65,10 +65,6 @@
[:& 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
{:type type
:ids [(:id shape)]
@ -91,6 +87,9 @@
:is-layout-container? is-layout-container?
: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
:type type

View File

@ -72,9 +72,6 @@
[:& measures-menu {:type type :ids measure-ids :values measure-values :shape shape}]
[:& 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
{:type type
:ids [(:id shape)]
@ -96,6 +93,9 @@
:is-grid-parent? is-grid-parent?
: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)
[:& fill-menu {:type type :ids fill-ids :values fill-values}])

View File

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

View File

@ -364,9 +364,6 @@
(when-not (empty? 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
{:type type
:ids layout-container-ids
@ -383,6 +380,9 @@
:is-grid-parent? is-grid-parent?
: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)
[:& ot/text-menu {:type type :ids text-ids :values text-values}])

View File

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

View File

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

View File

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

View File

@ -93,11 +93,6 @@
:values (select-keys shape measure-attrs)
: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
{:type type
:ids [(:id shape)]
@ -119,6 +114,11 @@
:is-grid-parent? is-grid-parent?
: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
{:ids ids
:type type

View File

@ -174,9 +174,12 @@
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-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-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?
drawing-path? create-comment? space? panning z? workspace-read-only?)
@ -365,6 +368,7 @@
:on-double-click on-double-click
:on-drag-enter on-drag-enter
:on-drag-over on-drag-over
:on-drag-end on-drag-end
:on-drop on-drop
:on-pointer-down on-pointer-down
:on-pointer-enter on-pointer-enter

View File

@ -21,6 +21,7 @@
[app.main.data.workspace.specialized-panel :as-alias dwsp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.workspace.sidebar.assets.components :as wsac]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
[app.util.dom :as dom]
[app.util.dom.dnd :as dnd]
@ -28,7 +29,8 @@
[app.util.keyboard :as kbd]
[app.util.mouse :as mse]
[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]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
@ -216,7 +218,7 @@
(st/emit! (mse/->MouseEvent :double-click ctrl? shift? alt? meta?))
;; Emit asynchronously so the double click to exit shapes won't break
(timers/schedule
(ts/schedule
(fn []
(when (and (not drawing-path?) shape)
(cond
@ -244,7 +246,7 @@
workspace-read-only?)
(let [position (dom/get-client-position event)]
;; Delayed callback because we need to wait to the previous context menu to be closed
(timers/schedule
(ts/schedule
#(st/emit!
(if (some? @hover)
(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
(reset! disable-paste true)
(timers/schedule #(reset! disable-paste false)))
(ts/schedule #(reset! disable-paste false)))
(st/emit! (dw/finish-panning)
(dw/finish-zooming))))))
@ -400,9 +402,28 @@
(st/emit! (dw/update-viewport-position {:x #(+ % (/ delta-x zoom))
:y #(+ % (/ delta-y zoom))}))))))))))
(defn on-drag-enter []
(defn on-drag-enter
[comp-inst-ref]
(mf/use-callback
(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")
(dnd/has-type? e "penpot/component")
(dnd/has-type? e "Files")
@ -410,8 +431,19 @@
(dnd/has-type? e "text/asset-id"))
(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]
(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
(fn [e]
(when (or (dnd/has-type? e "penpot/shape")
@ -423,7 +455,7 @@
(dom/prevent-default e))))))
(defn on-drop
[file]
[file comp-inst-ref]
(mf/use-fn
(fn [event]
(dom/prevent-default event)
@ -443,13 +475,13 @@
(assoc :y final-y)))))
(dnd/has-type? event "penpot/component")
(let [{:keys [component file-id]} (dnd/get-data event "penpot/component")
shape (get-in component [:objects (:id component)])
final-x (- (:x viewport-coord) (/ (:width shape) 2))
final-y (- (:y viewport-coord) (/ (:height shape) 2))]
(st/emit! (dwl/instantiate-component file-id
(:id component)
(gpt/point final-x final-y))))
(let [event (.-nativeEvent event)
ctrl? (kbd/ctrl? event)
shift? (kbd/shift? event)
alt? (kbd/alt? event)
meta? (kbd/meta? event)]
(st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?))
(mf/set-ref-val! comp-inst-ref false))
;; Will trigger when the user drags an image from a browser
;; to the viewport (firefox and chrome do it a bit different
@ -517,4 +549,3 @@
(not @disable-paste)
(not workspace-read-only?))
(st/emit! (dw/paste-from-event event @in-viewport?)))))))

View File

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

View File

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

View File

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

View File

@ -3442,6 +3442,12 @@ msgstr "Show fonts palette"
msgid "workspace.header.menu.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
msgid "workspace.header.reset-zoom"
msgstr "Reset"
@ -3593,6 +3599,10 @@ msgstr "Search shared libraries"
msgid "workspace.libraries.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
msgid "workspace.libraries.text.multiple-typography"
msgstr "Multiple typographies"

View File

@ -3504,6 +3504,12 @@ msgstr "Mostrar paleta de textos"
msgid "workspace.header.menu.undo"
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
msgid "workspace.header.reset-zoom"
msgstr "Restablecer"