mirror of
https://github.com/penpot/penpot.git
synced 2026-02-27 12:16:55 -05:00
Compare commits
80 Commits
azazeln28-
...
eva-create
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76d85f7fa0 | ||
|
|
c49f9ab080 | ||
|
|
8956a78244 | ||
|
|
12fb7df068 | ||
|
|
9cdfcc1a9b | ||
|
|
be6b547252 | ||
|
|
8941b33ca5 | ||
|
|
908a639b69 | ||
|
|
ccfb3a063e | ||
|
|
071e2cd1a2 | ||
|
|
767a9a114c | ||
|
|
8305084739 | ||
|
|
192f1c8674 | ||
|
|
a7d2688711 | ||
|
|
d561c94c52 | ||
|
|
d680973c85 | ||
|
|
0d194decbf | ||
|
|
c72e9ee1a0 | ||
|
|
ba87ea1a44 | ||
|
|
72a855d4ac | ||
|
|
e2377e8fa8 | ||
|
|
c08cff68d7 | ||
|
|
a75de11e70 | ||
|
|
701443c3d7 | ||
|
|
7e287bacfd | ||
|
|
b4c279ad7b | ||
|
|
e4b69426e9 | ||
|
|
c972c06142 | ||
|
|
bcc755b0be | ||
|
|
d159244ea6 | ||
|
|
27c4ddba10 | ||
|
|
16a067c0ae | ||
|
|
90288e32d5 | ||
|
|
a82cf34d35 | ||
|
|
3f277b7daf | ||
|
|
21a1320f16 | ||
|
|
0a54d25d5a | ||
|
|
a19860a77b | ||
|
|
426c8ea714 | ||
|
|
75e8d226d9 | ||
|
|
d42f5db1f0 | ||
|
|
03d0c62de1 | ||
|
|
698852cbeb | ||
|
|
7cf88359fa | ||
|
|
ea4c6c3998 | ||
|
|
5cc5e8771e | ||
|
|
f8dd02169c | ||
|
|
ebdae2cf65 | ||
|
|
79d3469f36 | ||
|
|
942da56e78 | ||
|
|
6a49b5df8c | ||
|
|
141847585e | ||
|
|
2b34767b2b | ||
|
|
082c8adb1d | ||
|
|
6cfaeb8a44 | ||
|
|
d192cf8893 | ||
|
|
e6fde82609 | ||
|
|
ecc633efbe | ||
|
|
dafad0c124 | ||
|
|
11690e7428 | ||
|
|
c32a336c50 | ||
|
|
b87d7e3de0 | ||
|
|
d09c909788 | ||
|
|
9fa77cd06c | ||
|
|
8c5ce4d318 | ||
|
|
3c0df27fe0 | ||
|
|
a278d54429 | ||
|
|
a1cc016727 | ||
|
|
3d38aeb089 | ||
|
|
43725a4abe | ||
|
|
a0236e8c7e | ||
|
|
caccf72c7f | ||
|
|
60ecb901b2 | ||
|
|
fbf1240998 | ||
|
|
c55c23c6dd | ||
|
|
7a52550889 | ||
|
|
08fc6fe917 | ||
|
|
926d573d3e | ||
|
|
bac04f8a73 | ||
|
|
b4e815e787 |
1
.github/workflows/build-develop.yml
vendored
1
.github/workflows/build-develop.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: _DEVELOP
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '16 5-20 * * 1-5'
|
||||
|
||||
|
||||
1
.github/workflows/build-staging-render.yml
vendored
1
.github/workflows/build-staging-render.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: _STAGING RENDER
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '36 5-20 * * 1-5'
|
||||
|
||||
|
||||
1
.github/workflows/build-staging.yml
vendored
1
.github/workflows/build-staging.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: _STAGING
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '36 5-20 * * 1-5'
|
||||
|
||||
|
||||
1
.github/workflows/build-tag.yml
vendored
1
.github/workflows/build-tag.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: _TAG
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
22
CHANGES.md
22
CHANGES.md
@@ -1,5 +1,25 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.15.0 (Unreleased)
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
### :heart: Community contributions (Thank you!)
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112), [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
|
||||
- Add woff2 support on user uploaded fonts (by @Nivl) [Github #8248](https://github.com/penpot/penpot/pull/8248)
|
||||
- Option to download custom fonts (by @dfelinto) [Github #8320](https://github.com/penpot/penpot/issues/8320)
|
||||
- Add copy as image to clipboard option to workspace context menu (by @dfelinto) [Github #8313](https://github.com/penpot/penpot/pull/8313)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix Alt/Option to draw shapes from center point (by @offreal) [Github #8361](https://github.com/penpot/penpot/pull/8361)
|
||||
|
||||
|
||||
## 2.14.0 (Unreleased)
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
@@ -31,7 +51,7 @@
|
||||
- Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174)
|
||||
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
|
||||
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
|
||||
- Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306)
|
||||
- Fix unable to finish the create account form using keyboard [Taiga #11333](https://tree.taiga.io/project/penpot/issue/11333)
|
||||
|
||||
## 2.13.3
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
::yres/status 200
|
||||
::yres/body (yres/stream-body
|
||||
(fn [_ output]
|
||||
|
||||
(let [channel (sp/chan :buf buf :xf (keep encode))
|
||||
listener (events/spawn-listener
|
||||
channel
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
[:path ::fs/path]
|
||||
[:mtype {:optional true} ::sm/text]])
|
||||
|
||||
(def ^:private check-input
|
||||
(def check-input
|
||||
(sm/check-fn schema:input))
|
||||
|
||||
(defn validate-media-type!
|
||||
@@ -381,6 +381,22 @@
|
||||
(when (zero? (:exit res))
|
||||
(:out res))))
|
||||
|
||||
(woff2->sfnt [data]
|
||||
;; woff2_decompress outputs to same directory with .ttf extension
|
||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2")
|
||||
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
|
||||
(try
|
||||
(io/write* finput data)
|
||||
(let [res (sh/sh "woff2_decompress" (str finput))]
|
||||
(if (zero? (:exit res))
|
||||
foutput
|
||||
(do
|
||||
(when (fs/exists? foutput)
|
||||
(fs/delete foutput))
|
||||
nil)))
|
||||
(finally
|
||||
(fs/delete finput)))))
|
||||
|
||||
;; Documented here:
|
||||
;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
||||
(get-sfnt-type [data]
|
||||
@@ -430,4 +446,27 @@
|
||||
|
||||
(= stype :ttf)
|
||||
(-> (assoc "font/otf" (ttf->otf sfnt))
|
||||
(assoc "font/ttf" sfnt)))))))))
|
||||
(assoc "font/ttf" sfnt)))))
|
||||
|
||||
(contains? current "font/woff2")
|
||||
(let [data (get input "font/woff2")
|
||||
foutput (woff2->sfnt data)]
|
||||
(when-not foutput
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-woff2-file
|
||||
:hint "invalid woff2 file"))
|
||||
(try
|
||||
(let [sfnt (io/read* foutput)
|
||||
type (get-sfnt-type sfnt)]
|
||||
(cond-> input
|
||||
(= type :otf)
|
||||
(-> (assoc "font/otf" sfnt)
|
||||
(assoc "font/ttf" (otf->ttf sfnt))
|
||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))
|
||||
|
||||
(= type :ttf)
|
||||
(-> (assoc "font/ttf" sfnt)
|
||||
(assoc "font/otf" (ttf->otf sfnt))
|
||||
(update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt)))))
|
||||
(finally
|
||||
(fs/delete foutput))))))))
|
||||
|
||||
@@ -463,8 +463,10 @@
|
||||
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
|
||||
|
||||
{:name "0145-fix-plugins-uri-on-profile"
|
||||
:fn mg0145/migrate}])
|
||||
:fn mg0145/migrate}
|
||||
|
||||
{:name "0146-mod-access-token-table"
|
||||
:fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
|
||||
@@ -58,3 +58,4 @@
|
||||
(when (nil? (:data file))
|
||||
(migrate-file conn file)))
|
||||
(db/exec-one! conn ["drop table page cascade;"])))
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE access_token
|
||||
ADD COLUMN type text NULL;
|
||||
@@ -87,6 +87,10 @@
|
||||
[:map
|
||||
[:valid ::sm/boolean]])
|
||||
|
||||
(def ^:private schema:connectivity
|
||||
[:map
|
||||
[:licenses ::sm/boolean]])
|
||||
|
||||
(defn- get-team-org
|
||||
[cfg {:keys [team-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
@@ -97,6 +101,11 @@
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params)))
|
||||
|
||||
(defn- get-connectivity
|
||||
[cfg params]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get (str baseuri "/api/connectivity") schema:connectivity params)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; INITIALIZATION
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -105,7 +114,8 @@
|
||||
[_ cfg]
|
||||
(when (contains? cf/flags :nitrate)
|
||||
{:get-team-org (partial get-team-org cfg)
|
||||
:is-valid-user (partial is-valid-user cfg)}))
|
||||
:is-valid-user (partial is-valid-user cfg)
|
||||
:connectivity (partial get-connectivity cfg)}))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; UTILS
|
||||
@@ -128,3 +138,7 @@
|
||||
(let [params (assoc (or params {}) :team-id (:id team))
|
||||
org (call cfg :get-team-org params)]
|
||||
(assoc team :organization-id (:id org) :organization-name (:name org))))
|
||||
|
||||
(defn connectivity
|
||||
[cfg]
|
||||
(call cfg :connectivity {}))
|
||||
|
||||
@@ -73,9 +73,13 @@
|
||||
(if (nil? result)
|
||||
204
|
||||
200))
|
||||
headers (cond-> (::http/headers mdata {})
|
||||
(yres/stream-body? result)
|
||||
|
||||
headers (::http/headers mdata {})
|
||||
headers (cond-> headers
|
||||
(and (yres/stream-body? result)
|
||||
(not (contains? headers "content-type")))
|
||||
(assoc "content-type" "application/octet-stream"))]
|
||||
|
||||
{::yres/status status
|
||||
::yres/headers headers
|
||||
::yres/body result}))]
|
||||
@@ -258,6 +262,7 @@
|
||||
'app.rpc.commands.ldap
|
||||
'app.rpc.commands.management
|
||||
'app.rpc.commands.media
|
||||
'app.rpc.commands.nitrate
|
||||
'app.rpc.commands.profile
|
||||
'app.rpc.commands.projects
|
||||
'app.rpc.commands.search
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
(dissoc row :perms))
|
||||
|
||||
(defn create-access-token
|
||||
[{:keys [::db/conn] :as cfg} profile-id name expiration]
|
||||
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
|
||||
(let [token-id (uuid/next)
|
||||
expires-at (some-> expiration (ct/in-future))
|
||||
created-at (ct/now)
|
||||
@@ -36,6 +36,7 @@
|
||||
{:id token-id
|
||||
:name name
|
||||
:token token
|
||||
:type type
|
||||
:profile-id profile-id
|
||||
:created-at created-at
|
||||
:updated-at created-at
|
||||
@@ -50,17 +51,18 @@
|
||||
(def ^:private schema:create-access-token
|
||||
[:map {:title "create-access-token"}
|
||||
[:name [:string {:max 250 :min 1}]]
|
||||
[:expiration {:optional true} ::ct/duration]])
|
||||
[:expiration {:optional true} ::ct/duration]
|
||||
[:type {:optional true} :string]])
|
||||
|
||||
(sv/defmethod ::create-access-token
|
||||
{::doc/added "1.18"
|
||||
::sm/params schema:create-access-token}
|
||||
[cfg {:keys [::rpc/profile-id name expiration]}]
|
||||
[cfg {:keys [::rpc/profile-id name expiration type]}]
|
||||
|
||||
(quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
|
||||
::quotes/profile-id profile-id})
|
||||
|
||||
(db/tx-run! cfg create-access-token profile-id name expiration))
|
||||
(db/tx-run! cfg create-access-token profile-id name expiration type))
|
||||
|
||||
(def ^:private schema:delete-access-token
|
||||
[:map {:title "delete-access-token"}
|
||||
@@ -83,5 +85,22 @@
|
||||
(->> (db/query pool :access-token
|
||||
{:profile-id profile-id}
|
||||
{:order-by [[:expires-at :asc] [:created-at :asc]]
|
||||
:columns [:id :name :perms :created-at :updated-at :expires-at]})
|
||||
:columns [:id :name :perms :type :created-at :updated-at :expires-at]})
|
||||
(mapv decode-row)))
|
||||
|
||||
|
||||
(def ^:private schema:get-current-mcp-token
|
||||
[:map {:title "get-current-mcp-token"}])
|
||||
|
||||
(sv/defmethod ::get-current-mcp-token
|
||||
{::doc/added "2.15"
|
||||
::sm/params schema:get-current-mcp-token}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id ::rpc/request-at]}]
|
||||
(->> (db/query pool :access-token
|
||||
{:profile-id profile-id
|
||||
:type "mcp"}
|
||||
{:order-by [[:expires-at :asc] [:created-at :asc]]
|
||||
:columns [:token :expires-at]})
|
||||
(remove #(ct/is-after? (:expires-at %) request-at))
|
||||
(map decode-row)
|
||||
(first)))
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cmedia]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as-alias sql]
|
||||
[app.features.logical-deletion :as ldel]
|
||||
[app.http :as-alias http]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.media :as media]
|
||||
@@ -34,7 +36,9 @@
|
||||
java.io.InputStream
|
||||
java.io.OutputStream
|
||||
java.io.SequenceInputStream
|
||||
java.util.Collections))
|
||||
java.util.Collections
|
||||
java.util.zip.ZipEntry
|
||||
java.util.zip.ZipOutputStream))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@@ -296,3 +300,98 @@
|
||||
(rph/with-meta (rph/wrap)
|
||||
{::audit/props {:font-family (:font-family variant)
|
||||
:font-id (:font-id variant)}})))
|
||||
|
||||
;; --- DOWNLOAD FONT
|
||||
|
||||
(defn- make-temporal-storage-object
|
||||
[cfg profile-id content]
|
||||
(let [storage (sto/resolve cfg)
|
||||
content (media/check-input content)
|
||||
hash (sto/calculate-hash (:path content))
|
||||
data (-> (sto/content (:path content))
|
||||
(sto/wrap-with-hash hash))
|
||||
mtype (:mtype content "application/octet-stream")
|
||||
content {::sto/content data
|
||||
::sto/deduplicate? true
|
||||
::sto/touched-at (ct/in-future {:minutes 30})
|
||||
:profile-id profile-id
|
||||
:content-type mtype
|
||||
:bucket "tempfile"}]
|
||||
|
||||
(sto/put-object! storage content)))
|
||||
|
||||
(defn- make-variant-filename
|
||||
[v mtype]
|
||||
(str (:font-family v) "-" (:font-weight v)
|
||||
(when-not (= "normal" (:font-style v)) (str "-" (:font-style v)))
|
||||
(cmedia/mtype->extension mtype)))
|
||||
|
||||
(def ^:private schema:download-font
|
||||
[:map {:title "download-font"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::download-font
|
||||
"Download the font file. Returns a http redirect to the asset resource uri."
|
||||
{::doc/added "2.15"
|
||||
::sm/params schema:download-font}
|
||||
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||
(let [variant (db/get pool :team-font-variant {:id id})]
|
||||
(teams/check-read-permissions! pool profile-id (:team-id variant))
|
||||
|
||||
;; Try to get the best available font format (prefer TTF for broader compatibility).
|
||||
(let [media-id (or (:ttf-file-id variant)
|
||||
(:otf-file-id variant)
|
||||
(:woff2-file-id variant)
|
||||
(:woff1-file-id variant))
|
||||
sobj (sto/get-object storage media-id)
|
||||
mtype (-> sobj meta :content-type)]
|
||||
|
||||
{:id (:id sobj)
|
||||
:uri (files/resolve-public-uri (:id sobj))
|
||||
:name (make-variant-filename variant mtype)})))
|
||||
|
||||
(def ^:private schema:download-font-family
|
||||
[:map {:title "download-font-family"}
|
||||
[:font-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::download-font-family
|
||||
"Download the entire font family as a zip file. Returns the zip
|
||||
bytes on the body, without encoding it on transit or json."
|
||||
{::doc/added "2.15"
|
||||
::sm/params schema:download-font-family}
|
||||
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [::rpc/profile-id font-id]}]
|
||||
(let [variants (db/query pool :team-font-variant
|
||||
{:font-id font-id
|
||||
:deleted-at nil})]
|
||||
|
||||
(when-not (seq variants)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found))
|
||||
|
||||
(teams/check-read-permissions! pool profile-id (:team-id (first variants)))
|
||||
|
||||
(let [tempfile (tmp/tempfile :suffix ".zip")
|
||||
ffamily (-> variants first :font-family)]
|
||||
|
||||
(with-open [^OutputStream output (io/output-stream tempfile)
|
||||
^OutputStream output (ZipOutputStream. output)]
|
||||
(doseq [v variants]
|
||||
(let [media-id (or (:ttf-file-id v)
|
||||
(:otf-file-id v)
|
||||
(:woff2-file-id v)
|
||||
(:woff1-file-id v))
|
||||
sobj (sto/get-object storage media-id)
|
||||
mtype (-> sobj meta :content-type)
|
||||
name (make-variant-filename v mtype)]
|
||||
|
||||
(with-open [input (sto/get-object-data storage sobj)]
|
||||
(.putNextEntry ^ZipOutputStream output (ZipEntry. ^String name))
|
||||
(io/copy input output :size (:size sobj))
|
||||
(.closeEntry ^ZipOutputStream output)))))
|
||||
|
||||
(let [{:keys [id] :as sobj} (make-temporal-storage-object cfg profile-id
|
||||
{:mtype "application/zip"
|
||||
:path tempfile})]
|
||||
{:id id
|
||||
:uri (files/resolve-public-uri id)
|
||||
:name (str ffamily ".zip")}))))
|
||||
|
||||
20
backend/src/app/rpc/commands/nitrate.clj
Normal file
20
backend/src/app/rpc/commands/nitrate.clj
Normal file
@@ -0,0 +1,20 @@
|
||||
(ns app.rpc.commands.nitrate
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]))
|
||||
|
||||
|
||||
(def schema:connectivity
|
||||
[:map {:title "nitrate-connectivity"}
|
||||
[:licenses ::sm/boolean]])
|
||||
|
||||
(sv/defmethod ::get-nitrate-connectivity
|
||||
{::rpc/auth false
|
||||
::doc/added "1.18"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:connectivity}
|
||||
[cfg _params]
|
||||
(nitrate/connectivity cfg))
|
||||
@@ -48,6 +48,7 @@
|
||||
(def schema:props
|
||||
[:map {:title "ProfileProps"}
|
||||
[:plugins {:optional true} schema:plugin-registry]
|
||||
[:mcp-status {:optional true} ::sm/boolean]
|
||||
[:newsletter-updates {:optional true} ::sm/boolean]
|
||||
[:newsletter-news {:optional true} ::sm/boolean]
|
||||
[:onboarding-team-id {:optional true} ::sm/uuid]
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
|
||||
(t/deftest access-token-authz
|
||||
(let [profile (th/create-profile* 1)
|
||||
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
|
||||
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil nil)
|
||||
handler (#'app.http.access-token/wrap-authz identity th/*system*)]
|
||||
|
||||
(let [response (handler nil)]
|
||||
|
||||
@@ -107,4 +107,18 @@
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(let [results (:result out)]
|
||||
(t/is (= 2 (count results))))))))
|
||||
(t/is (= 2 (count results))))))
|
||||
|
||||
(t/testing "get mcp token"
|
||||
(let [_ (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:type "mcp"
|
||||
:name "token 1"
|
||||
:perms ["get-profile"]})
|
||||
{:keys [error result]}
|
||||
(th/command! {::th/type :get-current-mcp-token
|
||||
::rpc/profile-id (:id prof)})]
|
||||
;; (th/print-result! result)
|
||||
(t/is (nil? error))
|
||||
(t/is (string? (:token result)))))))
|
||||
|
||||
|
||||
@@ -93,6 +93,41 @@
|
||||
:font-weight
|
||||
:font-style))))
|
||||
|
||||
(t/deftest woff2-font-upload-1
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
proj-id (:default-project-id prof)
|
||||
font-id (uuid/custom 10 1)
|
||||
|
||||
data (-> (io/resource "backend_tests/test_files/font-1.woff2")
|
||||
(io/read*))
|
||||
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "somefont"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:data {"font/woff2" data}}
|
||||
out (th/command! params)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(let [result (:result out)]
|
||||
(t/is (uuid? (:id result)))
|
||||
(t/is (uuid? (:ttf-file-id result)))
|
||||
(t/is (uuid? (:otf-file-id result)))
|
||||
(t/is (uuid? (:woff1-file-id result)))
|
||||
(t/is (uuid? (:woff2-file-id result)))
|
||||
(t/are [k] (= (get params k)
|
||||
(get result k))
|
||||
:team-id
|
||||
:font-id
|
||||
:font-family
|
||||
:font-weight
|
||||
:font-style))))
|
||||
|
||||
(t/deftest font-deletion-1
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
|
||||
BIN
backend/test/backend_tests/test_files/font-1.woff2
Normal file
BIN
backend/test/backend_tests/test_files/font-1.woff2
Normal file
Binary file not shown.
@@ -760,6 +760,21 @@
|
||||
default
|
||||
v))))
|
||||
|
||||
(defn percent?
|
||||
[v]
|
||||
(str/numeric? (str/rtrim v "%")))
|
||||
|
||||
(defn parse-percent
|
||||
([v]
|
||||
(parse-percent v nil))
|
||||
([v default]
|
||||
(if (str/ends-with? v "%")
|
||||
(let [v (impl-parse-double (str/trim v "%"))]
|
||||
(if (or (nil? v) (nan? v))
|
||||
default
|
||||
(/ v 100)))
|
||||
(parse-double v default))))
|
||||
|
||||
(defn parse-uuid
|
||||
[v]
|
||||
(try
|
||||
|
||||
@@ -31,18 +31,56 @@
|
||||
(def schema:token-value-generic
|
||||
[::sm/text {:error/fn token-value-empty-fn}])
|
||||
|
||||
(def schema:token-value-numeric
|
||||
[:and
|
||||
[::sm/text {:error/fn token-value-empty-fn}]
|
||||
[:fn {:error/fn #(tr "workspace.tokens.invalid-value" (:value %))}
|
||||
(fn [value]
|
||||
(if (str/numeric? value)
|
||||
(let [n (d/parse-double value)]
|
||||
(some? n))
|
||||
true))]]) ;; Leave references or formulas to be checked by the resolver
|
||||
|
||||
(def schema:token-value-percent
|
||||
[:and
|
||||
[::sm/text {:error/fn token-value-empty-fn}]
|
||||
[:fn {:error/fn #(tr "workspace.tokens.value-with-percent" (:value %))}
|
||||
(fn [value]
|
||||
(if (d/percent? value)
|
||||
(let [v (d/parse-percent value)]
|
||||
(some? v))
|
||||
true))]]) ;; Leave references or formulas to be checked by the resolver
|
||||
|
||||
(def schema:token-value-composite-ref
|
||||
[::sm/text {:error/fn token-value-empty-fn}])
|
||||
|
||||
(def schema:token-value-opacity
|
||||
[:and
|
||||
[::sm/text {:error/fn token-value-empty-fn}]
|
||||
[:fn {:error/fn #(tr "workspace.tokens.opacity-range")}
|
||||
(fn [opacity]
|
||||
(if (str/numeric? opacity)
|
||||
(let [n (d/parse-percent opacity)]
|
||||
(and (some? n) (<= 0 n 1)))
|
||||
true))]]) ;; Leave references or formulas to be checked by the resolver
|
||||
|
||||
(def schema:token-value-font-family
|
||||
[:vector ::sm/text])
|
||||
[:or
|
||||
[:vector ::sm/text]
|
||||
cto/schema:token-ref])
|
||||
|
||||
(def schema:token-value-font-weight
|
||||
[:or
|
||||
[:fn {:error/fn #(tr "workspace.tokens.invalid-font-weight-token-value")}
|
||||
cto/valid-font-weight-variant]
|
||||
::sm/text]) ;; Leave references or formulas to be checked by the resolver
|
||||
|
||||
(def schema:token-value-typography-map
|
||||
[:map
|
||||
[:font-family {:optional true} schema:token-value-font-family]
|
||||
[:font-weight {:optional true} schema:token-value-generic]
|
||||
[:font-size {:optional true} schema:token-value-generic]
|
||||
[:line-height {:optional true} schema:token-value-generic]
|
||||
[:font-size {:optional true} schema:token-value-numeric]
|
||||
[:font-weight {:optional true} schema:token-value-font-weight]
|
||||
[:line-height {:optional true} schema:token-value-percent]
|
||||
[:letter-spacing {:optional true} schema:token-value-generic]
|
||||
[:paragraph-spacing {:optional true} schema:token-value-generic]
|
||||
[:text-decoration {:optional true} schema:token-value-generic]
|
||||
@@ -84,7 +122,10 @@
|
||||
[token-type]
|
||||
[:multi {:dispatch (constantly token-type)
|
||||
:title "Token Value"}
|
||||
[:opacity schema:token-value-opacity]
|
||||
[:font-family schema:token-value-font-family]
|
||||
[:font-size schema:token-value-numeric]
|
||||
[:font-weight schema:token-value-font-weight]
|
||||
[:typography schema:token-value-typography]
|
||||
[:shadow schema:token-value-shadow]
|
||||
[::m/default schema:token-value-generic]])
|
||||
@@ -169,7 +210,7 @@
|
||||
[tokens-lib set-id]
|
||||
[:and
|
||||
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
|
||||
[:fn {:error/fn #(tr "errors.token-set-already-exists" (:value %))}
|
||||
[:fn {:error/fn #(tr "errors.token-set-already-exists")}
|
||||
(fn [name]
|
||||
(or (nil? tokens-lib)
|
||||
(let [set (ctob/get-set-by-name tokens-lib name)]
|
||||
@@ -196,7 +237,7 @@
|
||||
[tokens-lib name theme-id]
|
||||
[:and
|
||||
[:string {:min 0 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
|
||||
[:fn {:error/fn #(tr "errors.token-theme-already-exists" (:value %))}
|
||||
[:fn {:error/fn #(tr "errors.token-theme-already-exists")}
|
||||
(fn [group]
|
||||
(or (nil? tokens-lib)
|
||||
(let [theme (ctob/get-theme-by-name tokens-lib group name)]
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
;; Only for developtment.
|
||||
:tiered-file-data-storage
|
||||
:token-base-font-size
|
||||
:token-combobox
|
||||
:token-color
|
||||
:token-shadow
|
||||
:token-tokenscript
|
||||
@@ -152,7 +153,9 @@
|
||||
:redis-cache
|
||||
|
||||
;; Activates the nitrate module
|
||||
:nitrate})
|
||||
:nitrate
|
||||
|
||||
:mcp})
|
||||
|
||||
(def all-flags
|
||||
(set/union email login varia))
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
(def font-types
|
||||
#{"font/ttf"
|
||||
"font/woff"
|
||||
"font/woff2"
|
||||
"font/otf"
|
||||
"font/opentype"})
|
||||
|
||||
@@ -81,21 +82,22 @@
|
||||
(defn parse-font-weight
|
||||
[variant]
|
||||
(cond
|
||||
(re-seq #"(?i)(?:hairline|thin)" variant) 100
|
||||
(re-seq #"(?i)(?:extra\s*light|ultra\s*light)" variant) 200
|
||||
(re-seq #"(?i)(?:light)" variant) 300
|
||||
(re-seq #"(?i)(?:normal|regular)" variant) 400
|
||||
(re-seq #"(?i)(?:medium)" variant) 500
|
||||
(re-seq #"(?i)(?:semi\s*bold|demi\s*bold)" variant) 600
|
||||
(re-seq #"(?i)(?:extra\s*bold|ultra\s*bold)" variant) 800
|
||||
(re-seq #"(?i)(?:bold)" variant) 700
|
||||
(re-seq #"(?i)(?:extra\s*black|ultra\s*black)" variant) 950
|
||||
(re-seq #"(?i)(?:black|heavy|solid)" variant) 900
|
||||
:else 400))
|
||||
(re-seq #"(?i)(?:^|[-_\s])(hairline|thin)(?=(?:[-_\s]|$|italic\b))" variant) 100
|
||||
(re-seq #"(?i)(?:^|[-_\s])(extra\s*light|ultra\s*light)(?=(?:[-_\s]|$|italic\b))" variant) 200
|
||||
(re-seq #"(?i)(?:^|[-_\s])(light)(?=(?:[-_\s]|$|italic\b))" variant) 300
|
||||
(re-seq #"(?i)(?:^|[-_\s])(normal|regular)(?=(?:[-_\s]|$|italic\b))" variant) 400
|
||||
(re-seq #"(?i)(?:^|[-_\s])(medium)(?=(?:[-_\s]|$|italic\b))" variant) 500
|
||||
(re-seq #"(?i)(?:^|[-_\s])(semi\s*bold|demi\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 600
|
||||
(re-seq #"(?i)(?:^|[-_\s])(extra\s*bold|ultra\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 800
|
||||
(re-seq #"(?i)(?:^|[-_\s])(bold)(?=(?:[-_\s]|$|italic\b))" variant) 700
|
||||
(re-seq #"(?i)(?:^|[-_\s])(extra\s*black|ultra\s*black)(?=(?:[-_\s]|$|italic\b))" variant) 950
|
||||
(re-seq #"(?i)(?:^|[-_\s])(black|heavy|solid)(?=(?:[-_\s]|$|italic\b))" variant) 900
|
||||
:else 400))
|
||||
|
||||
(defn parse-font-style
|
||||
[variant]
|
||||
(if (re-seq #"(?i)(?:italic)" variant)
|
||||
(if (or (re-seq #"(?i)(?:^|[-_\s])(italic)(?:[-_\s]|$)" variant)
|
||||
(re-seq #"(?i)italic$" variant))
|
||||
"italic"
|
||||
"normal"))
|
||||
|
||||
|
||||
@@ -143,6 +143,15 @@
|
||||
:gen/gen sg/text}
|
||||
token-name-validation-regex])
|
||||
|
||||
(def token-ref-validation-regex
|
||||
#"^\{[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*\}$")
|
||||
|
||||
(def schema:token-ref
|
||||
"A token reference is a token name enclosed in {}."
|
||||
[:re {:title "TokenRef"
|
||||
:gen/gen sg/text}
|
||||
token-ref-validation-regex])
|
||||
|
||||
(def schema:token-type
|
||||
[::sm/one-of {:decode/json (fn [type]
|
||||
(if (string? type)
|
||||
|
||||
@@ -9,6 +9,39 @@
|
||||
[app.common.media :as media]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/deftest test-parse-font-weight
|
||||
(t/testing "matches weight tokens with proper boundaries"
|
||||
(t/is (= 700 (media/parse-font-weight "Roboto-Bold")))
|
||||
(t/is (= 700 (media/parse-font-weight "Roboto_Bold")))
|
||||
(t/is (= 700 (media/parse-font-weight "Roboto Bold")))
|
||||
(t/is (= 700 (media/parse-font-weight "Bold")))
|
||||
(t/is (= 800 (media/parse-font-weight "Roboto-ExtraBold")))
|
||||
(t/is (= 600 (media/parse-font-weight "OpenSans-SemiBold")))
|
||||
(t/is (= 300 (media/parse-font-weight "Lato-Light")))
|
||||
(t/is (= 100 (media/parse-font-weight "Roboto-Thin")))
|
||||
(t/is (= 200 (media/parse-font-weight "Roboto-ExtraLight")))
|
||||
(t/is (= 500 (media/parse-font-weight "Roboto-Medium")))
|
||||
(t/is (= 900 (media/parse-font-weight "Roboto-Black"))))
|
||||
|
||||
(t/testing "does not match weight tokens embedded in words"
|
||||
(t/is (= 400 (media/parse-font-weight "Boldini")))
|
||||
(t/is (= 400 (media/parse-font-weight "Lighthaus")))
|
||||
(t/is (= 400 (media/parse-font-weight "Blackwood")))
|
||||
(t/is (= 400 (media/parse-font-weight "Thinker")))
|
||||
(t/is (= 400 (media/parse-font-weight "Mediaeval")))))
|
||||
|
||||
(t/deftest test-parse-font-style
|
||||
(t/testing "matches italic with proper boundaries"
|
||||
(t/is (= "italic" (media/parse-font-style "Roboto-Italic")))
|
||||
(t/is (= "italic" (media/parse-font-style "Roboto_Italic")))
|
||||
(t/is (= "italic" (media/parse-font-style "Roboto Italic")))
|
||||
(t/is (= "italic" (media/parse-font-style "Italic")))
|
||||
(t/is (= "italic" (media/parse-font-style "Roboto-BoldItalic"))))
|
||||
|
||||
(t/testing "does not match italic embedded in words"
|
||||
(t/is (= "normal" (media/parse-font-style "Italica")))
|
||||
(t/is (= "normal" (media/parse-font-style "Roboto-Regular")))))
|
||||
|
||||
(t/deftest test-strip-image-extension
|
||||
(t/testing "removes extension from supported image files"
|
||||
(t/is (= (media/strip-image-extension "foo.png") "foo"))
|
||||
|
||||
@@ -100,12 +100,14 @@
|
||||
|
||||
(def browser-pool-factory
|
||||
(letfn [(create []
|
||||
(p/let [opts #js {:args #js ["--allow-insecure-localhost" "--font-render-hinting=none"]}
|
||||
browser (.launch pw/chromium opts)
|
||||
id (swap! pool-browser-id inc)]
|
||||
(l/info :origin "factory" :action "create" :browser-id id)
|
||||
(unchecked-set browser "__id" id)
|
||||
browser))
|
||||
(-> (p/let [opts #js {:args #js ["--allow-insecure-localhost" "--font-render-hinting=none"]}
|
||||
browser (.launch pw/chromium opts)
|
||||
id (swap! pool-browser-id inc)]
|
||||
(l/info :origin "factory" :action "create" :browser-id id)
|
||||
(unchecked-set browser "__id" id)
|
||||
browser)
|
||||
(p/catch (fn [cause]
|
||||
(l/error :hint "Cannot launch the headless browser" :cause cause)))))
|
||||
|
||||
(destroy [obj]
|
||||
(let [id (unchecked-get obj "__id")]
|
||||
|
||||
@@ -47,12 +47,13 @@
|
||||
|
||||
(s/def ::params
|
||||
(s/keys :req-un [::exports ::profile-id]
|
||||
:opt-un [::wait ::name ::skip-children]))
|
||||
:opt-un [::wait ::name ::skip-children ::force-multiple]))
|
||||
|
||||
(defn handler
|
||||
[{:keys [:request/auth-token] :as exchange} {:keys [exports] :as params}]
|
||||
[{:keys [:request/auth-token] :as exchange} {:keys [exports force-multiple] :as params}]
|
||||
(let [exports (prepare-exports exports auth-token)]
|
||||
(if (and (= 1 (count exports))
|
||||
(if (and (not force-multiple)
|
||||
(= 1 (count exports))
|
||||
(= 1 (count (-> exports first :objects))))
|
||||
(handle-single-export exchange (-> params
|
||||
(assoc :export (first exports))
|
||||
|
||||
@@ -404,8 +404,6 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
return content !== "";
|
||||
}, { timeout: 1000 });
|
||||
|
||||
await this.page.waitForTimeout(3000);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -419,8 +417,7 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
await this.viewport.click({ button: "right" });
|
||||
return this.page.getByText("Paste", { exact: true }).click();
|
||||
}
|
||||
await this.page.keyboard.press("ControlOrMeta+V");
|
||||
await this.page.waitForTimeout(3000);
|
||||
return this.page.keyboard.press("ControlOrMeta+V");
|
||||
}
|
||||
|
||||
async panOnViewportAt(x, y, width, height) {
|
||||
|
||||
@@ -97,6 +97,7 @@ test("Update an already created text shape by prepending text", async ({
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.moveFromStart(0);
|
||||
await page.evaluate(() => new Promise((resolve) => globalThis.requestIdleCallback(resolve)));
|
||||
await page.keyboard.type("Dolor sit amet ");
|
||||
await workspace.textEditor.stopEditing();
|
||||
await workspace.waitForSelectedShapeName("Dolor sit amet Lorem ipsum");
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WorkspacePage } from "../../pages/WorkspacePage";
|
||||
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
|
||||
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
|
||||
import {
|
||||
setupEmptyTokensFile,
|
||||
setupTokensFile,
|
||||
setupTypographyTokensFile,
|
||||
setupTokensFileRender,
|
||||
setupTypographyTokensFileRender,
|
||||
unfoldTokenTree,
|
||||
} from "./helpers";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WorkspacePage.init(page);
|
||||
await WasmWorkspacePage.init(page);
|
||||
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
|
||||
});
|
||||
|
||||
test.describe("Tokens: Apply token", () => {
|
||||
test("User applies color token to a shape", async ({ page }) => {
|
||||
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
await page.getByRole("tab", { name: "Layers" }).click();
|
||||
|
||||
@@ -44,7 +43,9 @@ test.describe("Tokens: Apply token", () => {
|
||||
page,
|
||||
}) => {
|
||||
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page, {
|
||||
flags: ["enable-token-combobox", "enable-feature-token-input"],
|
||||
});
|
||||
|
||||
await page.getByRole("tab", { name: "Layers" }).click();
|
||||
|
||||
@@ -83,7 +84,9 @@ test.describe("Tokens: Apply token", () => {
|
||||
await brTokenPillSM.click();
|
||||
|
||||
// Change token from dropdown
|
||||
const brTokenOptionXl = borderRadiusSection.getByLabel("borderRadius.xl");
|
||||
const brTokenOptionXl = borderRadiusSection
|
||||
.getByRole("option", { name: "borderRadius.xl" })
|
||||
.getByLabel("borderRadius.xl");
|
||||
await expect(brTokenOptionXl).toBeVisible();
|
||||
await brTokenOptionXl.click();
|
||||
|
||||
@@ -105,7 +108,7 @@ test.describe("Tokens: Apply token", () => {
|
||||
page,
|
||||
}) => {
|
||||
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
await page.getByRole("tab", { name: "Layers" }).click();
|
||||
|
||||
@@ -169,7 +172,7 @@ test.describe("Tokens: Apply token", () => {
|
||||
|
||||
test("User applies typography token to a text shape", async ({ page }) => {
|
||||
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTypographyTokensFile(page);
|
||||
await setupTypographyTokensFileRender(page);
|
||||
|
||||
await page.getByRole("tab", { name: "Layers" }).click();
|
||||
|
||||
@@ -203,7 +206,7 @@ test.describe("Tokens: Apply token", () => {
|
||||
tokensSidebar,
|
||||
workspacePage,
|
||||
tokenContextMenuForToken,
|
||||
} = await setupTokensFile(page, { flags: ["enable-token-shadow"] });
|
||||
} = await setupTokensFileRender(page, { flags: ["enable-token-shadow"] });
|
||||
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
|
||||
@@ -489,7 +492,7 @@ test.describe("Tokens: Apply token", () => {
|
||||
page,
|
||||
}) => {
|
||||
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
// Unfolds dimensions on token panel
|
||||
await page.getByRole("tab", { name: "Layers" }).click();
|
||||
@@ -518,7 +521,9 @@ test.describe("Tokens: Apply token", () => {
|
||||
await dimensionSMTokenPill.nth(1).click();
|
||||
|
||||
// Change token from dropdown
|
||||
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
|
||||
const dimensionTokenOptionXl = measuresSection.getByRole("option", {
|
||||
name: "dimension.xl",
|
||||
});
|
||||
await expect(dimensionTokenOptionXl).toBeVisible();
|
||||
await dimensionTokenOptionXl.click();
|
||||
|
||||
@@ -540,7 +545,7 @@ test.describe("Tokens: Apply token", () => {
|
||||
page,
|
||||
}) => {
|
||||
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
// Unfolds dimensions on token panel
|
||||
await page.getByRole("tab", { name: "Layers" }).click();
|
||||
@@ -572,7 +577,9 @@ test.describe("Tokens: Apply token", () => {
|
||||
await dimensionSMTokenPill.click();
|
||||
|
||||
// Change token from dropdown
|
||||
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
|
||||
const dimensionTokenOptionXl = measuresSection.getByRole("option", {
|
||||
name: "dimension.xl",
|
||||
});
|
||||
await expect(dimensionTokenOptionXl).toBeVisible();
|
||||
await dimensionTokenOptionXl.click();
|
||||
|
||||
@@ -594,7 +601,7 @@ test.describe("Tokens: Apply token", () => {
|
||||
page,
|
||||
}) => {
|
||||
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
// Unfolds dimensions on token panel
|
||||
await page.getByRole("tab", { name: "Layers" }).click();
|
||||
@@ -626,7 +633,9 @@ test.describe("Tokens: Apply token", () => {
|
||||
await dimensionSMTokenPill.click();
|
||||
|
||||
// Change token from dropdown
|
||||
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
|
||||
const dimensionTokenOptionXl = measuresSection.getByRole("option", {
|
||||
name: "dimension.xl",
|
||||
});
|
||||
await expect(dimensionTokenOptionXl).toBeVisible();
|
||||
await dimensionTokenOptionXl.click();
|
||||
|
||||
@@ -648,7 +657,7 @@ test.describe("Tokens: Apply token", () => {
|
||||
page,
|
||||
}) => {
|
||||
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
// Unfolds dimensions on token panel
|
||||
await page.getByRole("tab", { name: "Layers" }).click();
|
||||
@@ -681,8 +690,9 @@ test.describe("Tokens: Apply token", () => {
|
||||
await dimensionXSTokenPill.click();
|
||||
|
||||
// Change token from dropdown
|
||||
const dimensionTokenOptionXl =
|
||||
borderRadiusSection.getByLabel("dimension.xl");
|
||||
const dimensionTokenOptionXl = borderRadiusSection.getByRole("option", {
|
||||
name: "dimension.xl",
|
||||
});
|
||||
await expect(dimensionTokenOptionXl).toBeVisible();
|
||||
await dimensionTokenOptionXl.click();
|
||||
|
||||
@@ -701,7 +711,7 @@ test.describe("Tokens: Apply token", () => {
|
||||
});
|
||||
|
||||
test("User applies stroke width token to a shape", async ({ page }) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
const workspace = new WasmWorkspacePage(page, {
|
||||
textEditor: true,
|
||||
});
|
||||
// Set up
|
||||
@@ -751,7 +761,9 @@ test.describe("Tokens: Apply token", () => {
|
||||
});
|
||||
await tokenDropdown.click();
|
||||
|
||||
const widthOptionSmall = firstStrokeRow.getByLabel("width-small");
|
||||
const widthOptionSmall = firstStrokeRow.getByRole("option", {
|
||||
name: "width-small",
|
||||
});
|
||||
await expect(widthOptionSmall).toBeVisible();
|
||||
await widthOptionSmall.click();
|
||||
const StrokeWidthPillSmall = firstStrokeRow.getByRole("button", {
|
||||
@@ -761,7 +773,7 @@ test.describe("Tokens: Apply token", () => {
|
||||
});
|
||||
|
||||
test("User applies margin token to a shape", async ({ page }) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
const workspace = new WasmWorkspacePage(page, {
|
||||
textEditor: true,
|
||||
});
|
||||
// Set up
|
||||
@@ -853,7 +865,7 @@ test.describe("Tokens: Detach token", () => {
|
||||
page,
|
||||
}) => {
|
||||
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
await page.getByRole("tab", { name: "Layers" }).click();
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WorkspacePage } from "../../pages/WorkspacePage";
|
||||
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
|
||||
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
|
||||
import {
|
||||
setupEmptyTokensFile,
|
||||
setupTokensFile,
|
||||
setupTypographyTokensFile,
|
||||
setupEmptyTokensFileRender,
|
||||
setupTokensFileRender,
|
||||
setupTypographyTokensFileRender,
|
||||
testTokenCreationFlow,
|
||||
unfoldTokenTree,
|
||||
} from "./helpers";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WorkspacePage.init(page);
|
||||
await WasmWorkspacePage.init(page);
|
||||
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
|
||||
});
|
||||
|
||||
@@ -30,6 +30,89 @@ test.describe("Tokens - creation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("User creates border radius token with combobox", async ({ page }) => {
|
||||
const invalidValueError = "Invalid token value";
|
||||
const emptyNameError = "Name should be at least 1 character";
|
||||
const selfReferenceError = "Token has self reference";
|
||||
const missingReferenceError = "Missing token references";
|
||||
|
||||
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
|
||||
await setupEmptyTokensFileRender(page , {
|
||||
flags: ["enable-token-combobox", "enable-feature-token-input"],
|
||||
});
|
||||
|
||||
// Open modal
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
|
||||
const addTokenButton = tokensTabPanel.getByRole("button", {
|
||||
name: `Add Token: Border Radius`,
|
||||
});
|
||||
|
||||
await addTokenButton.click();
|
||||
await expect(tokensUpdateCreateModal).toBeVisible();
|
||||
|
||||
// Placeholder checks
|
||||
await expect(
|
||||
tokensUpdateCreateModal.getByPlaceholder(
|
||||
"Enter border radius token name",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
tokensUpdateCreateModal.getByPlaceholder(
|
||||
"Enter a value or alias with {alias}",
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
// Elements
|
||||
const nameField = tokensUpdateCreateModal.getByLabel("Name");
|
||||
const valueField = tokensUpdateCreateModal.getByRole("combobox", {
|
||||
name: "Value",
|
||||
});
|
||||
const submitButton = tokensUpdateCreateModal.getByRole("button", {
|
||||
name: "Save",
|
||||
});
|
||||
|
||||
// Create first token
|
||||
await nameField.fill("my-token");
|
||||
await valueField.fill("1 + 2");
|
||||
await expect(
|
||||
tokensUpdateCreateModal.getByText("Resolved value: 3"),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
await submitButton.click();
|
||||
|
||||
await expect(
|
||||
tokensTabPanel.getByRole("button", { name: "my-token" }),
|
||||
).toBeEnabled();
|
||||
|
||||
// Create second token referencing the first one using the combobox options
|
||||
await addTokenButton.click();
|
||||
|
||||
await nameField.fill("my-token-2");
|
||||
const toggleDropdownButton = tokensUpdateCreateModal.getByRole("button", {
|
||||
name: "Open token list",
|
||||
});
|
||||
await toggleDropdownButton.click();
|
||||
const option = page.getByRole("option", { name: "my-token" });
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
await expect(
|
||||
tokensUpdateCreateModal.getByText("Resolved value: 3"),
|
||||
).toBeVisible();
|
||||
|
||||
await valueField.pressSequentially(" + 2");
|
||||
await expect(
|
||||
tokensUpdateCreateModal.getByText("Resolved value: 5"),
|
||||
).toBeVisible();
|
||||
await valueField.pressSequentially(" + {");
|
||||
await option.click();
|
||||
await expect(
|
||||
tokensUpdateCreateModal.getByText("Resolved value: 8"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("User creates dimensions token", async ({ page }) => {
|
||||
await testTokenCreationFlow(page, {
|
||||
tokenLabel: "Dimensions",
|
||||
@@ -158,7 +241,7 @@ test.describe("Tokens - creation", () => {
|
||||
const selfReferenceError = "Token has self reference";
|
||||
const missingReferenceError = "Missing token references";
|
||||
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
await tokensSidebar
|
||||
.getByRole("button", { name: "Add Token: Color" })
|
||||
@@ -320,7 +403,7 @@ test.describe("Tokens - creation", () => {
|
||||
const missingReferenceError = "Missing token references";
|
||||
|
||||
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
// Open modal
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
@@ -465,7 +548,7 @@ test.describe("Tokens - creation", () => {
|
||||
const missingReferenceError = "Missing token references";
|
||||
|
||||
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
// Open modal
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
@@ -601,7 +684,7 @@ test.describe("Tokens - creation", () => {
|
||||
const missingReferenceError = "Missing token references";
|
||||
|
||||
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
// Open modal
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
@@ -717,7 +800,7 @@ test.describe("Tokens - creation", () => {
|
||||
const missingReferenceError = "Missing token references";
|
||||
|
||||
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
// Open modal
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
@@ -831,7 +914,7 @@ test.describe("Tokens - creation", () => {
|
||||
const emptyNameError = "Name should be at least 1 character";
|
||||
|
||||
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
|
||||
await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] });
|
||||
await setupEmptyTokensFileRender(page, { flags: ["enable-token-shadow"] });
|
||||
|
||||
// Open modal
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
@@ -1012,7 +1095,7 @@ test.describe("Tokens - creation", () => {
|
||||
page,
|
||||
}) => {
|
||||
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
|
||||
await setupTypographyTokensFile(page);
|
||||
await setupTypographyTokensFileRender(page);
|
||||
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
await tokensTabPanel
|
||||
@@ -1047,7 +1130,7 @@ test.describe("Tokens - creation", () => {
|
||||
const emptyNameError = "Name should be at least 1 character";
|
||||
|
||||
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
|
||||
await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] });
|
||||
await setupEmptyTokensFileRender(page, { flags: ["enable-token-shadow"] });
|
||||
|
||||
// Open modal
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
@@ -1232,7 +1315,7 @@ test.describe("Tokens - creation", () => {
|
||||
test("User creates typography token", async ({ page }) => {
|
||||
const emptyNameError = "Name should be at least 1 character";
|
||||
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
// Open modal
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
@@ -1479,7 +1562,7 @@ test.describe("Tokens - creation", () => {
|
||||
|
||||
test("User adds typography token with reference", async ({ page }) => {
|
||||
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
|
||||
await setupTypographyTokensFile(page);
|
||||
await setupTypographyTokensFileRender(page);
|
||||
|
||||
const newTokenTitle = "NewReference";
|
||||
|
||||
@@ -1529,7 +1612,7 @@ test.describe("Tokens - creation", () => {
|
||||
|
||||
test("User creates grouped color token", async ({ page }) => {
|
||||
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
await tokensSidebar
|
||||
.getByRole("button", { name: "Add Token: Color" })
|
||||
@@ -1562,7 +1645,7 @@ test.describe("Tokens - creation", () => {
|
||||
test("User cant create regular token with value missing", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
|
||||
const { tokensUpdateCreateModal } = await setupEmptyTokensFileRender(page);
|
||||
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
await tokensTabPanel
|
||||
@@ -1589,7 +1672,7 @@ test.describe("Tokens - creation", () => {
|
||||
|
||||
test("User duplicate color token", async ({ page }) => {
|
||||
const { tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
await expect(tokensSidebar).toBeVisible();
|
||||
|
||||
@@ -1613,7 +1696,7 @@ test.describe("Tokens - creation", () => {
|
||||
|
||||
test("User creates grouped color token", async ({ page }) => {
|
||||
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
await tokensSidebar.getByRole("button", { name: "Add Token: Color" }).click();
|
||||
|
||||
@@ -1642,7 +1725,7 @@ test("User creates grouped color token", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("User cant create regular token with value missing", async ({ page }) => {
|
||||
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
|
||||
const { tokensUpdateCreateModal } = await setupEmptyTokensFileRender(page);
|
||||
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
await tokensTabPanel
|
||||
@@ -1669,7 +1752,7 @@ test("User cant create regular token with value missing", async ({ page }) => {
|
||||
|
||||
test("User duplicate color token", async ({ page }) => {
|
||||
const { tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
await expect(tokensSidebar).toBeVisible();
|
||||
|
||||
@@ -1695,7 +1778,7 @@ test.describe("Tokens tab - edition", () => {
|
||||
page,
|
||||
}) => {
|
||||
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
|
||||
await setupTypographyTokensFile(page);
|
||||
await setupTypographyTokensFileRender(page);
|
||||
|
||||
await tokensSidebar
|
||||
.getByRole("button")
|
||||
@@ -1791,7 +1874,7 @@ test.describe("Tokens tab - edition", () => {
|
||||
page,
|
||||
}) => {
|
||||
const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
await expect(tokensSidebar).toBeVisible();
|
||||
|
||||
@@ -1827,7 +1910,7 @@ test.describe("Tokens tab - edition", () => {
|
||||
page,
|
||||
}) => {
|
||||
const { workspacePage, tokensUpdateCreateModal, tokenThemesSetsSidebar } =
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
await tokensTabPanel
|
||||
@@ -1882,7 +1965,7 @@ test.describe("Tokens tab - edition", () => {
|
||||
test.describe("Tokens tab - delete", () => {
|
||||
test("User delete color token", async ({ page }) => {
|
||||
const { tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
await expect(tokensSidebar).toBeVisible();
|
||||
|
||||
@@ -1902,7 +1985,7 @@ test.describe("Tokens tab - delete", () => {
|
||||
});
|
||||
|
||||
test("User removes node and all child tokens", async ({ page }) => {
|
||||
const { tokensSidebar } = await setupTokensFile(page);
|
||||
const { tokensSidebar } = await setupTokensFileRender(page);
|
||||
|
||||
await expect(tokensSidebar).toBeVisible();
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WorkspacePage } from "../../pages/WorkspacePage";
|
||||
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
|
||||
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
|
||||
import { setupEmptyTokensFile } from "./helpers";
|
||||
import { setupEmptyTokensFileRender } from "./helpers";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WorkspacePage.init(page);
|
||||
await WasmWorkspacePage.init(page);
|
||||
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe("Tokens tab - common tests", () => {
|
||||
test("Clicking tokens tab button opens tokens sidebar tab", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WorkspacePage } from "../../pages/WorkspacePage";
|
||||
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
|
||||
|
||||
const setupEmptyTokensFile = async (page, options = {}) => {
|
||||
const { flags = [] } = options;
|
||||
@@ -40,6 +41,45 @@ const setupEmptyTokensFile = async (page, options = {}) => {
|
||||
};
|
||||
};
|
||||
|
||||
const setupEmptyTokensFileRender = async (page, options = {}) => {
|
||||
const { flags = [] } = options;
|
||||
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
if (flags.length > 0) {
|
||||
await workspacePage.mockConfigFlags(flags);
|
||||
}
|
||||
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.mockRPC(
|
||||
"get-team?id=*",
|
||||
"workspace/get-team-tokens.json",
|
||||
);
|
||||
|
||||
await workspacePage.mockRPC(
|
||||
"update-file?id=*",
|
||||
"workspace/update-file-create-rect.json",
|
||||
);
|
||||
|
||||
await workspacePage.goToWorkspace({
|
||||
fileId: "c7ce0794-0992-8105-8004-38f280443849",
|
||||
pageId: "66697432-c33d-8055-8006-2c62cc084cad",
|
||||
});
|
||||
|
||||
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
|
||||
await tokensTabButton.click();
|
||||
|
||||
return {
|
||||
workspacePage,
|
||||
tokenThemeUpdateCreateModal: workspacePage.tokenThemeUpdateCreateModal,
|
||||
tokensUpdateCreateModal: workspacePage.tokensUpdateCreateModal,
|
||||
tokenThemesSetsSidebar: workspacePage.tokenThemesSetsSidebar,
|
||||
tokenSetItems: workspacePage.tokenSetItems,
|
||||
tokensSidebar: workspacePage.tokensSidebar,
|
||||
tokenSetGroupItems: workspacePage.tokenSetGroupItems,
|
||||
tokenContextMenuForSet: workspacePage.tokenContextMenuForSet,
|
||||
};
|
||||
};
|
||||
|
||||
const setupTokensFile = async (page, options = {}) => {
|
||||
const {
|
||||
file = "workspace/get-file-tokens.json",
|
||||
@@ -85,6 +125,51 @@ const setupTokensFile = async (page, options = {}) => {
|
||||
};
|
||||
};
|
||||
|
||||
const setupTokensFileRender = async (page, options = {}) => {
|
||||
const {
|
||||
file = "workspace/get-file-tokens.json",
|
||||
fileFragment = "workspace/get-file-fragment-tokens.json",
|
||||
flags = ["enable-feature-token-input"],
|
||||
} = options;
|
||||
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
if (flags.length > 0) {
|
||||
await workspacePage.mockConfigFlags(flags);
|
||||
}
|
||||
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.mockRPC(
|
||||
"get-team?id=*",
|
||||
"workspace/get-team-tokens.json",
|
||||
);
|
||||
await workspacePage.mockRPC(/get\-file\?/, file);
|
||||
await workspacePage.mockRPC(/get\-file\-fragment\?/, fileFragment);
|
||||
await workspacePage.mockRPC(
|
||||
"update-file?id=*",
|
||||
"workspace/update-file-create-rect.json",
|
||||
);
|
||||
|
||||
await workspacePage.goToWorkspace({
|
||||
fileId: "c7ce0794-0992-8105-8004-38f280443849",
|
||||
pageId: "66697432-c33d-8055-8006-2c62cc084cad",
|
||||
});
|
||||
|
||||
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
|
||||
await tokensTabButton.click();
|
||||
|
||||
return {
|
||||
workspacePage,
|
||||
tokensUpdateCreateModal: workspacePage.tokensUpdateCreateModal,
|
||||
tokenThemeUpdateCreateModal: workspacePage.tokenThemeUpdateCreateModal,
|
||||
tokenThemesSetsSidebar: workspacePage.tokenThemesSetsSidebar,
|
||||
tokenSetItems: workspacePage.tokenSetItems,
|
||||
tokenSetGroupItems: workspacePage.tokenSetGroupItems,
|
||||
tokensSidebar: workspacePage.tokensSidebar,
|
||||
tokenContextMenuForToken: workspacePage.tokenContextMenuForToken,
|
||||
tokenContextMenuForSet: workspacePage.tokenContextMenuForSet,
|
||||
};
|
||||
};
|
||||
|
||||
const setupTypographyTokensFile = async (page, options = {}) => {
|
||||
return setupTokensFile(page, {
|
||||
file: "workspace/get-file-typography-tokens.json",
|
||||
@@ -93,6 +178,14 @@ const setupTypographyTokensFile = async (page, options = {}) => {
|
||||
});
|
||||
};
|
||||
|
||||
const setupTypographyTokensFileRender = async (page, options = {}) => {
|
||||
return setupTokensFileRender(page, {
|
||||
file: "workspace/get-file-typography-tokens.json",
|
||||
fileFragment: "workspace/get-file-fragment-typography-tokens.json",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
const testTokenCreationFlow = async (
|
||||
page,
|
||||
{
|
||||
@@ -114,7 +207,7 @@ const testTokenCreationFlow = async (
|
||||
const missingReferenceError = "Missing token references";
|
||||
|
||||
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
// Open modal
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
@@ -259,8 +352,11 @@ const unfoldTokenTree = async (tokensTabPanel, type, tokenName) => {
|
||||
|
||||
export {
|
||||
setupEmptyTokensFile,
|
||||
setupEmptyTokensFileRender,
|
||||
setupTokensFile,
|
||||
setupTokensFileRender,
|
||||
setupTypographyTokensFile,
|
||||
setupTypographyTokensFileRender,
|
||||
testTokenCreationFlow,
|
||||
unfoldTokenTree,
|
||||
};
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WorkspacePage } from "../../pages/WorkspacePage";
|
||||
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
|
||||
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
|
||||
import {
|
||||
setupEmptyTokensFile,
|
||||
setupTokensFile,
|
||||
setupTypographyTokensFile,
|
||||
setupTokensFileRender,
|
||||
setupTypographyTokensFileRender,
|
||||
} from "./helpers";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WorkspacePage.init(page);
|
||||
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
|
||||
await WasmWorkspacePage.mockConfigFlags(page, [
|
||||
"enable-feature-design-tokens-v1",
|
||||
]);
|
||||
await WasmWorkspacePage.mockRPC(page, "get-teams", "get-teams-tokens.json");
|
||||
});
|
||||
|
||||
const createToken = async (page, type, name, textFieldName, value) => {
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
|
||||
const { tokensUpdateCreateModal } = await setupTokensFile(page, {
|
||||
const { tokensUpdateCreateModal } = await setupTokensFileRender(page, {
|
||||
flags: ["enable-token-shadow"],
|
||||
});
|
||||
|
||||
@@ -40,9 +42,37 @@ const createToken = async (page, type, name, textFieldName, value) => {
|
||||
await expect(tokensUpdateCreateModal).not.toBeVisible();
|
||||
};
|
||||
|
||||
const createTokenCombobox = async (page, type, name, textFieldName, value) => {
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
|
||||
const { tokensUpdateCreateModal } = await setupTokensFileRender(page, {
|
||||
flags: ["enable-token-shadow"],
|
||||
});
|
||||
|
||||
// Create base token
|
||||
await tokensTabPanel
|
||||
.getByRole("button", { name: `Add Token: ${type}` })
|
||||
.click();
|
||||
await expect(tokensUpdateCreateModal).toBeVisible();
|
||||
|
||||
const nameField = tokensUpdateCreateModal.getByLabel("Name");
|
||||
await nameField.fill(name);
|
||||
|
||||
const valueFill = tokensUpdateCreateModal.getByRole("combobox", {
|
||||
name: textFieldName,
|
||||
});
|
||||
await valueFill.fill(value);
|
||||
|
||||
const submitButton = tokensUpdateCreateModal.getByRole("button", {
|
||||
name: "Save",
|
||||
});
|
||||
await submitButton.click();
|
||||
await expect(tokensUpdateCreateModal).not.toBeVisible();
|
||||
};
|
||||
|
||||
const renameToken = async (page, oldName, newName) => {
|
||||
const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTokensFile(page, { flags: ["enable-token-shadow"] });
|
||||
await setupTokensFileRender(page, { flags: ["enable-token-shadow"] });
|
||||
|
||||
const baseToken = tokensSidebar.getByRole("button", {
|
||||
name: oldName,
|
||||
@@ -64,7 +94,7 @@ const renameToken = async (page, oldName, newName) => {
|
||||
const createCompositeDerivedToken = async (page, type, name, reference) => {
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
|
||||
const { tokensUpdateCreateModal } = await setupTokensFile(page, {
|
||||
const { tokensUpdateCreateModal } = await setupTokensFileRender(page, {
|
||||
flags: ["enable-token-shadow"],
|
||||
});
|
||||
|
||||
@@ -98,7 +128,7 @@ test.describe("Remapping Tokens", () => {
|
||||
test("User renames box shadow token with alias references", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { tokensSidebar } = await setupTokensFile(page, {
|
||||
const { tokensSidebar } = await setupTokensFileRender(page, {
|
||||
flags: ["enable-token-shadow"],
|
||||
});
|
||||
|
||||
@@ -144,7 +174,7 @@ test.describe("Remapping Tokens", () => {
|
||||
tokensSidebar,
|
||||
tokenContextMenuForToken,
|
||||
workspacePage,
|
||||
} = await setupTokensFile(page, { flags: ["enable-token-shadow"] });
|
||||
} = await setupTokensFileRender(page, { flags: ["enable-token-shadow"] });
|
||||
|
||||
// Create base shadow token
|
||||
await createToken(page, "Shadow", "primary-shadow", "Color", "#000000");
|
||||
@@ -249,7 +279,7 @@ test.describe("Remapping Tokens", () => {
|
||||
tokensUpdateCreateModal,
|
||||
tokensSidebar,
|
||||
tokenContextMenuForToken,
|
||||
} = await setupTypographyTokensFile(page);
|
||||
} = await setupTypographyTokensFileRender(page);
|
||||
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
|
||||
@@ -293,7 +323,7 @@ test.describe("Remapping Tokens", () => {
|
||||
tokensSidebar,
|
||||
tokenContextMenuForToken,
|
||||
workspacePage,
|
||||
} = await setupTypographyTokensFile(page);
|
||||
} = await setupTypographyTokensFileRender(page);
|
||||
|
||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||
|
||||
@@ -401,13 +431,21 @@ test.describe("Remapping Tokens", () => {
|
||||
test("User renames border radius token with alias references", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { tokensSidebar } = await setupTokensFile(page);
|
||||
const { tokensSidebar } = await setupTokensFileRender(page, {
|
||||
flags: ["enable-token-combobox", "enable-feature-token-input"],
|
||||
});
|
||||
|
||||
// Create base border radius token
|
||||
await createToken(page, "Border Radius", "base-radius", "Value", "4");
|
||||
await createTokenCombobox(
|
||||
page,
|
||||
"Border Radius",
|
||||
"base-radius",
|
||||
"Value",
|
||||
"4",
|
||||
);
|
||||
|
||||
// Create derived border radius token
|
||||
await createToken(
|
||||
await createTokenCombobox(
|
||||
page,
|
||||
"Border Radius",
|
||||
"card-radius",
|
||||
@@ -443,13 +481,21 @@ test.describe("Remapping Tokens", () => {
|
||||
tokensUpdateCreateModal,
|
||||
tokensSidebar,
|
||||
tokenContextMenuForToken,
|
||||
} = await setupTokensFile(page);
|
||||
} = await setupTokensFileRender(page, {
|
||||
flags: ["enable-token-combobox", "enable-feature-token-input"],
|
||||
});
|
||||
|
||||
// Create base border radius token
|
||||
await createToken(page, "Border Radius", "radius-sm", "Value", "4");
|
||||
await createTokenCombobox(
|
||||
page,
|
||||
"Border Radius",
|
||||
"radius-sm",
|
||||
"Value",
|
||||
"4",
|
||||
);
|
||||
|
||||
// Create derived border radius token
|
||||
await createToken(
|
||||
await createTokenCombobox(
|
||||
page,
|
||||
"Border Radius",
|
||||
"button-radius",
|
||||
@@ -512,7 +558,7 @@ test.describe("Remapping Tokens", () => {
|
||||
|
||||
test.describe("Cancel remap", () => {
|
||||
test("Only rename - breaks reference", async ({ page }) => {
|
||||
const { tokensSidebar } = await setupTokensFile(page, {
|
||||
const { tokensSidebar } = await setupTokensFileRender(page, {
|
||||
flags: ["enable-token-shadow"],
|
||||
});
|
||||
|
||||
@@ -551,7 +597,7 @@ test.describe("Remapping Tokens", () => {
|
||||
});
|
||||
|
||||
test("Cancel process - no changes applied", async ({ page }) => {
|
||||
const { tokensSidebar } = await setupTokensFile(page, {
|
||||
const { tokensSidebar } = await setupTokensFileRender(page, {
|
||||
flags: ["enable-token-shadow"],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
|
||||
import { WorkspacePage } from "../../pages/WorkspacePage";
|
||||
import { setupEmptyTokensFile, setupTokensFile } from "./helpers";
|
||||
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
|
||||
import { setupEmptyTokensFileRender, setupTokensFileRender } from "./helpers";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WorkspacePage.init(page);
|
||||
await WasmWorkspacePage.init(page);
|
||||
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ test.describe("Tokens: Sets Tab", () => {
|
||||
page,
|
||||
}) => {
|
||||
const { tokenThemesSetsSidebar, tokenContextMenuForSet } =
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
const tokensTabButton = tokenThemesSetsSidebar
|
||||
.getByRole("button", { name: "Add set" })
|
||||
@@ -139,7 +139,7 @@ test.describe("Tokens: Sets Tab", () => {
|
||||
page,
|
||||
}) => {
|
||||
const { tokenThemesSetsSidebar, tokenContextMenuForSet } =
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
const tokensTabButton = tokenThemesSetsSidebar
|
||||
.getByRole("button", { name: "Add set" })
|
||||
@@ -176,7 +176,7 @@ test.describe("Tokens: Sets Tab", () => {
|
||||
|
||||
test("Fold/Unfold set", async ({ page }) => {
|
||||
const { tokenThemesSetsSidebar, tokenSetGroupItems } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
await expect(tokenThemesSetsSidebar).toBeVisible();
|
||||
|
||||
@@ -202,7 +202,7 @@ test.describe("Tokens: Sets Tab", () => {
|
||||
|
||||
test("Change current theme", async ({ page }) => {
|
||||
const { tokenThemesSetsSidebar, tokenSetItems } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
await expect(tokenSetItems.nth(1)).toHaveAttribute("aria-checked", "true");
|
||||
await expect(tokenSetItems.nth(2)).toHaveAttribute("aria-checked", "false");
|
||||
@@ -219,7 +219,7 @@ test.describe("Tokens: Sets Tab", () => {
|
||||
|
||||
test("Display active set and verify if is enabled", async ({ page }) => {
|
||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenSetItems } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
// Create set
|
||||
await tokenThemesSetsSidebar
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WorkspacePage } from "../../pages/WorkspacePage";
|
||||
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
|
||||
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
|
||||
import { setupEmptyTokensFile, setupTokensFile } from "./helpers";
|
||||
import { setupEmptyTokensFileRender, setupTokensFileRender } from "./helpers";
|
||||
|
||||
// THEMES HELPERS
|
||||
|
||||
@@ -23,14 +23,17 @@ const checkInputFieldWithoutError = async (inputLocator) => {
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WorkspacePage.init(page);
|
||||
await WasmWorkspacePage.init(page);
|
||||
await WasmWorkspacePage.mockConfigFlags(page, [
|
||||
"enable-feature-design-tokens-v1",
|
||||
]);
|
||||
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
|
||||
});
|
||||
|
||||
test.describe("Tokens Themes", () => {
|
||||
test("User edits theme and activates it in the sidebar", async ({ page }) => {
|
||||
const { tokenThemesSetsSidebar, tokenThemeUpdateCreateModal } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
await expect(tokenThemesSetsSidebar).toBeVisible();
|
||||
|
||||
@@ -117,7 +120,7 @@ test.describe("Tokens Themes", () => {
|
||||
test.describe("Tokens: Themes modal", () => {
|
||||
test("Delete theme", async ({ page }) => {
|
||||
const { tokenThemeUpdateCreateModal, workspacePage } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
workspacePage.openTokenThemesModal();
|
||||
|
||||
@@ -137,7 +140,7 @@ test.describe("Tokens: Themes modal", () => {
|
||||
|
||||
test("Add new theme in empty file", async ({ page }) => {
|
||||
const { tokenThemesSetsSidebar, tokenThemeUpdateCreateModal } =
|
||||
await setupEmptyTokensFile(page);
|
||||
await setupEmptyTokensFileRender(page);
|
||||
|
||||
await tokenThemesSetsSidebar
|
||||
.getByRole("button", { name: "Create one." })
|
||||
@@ -170,7 +173,7 @@ test.describe("Tokens: Themes modal", () => {
|
||||
|
||||
test("Add new theme", async ({ page }) => {
|
||||
const { tokenThemeUpdateCreateModal, workspacePage } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
workspacePage.openTokenThemesModal();
|
||||
|
||||
@@ -210,7 +213,7 @@ test.describe("Tokens: Themes modal", () => {
|
||||
|
||||
test("Edit theme", async ({ page }) => {
|
||||
const { tokenThemeUpdateCreateModal, workspacePage } =
|
||||
await setupTokensFile(page);
|
||||
await setupTokensFileRender(page);
|
||||
|
||||
workspacePage.openTokenThemesModal();
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WorkspacePage } from "../../pages/WorkspacePage";
|
||||
import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage";
|
||||
import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage";
|
||||
import { setupTokensFile, unfoldTokenTree } from "./helpers";
|
||||
import { setupTokensFileRender, unfoldTokenTree } from "./helpers";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WorkspacePage.init(page);
|
||||
await WasmWorkspacePage.init(page);
|
||||
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
|
||||
});
|
||||
|
||||
test.describe("Tokens - node tree", () => {
|
||||
test("User fold/unfold color tokens", async ({ page }) => {
|
||||
const { tokensSidebar } = await setupTokensFile(page);
|
||||
const { tokensSidebar } = await setupTokensFileRender(page);
|
||||
|
||||
await expect(tokensSidebar).toBeVisible();
|
||||
|
||||
|
||||
@@ -383,26 +383,24 @@ test("User cut paste a component with path inside a variant", async ({
|
||||
|
||||
const variant = await findVariant(workspacePage, 0);
|
||||
|
||||
// Create a component
|
||||
//Create a component
|
||||
await workspacePage.ellipseShapeButton.click();
|
||||
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
|
||||
await workspacePage.clickLeafLayer("Ellipse");
|
||||
await workspacePage.page.keyboard.press("ControlOrMeta+k");
|
||||
await workspacePage.page.waitForTimeout(3000);
|
||||
|
||||
// Rename the component
|
||||
//Rename the component
|
||||
await workspacePage.layers.getByText("Ellipse").dblclick();
|
||||
await workspacePage.page
|
||||
.getByTestId("layer-item")
|
||||
.getByRole("textbox")
|
||||
.pressSequentially("button / hover");
|
||||
await workspacePage.page.keyboard.press("Enter");
|
||||
await workspacePage.page.waitForTimeout(3000);
|
||||
|
||||
// Cut the component
|
||||
//Cut the component
|
||||
await workspacePage.cut("keyboard");
|
||||
|
||||
// Paste the component inside the variant
|
||||
//Paste the component inside the variant
|
||||
await variant.container.click();
|
||||
await workspacePage.paste("keyboard");
|
||||
|
||||
@@ -429,7 +427,6 @@ test("User drag and drop a component with path inside a variant", async ({
|
||||
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
|
||||
await workspacePage.clickLeafLayer("Ellipse");
|
||||
await workspacePage.page.keyboard.press("ControlOrMeta+k");
|
||||
await workspacePage.page.waitForTimeout(3000);
|
||||
|
||||
//Rename the component
|
||||
await workspacePage.layers.getByText("Ellipse").dblclick();
|
||||
|
||||
@@ -119,6 +119,10 @@
|
||||
(normalize-uri (or (obj/get global "penpotPublicURI")
|
||||
(obj/get location "origin"))))
|
||||
|
||||
(def mcp-ws-uri
|
||||
(or (some-> (obj/get global "penpotMcpServerURI") u/uri)
|
||||
(u/join public-uri "mcp/ws")))
|
||||
|
||||
(def rasterizer-uri
|
||||
(or (some-> (obj/get global "penpotRasterizerURI") normalize-uri)
|
||||
public-uri))
|
||||
@@ -147,6 +151,9 @@
|
||||
(let [f (obj/get global "initializeExternalConfigInfo")]
|
||||
(when (fn? f) (f))))
|
||||
|
||||
(def mcp-server-url (-> public-uri u/ensure-path-slash (u/join "mcp/stream") str))
|
||||
(def mcp-help-center-uri "https://help.penpot.app/technical-guide/")
|
||||
|
||||
;; --- Helper Functions
|
||||
|
||||
(defn ^boolean check-browser? [candidate]
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
params {:exports exports
|
||||
:cmd cmd
|
||||
:profile-id profile-id
|
||||
:wait false}
|
||||
:force-multiple true}
|
||||
|
||||
progress-stream
|
||||
(->> (ws/get-rcv-stream ws-conn)
|
||||
|
||||
@@ -99,46 +99,65 @@
|
||||
map with temporal ID's associated to each font entry."
|
||||
[blobs team-id]
|
||||
(letfn [(prepare [{:keys [font type name data] :as params}]
|
||||
(let [family (or (.getEnglishName ^js font "preferredFamily")
|
||||
(.getEnglishName ^js font "fontFamily"))
|
||||
variant (or (.getEnglishName ^js font "preferredSubfamily")
|
||||
(.getEnglishName ^js font "fontSubfamily"))
|
||||
(if font
|
||||
;; Font was parsed with opentype.js (ttf, otf, woff)
|
||||
(let [family (or (.getEnglishName ^js font "preferredFamily")
|
||||
(.getEnglishName ^js font "fontFamily"))
|
||||
variant (or (.getEnglishName ^js font "preferredSubfamily")
|
||||
(.getEnglishName ^js font "fontSubfamily"))
|
||||
|
||||
;; Vertical metrics determine the baseline in a text and the space between lines of
|
||||
;; text. For historical reasons, there are three pairs of ascender/descender
|
||||
;; values, known as hhea, OS/2 and uSWin metrics. Depending on the font, operating
|
||||
;; system and application a different set will be used to render text on the
|
||||
;; screen. On Mac, Safari and Chrome use the hhea values to render text. Firefox
|
||||
;; will respect the useTypoMetrics setting and will use the OS/2 if it is set. If
|
||||
;; the useTypoMetrics is not set, Firefox will also use metrics from the hhea
|
||||
;; table. On Windows, all browsers use the usWin metrics, but respect the
|
||||
;; useTypoMetrics setting and if set will use the OS/2 values.
|
||||
;; Vertical metrics determine the baseline in a text and the space between lines of
|
||||
;; text. For historical reasons, there are three pairs of ascender/descender
|
||||
;; values, known as hhea, OS/2 and uSWin metrics. Depending on the font, operating
|
||||
;; system and application a different set will be used to render text on the
|
||||
;; screen. On Mac, Safari and Chrome use the hhea values to render text. Firefox
|
||||
;; will respect the useTypoMetrics setting and will use the OS/2 if it is set. If
|
||||
;; the useTypoMetrics is not set, Firefox will also use metrics from the hhea
|
||||
;; table. On Windows, all browsers use the usWin metrics, but respect the
|
||||
;; useTypoMetrics setting and if set will use the OS/2 values.
|
||||
|
||||
hhea-ascender (abs (-> ^js font .-tables .-hhea .-ascender))
|
||||
hhea-descender (abs (-> ^js font .-tables .-hhea .-descender))
|
||||
hhea-ascender (abs (-> ^js font .-tables .-hhea .-ascender))
|
||||
hhea-descender (abs (-> ^js font .-tables .-hhea .-descender))
|
||||
|
||||
win-ascent (abs (-> ^js font .-tables .-os2 .-usWinAscent))
|
||||
win-descent (abs (-> ^js font .-tables .-os2 .-usWinDescent))
|
||||
win-ascent (abs (-> ^js font .-tables .-os2 .-usWinAscent))
|
||||
win-descent (abs (-> ^js font .-tables .-os2 .-usWinDescent))
|
||||
|
||||
os2-ascent (abs (-> ^js font .-tables .-os2 .-sTypoAscender))
|
||||
os2-descent (abs (-> ^js font .-tables .-os2 .-sTypoDescender))
|
||||
os2-ascent (abs (-> ^js font .-tables .-os2 .-sTypoAscender))
|
||||
os2-descent (abs (-> ^js font .-tables .-os2 .-sTypoDescender))
|
||||
|
||||
;; useTypoMetrics can be read from the 7th bit
|
||||
f-selection (-> ^js font .-tables .-os2 .-fsSelection (bit-test 7))
|
||||
;; useTypoMetrics can be read from the 7th bit
|
||||
f-selection (-> ^js font .-tables .-os2 .-fsSelection (bit-test 7))
|
||||
|
||||
height-warning? (or (not= hhea-ascender win-ascent)
|
||||
(not= hhea-descender win-descent)
|
||||
(and f-selection (or
|
||||
(not= hhea-ascender os2-ascent)
|
||||
(not= hhea-descender os2-descent))))
|
||||
data (js/Uint8Array. data)]
|
||||
{:content {:data (chunk-array data default-chunk-size)
|
||||
:name name
|
||||
:type type}
|
||||
:font-family (or family "")
|
||||
:font-weight (cm/parse-font-weight variant)
|
||||
:font-style (cm/parse-font-style variant)
|
||||
:height-warning? height-warning?}))
|
||||
height-warning? (or (not= hhea-ascender win-ascent)
|
||||
(not= hhea-descender win-descent)
|
||||
(and f-selection (or
|
||||
(not= hhea-ascender os2-ascent)
|
||||
(not= hhea-descender os2-descent))))
|
||||
data (js/Uint8Array. data)]
|
||||
{:content {:data (chunk-array data default-chunk-size)
|
||||
:name name
|
||||
:type type}
|
||||
:font-family (or family "")
|
||||
:font-weight (cm/parse-font-weight variant)
|
||||
:font-style (cm/parse-font-style variant)
|
||||
:height-warning? height-warning?})
|
||||
;; Font could not be parsed (woff2), extract metadata from filename
|
||||
(let [base-name (str/replace name #"\.[^.]+$" "")
|
||||
;; Strip known weight/style tokens and separators to derive family name
|
||||
;; Use word boundaries to avoid matching substrings (e.g. "Boldini" should not match "bold")
|
||||
raw-family-name (-> base-name
|
||||
(str/replace #"(?i)(^|[-_\s])(extra\s*black|ultra\s*black|extra\s*bold|ultra\s*bold|semi\s*bold|demi\s*bold|extra\s*light|ultra\s*light|hairline|thin|light|normal|regular|medium|bold|black|heavy|solid|italic)([-_\s]|$)" "$1$3")
|
||||
(str/replace #"[-_\s]+" " ")
|
||||
(str/trim))
|
||||
family-name (if (str/blank? raw-family-name) base-name raw-family-name)
|
||||
data (js/Uint8Array. data)]
|
||||
{:content {:data (chunk-array data default-chunk-size)
|
||||
:name name
|
||||
:type type}
|
||||
:font-family family-name
|
||||
:font-weight (cm/parse-font-weight base-name)
|
||||
:font-style (cm/parse-font-style base-name)
|
||||
:height-warning? false})))
|
||||
|
||||
(join [res {:keys [content] :as font}]
|
||||
(let [key-fn (juxt :font-family :font-weight :font-style)
|
||||
@@ -166,14 +185,18 @@
|
||||
(case sg
|
||||
"117 124 124 117" "font/otf"
|
||||
"0 1 0 0" "font/ttf"
|
||||
"167 117 106 106" "font/woff")))
|
||||
"167 117 106 106" "font/woff"
|
||||
"167 117 106 62" "font/woff2")))
|
||||
|
||||
(parse-font [{:keys [data] :as params}]
|
||||
(try
|
||||
(assoc params :font (ot/parse data))
|
||||
(catch :default _e
|
||||
(log/warn :msg (str/fmt "skipping file %s, unsupported format" (:name params)))
|
||||
nil)))
|
||||
(parse-font [{:keys [data type name] :as params}]
|
||||
(if (= type "font/woff2")
|
||||
;; woff2 cannot be parsed by opentype.js, extract metadata from filename
|
||||
(assoc params :font nil)
|
||||
(try
|
||||
(assoc params :font (ot/parse data))
|
||||
(catch :default _e
|
||||
(log/warn :msg (str/fmt "skipping file %s, unsupported format" name))
|
||||
nil))))
|
||||
|
||||
(read-blob [blob]
|
||||
(->> (wa/read-file-as-array-buffer blob)
|
||||
|
||||
17
frontend/src/app/main/data/nitrate.cljs
Normal file
17
frontend/src/app/main/data/nitrate.cljs
Normal file
@@ -0,0 +1,17 @@
|
||||
(ns app.main.data.nitrate
|
||||
(:require
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.repo :as rp]
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(defn show-nitrate-popup
|
||||
[popup-type]
|
||||
(ptk/reify ::show-nitrate-popup
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(->> (rp/cmd! ::get-nitrate-connectivity {})
|
||||
(rx/map (fn [connectivity]
|
||||
(modal/show popup-type (or connectivity {}))))))))
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.flags :as pflag]
|
||||
[app.plugins.register :as preg]
|
||||
[app.util.globals :as ug]
|
||||
[app.util.http :as http]
|
||||
@@ -44,20 +45,6 @@
|
||||
(update [_ state]
|
||||
(update-in state [:workspace-local :open-plugins] (fnil conj #{}) id))))
|
||||
|
||||
(defn reset-plugin-flags
|
||||
[id]
|
||||
(ptk/reify ::reset-plugin-flags
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update-in state [:workspace-local :plugin-flags] assoc id {}))))
|
||||
|
||||
(defn set-plugin-flag
|
||||
[id key value]
|
||||
(ptk/reify ::set-plugin-flag
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update-in state [:workspace-local :plugin-flags id] assoc key value))))
|
||||
|
||||
(defn remove-current-plugin
|
||||
[id]
|
||||
(ptk/reify ::remove-current-plugin
|
||||
@@ -65,11 +52,26 @@
|
||||
(update [_ state]
|
||||
(update-in state [:workspace-local :open-plugins] (fnil disj #{}) id))))
|
||||
|
||||
(defn start-plugin!
|
||||
[{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions]
|
||||
(.ɵloadPlugin
|
||||
^js ug/global
|
||||
#js {:pluginId plugin-id
|
||||
:name name
|
||||
:version version
|
||||
:description description
|
||||
:host host
|
||||
:code code
|
||||
:allowBackground (boolean allow-background)
|
||||
:permissions (apply array permissions)}
|
||||
nil
|
||||
extensions))
|
||||
|
||||
(defn- load-plugin!
|
||||
[{:keys [plugin-id name description host code icon permissions]}]
|
||||
[{:keys [plugin-id name description host code icon permissions] :as params}]
|
||||
(try
|
||||
(st/emit! (save-current-plugin plugin-id)
|
||||
(reset-plugin-flags plugin-id))
|
||||
(st/emit! (pflag/clear plugin-id)
|
||||
(save-current-plugin plugin-id))
|
||||
|
||||
(.ɵloadPlugin
|
||||
^js ug/global
|
||||
|
||||
@@ -498,4 +498,3 @@
|
||||
(->> (rp/cmd! :delete-access-token params)
|
||||
(rx/tap on-success)
|
||||
(rx/catch on-error))))))
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
(and (number-with-unit-symbol? v)
|
||||
(= (.-unit v) "rem")))
|
||||
|
||||
(defn percent-number-with-unit? [v]
|
||||
(and (number-with-unit-symbol? v)
|
||||
(= (.-unit v) "%")))
|
||||
|
||||
(defn rem->px [^js v]
|
||||
(* (.-value v) 16))
|
||||
|
||||
@@ -87,10 +91,12 @@
|
||||
|
||||
(defn tokenscript-symbols->penpot-unit [^js v]
|
||||
(cond
|
||||
(nil? v) nil
|
||||
(structured-token? v) (structured-token->penpot-map v)
|
||||
(list-symbol? v) (structured-token->penpot-map v)
|
||||
(color-symbol? v) (.-value (.to v "hex"))
|
||||
(rem-number-with-unit? v) (rem->px v)
|
||||
(percent-number-with-unit? v) (/ (.-value v) 100)
|
||||
:else (.-value v)))
|
||||
|
||||
;; Processors ------------------------------------------------------------------
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
[app.main.data.workspace.layers :as dwly]
|
||||
[app.main.data.workspace.layout :as layout]
|
||||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.data.workspace.mcp :as mcp]
|
||||
[app.main.data.workspace.notifications :as dwn]
|
||||
[app.main.data.workspace.pages :as dwpg]
|
||||
[app.main.data.workspace.path :as dwdp]
|
||||
@@ -212,7 +213,8 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (dp/check-open-plugin)
|
||||
(fdf/fix-deleted-fonts-for-local-library file-id)))))
|
||||
(fdf/fix-deleted-fonts-for-local-library file-id)
|
||||
(mcp/init-mcp-connexion)))))
|
||||
|
||||
(defn- bundle-fetched
|
||||
[{:keys [file file-id thumbnails] :as bundle}]
|
||||
@@ -1446,6 +1448,7 @@
|
||||
(dm/export dwcp/paste-shapes)
|
||||
(dm/export dwcp/paste-data-valid?)
|
||||
(dm/export dwcp/copy-link-to-clipboard)
|
||||
(dm/export dwcp/copy-as-image)
|
||||
|
||||
;; Drawing
|
||||
(dm/export dwd/select-for-drawing)
|
||||
|
||||
@@ -1039,3 +1039,55 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(clipboard/to-clipboard (rt/get-current-href)))))
|
||||
|
||||
(defn copy-as-image
|
||||
[]
|
||||
(ptk/reify ::copy-as-image
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [file-id (:current-file-id state)
|
||||
page-id (:current-page-id state)
|
||||
selected (first (dsh/lookup-selected state))
|
||||
|
||||
export {:file-id file-id
|
||||
:page-id page-id
|
||||
:object-id selected
|
||||
;; webp would be preferrable, but PNG is the most supported image MIME type by clipboard APIs.
|
||||
:type :png
|
||||
;; Always use 2 to ensure good enough quality for wireframes.
|
||||
:scale 2
|
||||
:suffix ""
|
||||
:enabled true
|
||||
:name ""}
|
||||
|
||||
params {:exports [export]
|
||||
:profile-id (:profile-id state)
|
||||
:cmd :export-shapes
|
||||
:wait true}]
|
||||
|
||||
(rx/concat
|
||||
;; Ensure current state persisted before exporting.
|
||||
(rx/of ::dps/force-persist)
|
||||
(->> (rx/from-atom refs/persistence-state {:emit-current-value? true})
|
||||
(rx/filter #(or (nil? %) (= :saved %)))
|
||||
(rx/first)
|
||||
(rx/timeout 400 (rx/empty)))
|
||||
|
||||
;; Exporting itself can time its time, better to notify that we are busy.
|
||||
(rx/of (ntf/info (tr "workspace.clipboard.copying")))
|
||||
|
||||
;; Call exporter to get image URI, then fetch and copy blob.
|
||||
(->> (rp/cmd! :export params)
|
||||
(rx/mapcat (fn [{:keys [uri]}]
|
||||
(http/send! {:method :get
|
||||
:uri uri
|
||||
:response-type :blob})))
|
||||
(rx/map :body)
|
||||
(rx/tap (fn [blob]
|
||||
(clipboard/to-clipboard-promise "image/png" (p/resolved blob))))
|
||||
(rx/map (fn [_]
|
||||
(ntf/success (tr "workspace.clipboard.image-copied"))))
|
||||
(rx/catch (fn [e]
|
||||
(js/console.error "clipboard blocked:" e)
|
||||
(ntf/error (tr "workspace.clipboard.image-copy-failed"))
|
||||
(rx/empty)))))))))
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
(ns app.main.data.workspace.drawing.box
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.rect :as grc]
|
||||
@@ -28,9 +29,9 @@
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(defn adjust-ratio
|
||||
(defn- adjust-ratio
|
||||
[point initial]
|
||||
(let [v (gpt/to-vec point initial)
|
||||
(let [v (gpt/to-vec point initial)
|
||||
dx (mth/abs (:x v))
|
||||
dy (mth/abs (:y v))
|
||||
sx (mth/sign (:x v))
|
||||
@@ -43,32 +44,43 @@
|
||||
(> dy dx)
|
||||
(assoc :x (- (:x point) (* sx (- dy dx)))))))
|
||||
|
||||
(defn resize-shape [{:keys [x y width height] :as shape} initial point lock? mod? snap-pixel?]
|
||||
(defn- resize-shape
|
||||
[{:keys [x y width height] :as shape} initial point lock? mod? snap-pixel?]
|
||||
(if (and (some? x) (some? y) (some? width) (some? height))
|
||||
(let [draw-rect (cond-> (grc/make-rect initial (cond-> point lock? (adjust-ratio initial)))
|
||||
snap-pixel?
|
||||
(-> (update :width max 1)
|
||||
(update :height max 1)))
|
||||
(let [p2
|
||||
(cond-> point lock? (adjust-ratio initial))
|
||||
|
||||
shape-rect (grc/make-rect x y width height)
|
||||
p1
|
||||
(if mod?
|
||||
(gpt/point (- (* 2 (:x initial)) (:x p2))
|
||||
(- (* 2 (:y initial)) (:y p2)))
|
||||
initial)
|
||||
|
||||
scalev (gpt/point (/ (:width draw-rect)
|
||||
(:width shape-rect))
|
||||
(/ (:height draw-rect)
|
||||
(:height shape-rect)))
|
||||
draw-rect
|
||||
(cond-> (grc/make-rect p1 p2)
|
||||
snap-pixel?
|
||||
(-> (update :width d/max 1)
|
||||
(update :height d/max 1)))
|
||||
|
||||
movev (gpt/to-vec (gpt/point shape-rect)
|
||||
(gpt/point draw-rect))]
|
||||
shape-rect
|
||||
(grc/make-rect x y width height)
|
||||
|
||||
scalev
|
||||
(gpt/point (/ (:width draw-rect) (:width shape-rect))
|
||||
(/ (:height draw-rect) (:height shape-rect)))
|
||||
|
||||
movev
|
||||
(gpt/to-vec (gpt/point shape-rect) (gpt/point draw-rect))]
|
||||
|
||||
(-> shape
|
||||
(assoc :click-draw? false)
|
||||
(vary-meta merge {:mod? mod?})
|
||||
(gsh/transform-shape (-> (ctm/empty)
|
||||
(ctm/resize scalev (gpt/point x y))
|
||||
(ctm/move movev)))))
|
||||
shape))
|
||||
|
||||
(defn- update-drawing [state initial point lock? mod? snap-pixel?]
|
||||
(defn- update-drawing
|
||||
[state initial point lock? mod? snap-pixel?]
|
||||
(update-in state [:workspace-drawing :object] resize-shape initial point lock? mod? snap-pixel?))
|
||||
|
||||
(defn move-drawing
|
||||
@@ -128,7 +140,7 @@
|
||||
;; Take until before the snap calculation otherwise we could cancel the snap in the worker
|
||||
;; and its a problem for fast moving drawing
|
||||
(rx/take-until stopper)
|
||||
(rx/with-latest-from ms/mouse-position-shift ms/mouse-position-mod)
|
||||
(rx/with-latest-from ms/mouse-position-shift ms/mouse-position-alt)
|
||||
(rx/switch-map
|
||||
(fn [[point :as current]]
|
||||
(->> (snap/closest-snap-point page-id [shape] objects layout zoom focus point)
|
||||
|
||||
87
frontend/src/app/main/data/workspace/mcp.cljs
Normal file
87
frontend/src/app/main/data/workspace/mcp.cljs
Normal file
@@ -0,0 +1,87 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.main.data.workspace.mcp
|
||||
(:require
|
||||
[app.common.logging :as log]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.main.data.plugins :as dp]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.register :refer [mcp-plugin-id]]
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(log/set-level! :info)
|
||||
|
||||
(def ^:private default-manifest
|
||||
{:code "plugin.js"
|
||||
:name "Penpot MCP Plugin"
|
||||
:version 2
|
||||
:plugin-id mcp-plugin-id
|
||||
:description "This plugin enables interaction with the Penpot MCP server"
|
||||
:allow-background true
|
||||
:permissions
|
||||
#{"library:read" "library:write"
|
||||
"comment:read" "comment:write"
|
||||
"content:write" "content:read"}})
|
||||
|
||||
(defn finalize-workspace?
|
||||
[event]
|
||||
(= (ptk/type event) :app.main.data.workspace/finalize-workspace))
|
||||
|
||||
(defn init-mcp!
|
||||
[stream]
|
||||
(->> (rp/cmd! :get-current-mcp-token)
|
||||
(rx/subs!
|
||||
(fn [{:keys [token]}]
|
||||
(when token
|
||||
(dp/start-plugin!
|
||||
(assoc default-manifest
|
||||
:url (str (u/join cf/public-uri "plugins/mcp/manifest.json"))
|
||||
:host (str (u/join cf/public-uri "plugins/mcp/")))
|
||||
|
||||
;; API extension for MCP server
|
||||
#js {:mcp
|
||||
#js
|
||||
{:getToken (constantly token)
|
||||
:getServerUrl #(str cf/mcp-ws-uri)
|
||||
:setMcpStatus
|
||||
(fn [status]
|
||||
;; TODO: Visual feedback
|
||||
(log/info :hint "MCP STATUS" :status status))
|
||||
|
||||
:on
|
||||
(fn [event cb]
|
||||
(when-let [event
|
||||
(case event
|
||||
"disconnect" ::disconnect
|
||||
"connect" ::connect
|
||||
nil)]
|
||||
|
||||
(let [stopper (rx/filter finalize-workspace? stream)]
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? event))
|
||||
(rx/take-until stopper)
|
||||
(rx/subs! #(cb))))))}}))))))
|
||||
|
||||
(defn disconnect-mcp
|
||||
[]
|
||||
(st/emit! (ptk/data-event ::disconnect)))
|
||||
|
||||
(defn connect-mcp
|
||||
[]
|
||||
(st/emit! (ptk/data-event ::connect)))
|
||||
|
||||
(defn init-mcp-connexion
|
||||
[]
|
||||
(ptk/reify ::init-mcp-connexion
|
||||
ptk/EffectEvent
|
||||
(effect [_ state stream]
|
||||
(when (and (contains? cf/flags :mcp)
|
||||
(-> state :profile :props :mcp-status))
|
||||
(init-mcp! stream)))))
|
||||
@@ -620,68 +620,61 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
;; We do not allow to apply tokens while text editor is open.
|
||||
;; The classic text editor sets :workspace-editor-state; the WASM text editor
|
||||
;; does not, so we also check :workspace-local :edition for text shapes.
|
||||
(let [edition (get-in state [:workspace-local :edition])
|
||||
objects (dsh/lookup-page-objects state)
|
||||
text-editing? (and (some? edition)
|
||||
(= :text (:type (get objects edition))))]
|
||||
(when (and (empty? (get state :workspace-editor-state))
|
||||
(not text-editing?))
|
||||
(let [attributes-to-remove
|
||||
;; Remove atomic typography tokens when applying composite and vice-verca
|
||||
(cond
|
||||
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
|
||||
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
|
||||
:else attributes-to-remove)]
|
||||
(when-let [tokens (some-> (dsh/lookup-file-data state)
|
||||
(get :tokens-lib)
|
||||
(ctob/get-tokens-in-active-sets))]
|
||||
(->> (if (contains? cf/flags :tokenscript)
|
||||
(rx/of (ts/resolve-tokens tokens))
|
||||
(sd/resolve-tokens tokens))
|
||||
(rx/mapcat
|
||||
(fn [resolved-tokens]
|
||||
(let [undo-id (js/Symbol)
|
||||
objects (dsh/lookup-page-objects state)
|
||||
selected-shapes (select-keys objects shape-ids)
|
||||
(when (empty? (get state :workspace-editor-state))
|
||||
(let [attributes-to-remove
|
||||
;; Remove atomic typography tokens when applying composite and vice-verca
|
||||
(cond
|
||||
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
|
||||
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
|
||||
:else attributes-to-remove)]
|
||||
(when-let [tokens (some-> (dsh/lookup-file-data state)
|
||||
(get :tokens-lib)
|
||||
(ctob/get-tokens-in-active-sets))]
|
||||
(->> (if (contains? cf/flags :tokenscript)
|
||||
(rx/of (ts/resolve-tokens tokens))
|
||||
(sd/resolve-tokens tokens))
|
||||
(rx/mapcat
|
||||
(fn [resolved-tokens]
|
||||
(let [undo-id (js/Symbol)
|
||||
objects (dsh/lookup-page-objects state)
|
||||
selected-shapes (select-keys objects shape-ids)
|
||||
|
||||
shapes (->> selected-shapes
|
||||
(filter (fn [[_ shape]]
|
||||
(or
|
||||
(and (ctsl/any-layout-immediate-child? objects shape)
|
||||
(some ctt/spacing-margin-keys attributes))
|
||||
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
|
||||
(all-attrs-appliable-for-token? attributes (:type token)))))))
|
||||
shape-ids (d/nilv (keys shapes) [])
|
||||
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
|
||||
shapes (->> selected-shapes
|
||||
(filter (fn [[_ shape]]
|
||||
(or
|
||||
(and (ctsl/any-layout-immediate-child? objects shape)
|
||||
(some ctt/spacing-margin-keys attributes))
|
||||
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
|
||||
(all-attrs-appliable-for-token? attributes (:type token)))))))
|
||||
shape-ids (d/nilv (keys shapes) [])
|
||||
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
|
||||
|
||||
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
|
||||
resolved-value (if (contains? cf/flags :tokenscript)
|
||||
(ts/tokenscript-symbols->penpot-unit resolved-value)
|
||||
resolved-value)
|
||||
tokenized-attributes (cfo/attributes-map attributes token)
|
||||
type (:type token)]
|
||||
(rx/concat
|
||||
(rx/of
|
||||
(st/emit! (ev/event {::ev/name "apply-tokens"
|
||||
:type type
|
||||
:applied-to attributes
|
||||
:applied-to-variant any-variant?}))
|
||||
(dwu/start-undo-transaction undo-id)
|
||||
(dwsh/update-shapes shape-ids (fn [shape]
|
||||
(cond-> shape
|
||||
attributes-to-remove
|
||||
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
|
||||
:always
|
||||
(update :applied-tokens merge tokenized-attributes)))))
|
||||
(when on-update-shape
|
||||
(let [res (on-update-shape resolved-value shape-ids attributes)]
|
||||
;; Composed updates return observables and need to be executed differently
|
||||
(if (rx/observable? res)
|
||||
res
|
||||
(rx/of res))))
|
||||
(rx/of (dwu/commit-undo-transaction undo-id))))))))))))))
|
||||
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
|
||||
resolved-value (if (contains? cf/flags :tokenscript)
|
||||
(ts/tokenscript-symbols->penpot-unit resolved-value)
|
||||
resolved-value)
|
||||
tokenized-attributes (cfo/attributes-map attributes token)
|
||||
type (:type token)]
|
||||
(rx/concat
|
||||
(rx/of
|
||||
(st/emit! (ev/event {::ev/name "apply-tokens"
|
||||
:type type
|
||||
:applied-to attributes
|
||||
:applied-to-variant any-variant?}))
|
||||
(dwu/start-undo-transaction undo-id)
|
||||
(dwsh/update-shapes shape-ids (fn [shape]
|
||||
(cond-> shape
|
||||
attributes-to-remove
|
||||
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
|
||||
:always
|
||||
(update :applied-tokens merge tokenized-attributes)))))
|
||||
(when on-update-shape
|
||||
(let [res (on-update-shape resolved-value shape-ids attributes)]
|
||||
;; Composed updates return observables and need to be executed differently
|
||||
(if (rx/observable? res)
|
||||
res
|
||||
(rx/of res))))
|
||||
(rx/of (dwu/commit-undo-transaction undo-id)))))))))))))
|
||||
|
||||
(defn apply-spacing-token-separated
|
||||
"Handles edge-case for spacing token when applying token via toggle button.
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
(:require
|
||||
[app.common.json :as json]
|
||||
[app.common.path-names :as cpn]
|
||||
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.config :as cf]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.style-dictionary :as sd]
|
||||
[app.main.data.tokenscript :as ts]
|
||||
[app.main.data.workspace.tokens.errors :as wte]
|
||||
[app.main.store :as st]
|
||||
[app.util.i18n :refer [tr]]
|
||||
@@ -74,15 +75,18 @@
|
||||
(when unknown-tokens
|
||||
(st/emit! (show-unknown-types-warning unknown-tokens)))
|
||||
(try
|
||||
(->> (ctob/get-all-tokens-map tokens-lib)
|
||||
(sd/resolve-tokens-with-verbose-errors)
|
||||
(rx/map (fn [_]
|
||||
tokens-lib))
|
||||
(rx/catch (fn [sd-error]
|
||||
(let [reference-errors (extract-reference-errors sd-error)]
|
||||
(if reference-errors
|
||||
(rx/of tokens-lib)
|
||||
(throw (wte/error-ex-info :error.import/style-dictionary-unknown-error sd-error sd-error)))))))
|
||||
(let [tokens-tree (ctob/get-all-tokens-map tokens-lib)
|
||||
resolved-tokens (if (contains? cf/flags :tokenscript)
|
||||
(rx/of (ts/resolve-tokens tokens-tree))
|
||||
(sd/resolve-tokens-with-verbose-errors tokens-tree))]
|
||||
(->> resolved-tokens
|
||||
(rx/map (fn [_]
|
||||
tokens-lib))
|
||||
(rx/catch (fn [sd-error]
|
||||
(let [reference-errors (extract-reference-errors sd-error)]
|
||||
(if reference-errors
|
||||
(rx/of tokens-lib)
|
||||
(throw (wte/error-ex-info :error.import/style-dictionary-unknown-error sd-error sd-error))))))))
|
||||
(catch js/Error e
|
||||
(throw (wte/error-ex-info :error.import/style-dictionary-unknown-error "" e)))))
|
||||
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
|
||||
(ns app.main.data.workspace.tokens.propagation
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.logging :as l]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.token :as ctt]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.config :as cf]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.style-dictionary :as sd]
|
||||
[app.main.data.tokenscript :as ts]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.thumbnails :as dwt]
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
@@ -210,10 +213,13 @@
|
||||
(ptk/reify ::propagate-workspace-tokens
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(when-let [tokens-lib (-> (dsh/lookup-file-data state)
|
||||
(get :tokens-lib))]
|
||||
(->> (ctob/get-tokens-in-active-sets tokens-lib)
|
||||
(sd/resolve-tokens)
|
||||
(when-let [tokens-tree (-> (dsh/lookup-file-data state)
|
||||
(get :tokens-lib)
|
||||
(ctob/get-tokens-in-active-sets))]
|
||||
(->> (if (contains? cf/flags :tokenscript)
|
||||
(rx/of (-> (ts/resolve-tokens tokens-tree)
|
||||
(d/update-vals #(update % :resolved-value ts/tokenscript-symbols->penpot-unit))))
|
||||
(sd/resolve-tokens tokens-tree))
|
||||
(rx/mapcat (fn [sd-tokens]
|
||||
(let [undo-id (js/Symbol)]
|
||||
(rx/concat
|
||||
|
||||
@@ -548,7 +548,7 @@
|
||||
modif-tree
|
||||
(dwm/build-modif-tree ids objects get-modifier)]
|
||||
|
||||
(rx/of (dwm/apply-wasm-modifiers modif-tree :ignore-touched (:ignore-touched options))))
|
||||
(rx/of (dwm/apply-wasm-modifiers modif-tree)))
|
||||
|
||||
(let [page-id (or (:page-id options)
|
||||
(:current-page-id state))
|
||||
@@ -1173,7 +1173,8 @@
|
||||
(when add-component-to-variant?
|
||||
(rx/of (ev/event {::ev/name "add-component-to-variant"})))
|
||||
(when add-new-variant?
|
||||
(rx/of (ev/event {::ev/name "add-new-variant" ::ev/origin "workspace:move-shapes-to-frame"}))))))))
|
||||
(rx/of (ev/event {::ev/name "add-new-variant"
|
||||
::ev/origin "workspace:move-shapes-to-frame"}))))))))
|
||||
|
||||
(defn- get-displacement
|
||||
"Retrieve the correct displacement delta point for the
|
||||
|
||||
@@ -86,24 +86,6 @@
|
||||
:else
|
||||
(enabled-by-flags? state feature))))
|
||||
|
||||
(defn active-features?
|
||||
"Given a state and a set of features, check if the features are all enabled."
|
||||
([state a]
|
||||
(js/console.warn "Please, use active-feature? instead")
|
||||
(active-feature? state a))
|
||||
([state a b]
|
||||
(and ^boolean (active-feature? state a)
|
||||
^boolean (active-feature? state b)))
|
||||
([state a b c]
|
||||
(and ^boolean (active-feature? state a)
|
||||
^boolean (active-feature? state b)
|
||||
^boolean (active-feature? state c)))
|
||||
([state a b c & others]
|
||||
(and ^boolean (active-feature? state a)
|
||||
^boolean (active-feature? state b)
|
||||
^boolean (active-feature? state c)
|
||||
^boolean (every? #(active-feature? state %) others))))
|
||||
|
||||
(def ^:private features-ref
|
||||
(l/derived (l/key :features) st/state))
|
||||
|
||||
|
||||
@@ -183,6 +183,9 @@
|
||||
[id]
|
||||
(l/derived #(contains? % id) selected-shapes))
|
||||
|
||||
(def highlighted-shapes
|
||||
(l/derived :highlighted workspace-local))
|
||||
|
||||
(def export-in-progress?
|
||||
(l/derived :export-in-progress? export))
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
:settings-options
|
||||
:settings-feedback
|
||||
:settings-subscription
|
||||
:settings-access-tokens
|
||||
:settings-integrations
|
||||
:settings-notifications)
|
||||
(let [params (get params :query)
|
||||
error-report-id (some-> params :error-report-id uuid/parse*)]
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
(kbd/enter? event)
|
||||
(let [selected (dom/get-active)]
|
||||
(dom/prevent-default event)
|
||||
(dom/click! selected))
|
||||
(dom/click selected))
|
||||
|
||||
(kbd/tab? event)
|
||||
(on-close)))))]
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
input-name (get props :name)
|
||||
more-classes (get props :class)
|
||||
auto-focus? (get props :auto-focus? false)
|
||||
input-ref (mf/use-ref nil)
|
||||
|
||||
data-testid (d/nilv data-testid input-name)
|
||||
|
||||
@@ -82,7 +83,6 @@
|
||||
(swap! form assoc-in [:touched input-name] true)
|
||||
(fm/on-input-change form input-name value trim)
|
||||
(on-change-value name value)))
|
||||
|
||||
on-blur
|
||||
(fn [_]
|
||||
(reset! focus? false))
|
||||
@@ -92,9 +92,18 @@
|
||||
(when-not (get-in @form [:touched input-name])
|
||||
(swap! form assoc-in [:touched input-name] true)))
|
||||
|
||||
on-key-press
|
||||
(mf/use-fn
|
||||
(mf/deps input-ref)
|
||||
(fn [e]
|
||||
(dom/prevent-default e)
|
||||
(when (kbd/space? e)
|
||||
(dom/click (mf/ref-val input-ref)))))
|
||||
|
||||
props (-> props
|
||||
(dissoc :help-icon :form :trim :children :show-success? :auto-focus? :label)
|
||||
(assoc :id (name input-name)
|
||||
:ref input-ref
|
||||
:value value
|
||||
:auto-focus auto-focus?
|
||||
:on-click (when (or is-radio? is-checkbox?) on-click)
|
||||
@@ -131,7 +140,7 @@
|
||||
:for (name input-name)} label
|
||||
|
||||
(when is-checkbox?
|
||||
[:span {:class (stl/css-case :global/checked checked?)} (when checked? deprecated-icon/status-tick)])
|
||||
[:span {:class (stl/css-case :global/checked checked?) :tab-index "0" :on-key-press on-key-press} (when checked? deprecated-icon/status-tick)])
|
||||
|
||||
(if is-checkbox?
|
||||
[:> :input props]
|
||||
|
||||
@@ -9,14 +9,17 @@
|
||||
(:require
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
|
||||
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.keyboard :as k]
|
||||
[goog.events :as events]
|
||||
[rumext.v2 :as mf])
|
||||
(:import goog.events.EventType))
|
||||
(:import
|
||||
goog.events.EventType))
|
||||
|
||||
(mf/defc confirm-dialog
|
||||
{::mf/register modal/components
|
||||
@@ -68,8 +71,11 @@
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h2 {:class (stl/css :modal-title)} title]
|
||||
[:button {:class (stl/css :modal-close-btn)
|
||||
:on-click cancel-fn} deprecated-icon/close]]
|
||||
[:div {:class (stl/css :modal-close-btn)}
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "labels.close")
|
||||
:on-click cancel-fn
|
||||
:icon i/close}]]]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
(when (and (string? message) (not= message ""))
|
||||
@@ -87,24 +93,19 @@
|
||||
[:ul {:class (stl/css :component-list)}
|
||||
(for [item items]
|
||||
[:li {:class (stl/css :modal-item-element)}
|
||||
[:span {:class (stl/css :modal-component-icon)}
|
||||
deprecated-icon/component]
|
||||
[:> icon* {:icon-id i/component
|
||||
:class (stl/css :modal-component-icon)
|
||||
:size "s"}]
|
||||
[:span {:class (stl/css :modal-component-name)}
|
||||
(:name item)]])]])]
|
||||
|
||||
[:div {:class (stl/css :modal-footer)}
|
||||
[:div {:class (stl/css :action-buttons)}
|
||||
(when-not (= cancel-label :omit)
|
||||
[:input
|
||||
{:class (stl/css :cancel-button)
|
||||
:type "button"
|
||||
:value cancel-label
|
||||
:on-click cancel-fn}])
|
||||
|
||||
[:input
|
||||
{:class (stl/css-case :accept-btn true
|
||||
:danger (= accept-style :danger)
|
||||
:primary (= accept-style :primary))
|
||||
:type "button"
|
||||
:value accept-label
|
||||
:on-click accept-fn}]]]]]))
|
||||
[:> button* {:variant "secondary"
|
||||
:on-click cancel-fn}
|
||||
cancel-label])
|
||||
[:> button* {:variant (cond (= accept-style :danger) "destructive"
|
||||
(= accept-style :primary) "primary")
|
||||
:on-click accept-fn}
|
||||
accept-label]]]]]))
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
|
||||
.modal-container {
|
||||
@extend .modal-container-base;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: deprecated.$s-24;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xxl);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@@ -27,12 +26,13 @@
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
@extend .modal-close-btn-base;
|
||||
position: absolute;
|
||||
top: var(--sp-m);
|
||||
right: var(--sp-m);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@include deprecated.bodyLargeTypography;
|
||||
margin-bottom: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.modal-item-element {
|
||||
@@ -41,32 +41,18 @@
|
||||
|
||||
.modal-component-icon {
|
||||
@include deprecated.flexCenter;
|
||||
height: deprecated.$s-16;
|
||||
width: deprecated.$s-16;
|
||||
svg {
|
||||
@extend .button-icon-small;
|
||||
stroke: var(--color);
|
||||
}
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.modal-component-name {
|
||||
@include deprecated.bodyLargeTypography;
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
@extend .modal-action-btns;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
@extend .modal-cancel-btn;
|
||||
}
|
||||
|
||||
.accept-btn {
|
||||
@extend .modal-accept-btn;
|
||||
&.danger {
|
||||
@extend .modal-danger-btn;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-scd-msg,
|
||||
.modal-subtitle,
|
||||
.modal-msg {
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
(ns app.main.ui.dashboard.fonts
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cm]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
@@ -22,6 +24,7 @@
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.main.ui.notifications.context-notification :refer [context-notification]]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.http :as http]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.keyboard :as kbd]
|
||||
[beicon.v2.core :as rx]
|
||||
@@ -32,7 +35,7 @@
|
||||
(def ^:private accept-font-types
|
||||
(str (str/join "," cm/font-types)
|
||||
;; A workaround to solve a problem with chrome input selector
|
||||
",.ttf,application/font-woff,woff,.otf"))
|
||||
",.ttf,application/font-woff,.woff,.woff2,.otf"))
|
||||
|
||||
(defn- use-page-title
|
||||
[team section]
|
||||
@@ -116,10 +119,10 @@
|
||||
(swap! fonts* dissoc id)
|
||||
(swap! uploading* disj id)
|
||||
(st/emit! (df/add-font font)))
|
||||
(fn [error]
|
||||
(fn [cause]
|
||||
(st/emit! (ntf/error (tr "errors.bad-font" (first (:names item)))))
|
||||
(swap! fonts* dissoc id)
|
||||
(js/console.log "error" error))))))
|
||||
(ex/print-throwable cause))))))
|
||||
|
||||
on-upload
|
||||
(mf/use-fn
|
||||
@@ -259,11 +262,14 @@
|
||||
(mf/defc installed-font-context-menu
|
||||
{::mf/props :obj
|
||||
::mf/private true}
|
||||
[{:keys [is-open on-close on-edit on-delete]}]
|
||||
(let [options (mf/with-memo [on-edit on-delete]
|
||||
[{:keys [is-open on-close on-edit on-download on-delete]}]
|
||||
(let [options (mf/with-memo [on-edit on-download on-delete]
|
||||
[{:name (tr "labels.edit")
|
||||
:id "font-edit"
|
||||
:handler on-edit}
|
||||
{:name (tr "labels.download-simple")
|
||||
:id "font-download"
|
||||
:handler on-download}
|
||||
{:name (tr "labels.delete")
|
||||
:id "font-delete"
|
||||
:handler on-delete}])]
|
||||
@@ -345,6 +351,26 @@
|
||||
(st/emit! (df/delete-font font-id)))}]
|
||||
(st/emit! (modal/show options)))))
|
||||
|
||||
on-download
|
||||
(mf/use-fn
|
||||
(mf/deps variants)
|
||||
(fn [_event]
|
||||
(let [variant (first variants)
|
||||
variant-id (:id variant)
|
||||
multiple? (> (count variants) 1)
|
||||
cmd (if multiple? :download-font-family :download-font)
|
||||
params (if multiple? {:font-id font-id} {:id variant-id})]
|
||||
(->> (rp/cmd! cmd params)
|
||||
(rx/mapcat (fn [{:keys [name uri]}]
|
||||
(->> (http/send! {:uri uri :method :get :response-type :blob})
|
||||
(rx/map :body)
|
||||
(rx/map (fn [blob] (d/vec2 name blob))))))
|
||||
(rx/subs! (fn [[filename blob]]
|
||||
(dom/trigger-download filename blob))
|
||||
(fn [error]
|
||||
(js/console.error "error downloading font" error)
|
||||
(st/emit! (ntf/error (tr "errors.generic")))))))))
|
||||
|
||||
on-delete-variant
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
@@ -407,6 +433,7 @@
|
||||
{:on-close on-menu-close
|
||||
:is-open menu-open?
|
||||
:on-delete on-delete-font
|
||||
:on-download on-download
|
||||
:on-edit on-edit}]]))]))
|
||||
|
||||
(mf/defc installed-fonts*
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
(mf/use-ref nil)
|
||||
|
||||
on-import-files
|
||||
(fn [] (dom/click! (mf/ref-val file-input)))
|
||||
(fn [] (dom/click (mf/ref-val file-input)))
|
||||
|
||||
on-finish-import
|
||||
(mf/use-fn
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
[app.main.data.dashboard :as dd]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.nitrate :as dnt]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.team :as dtm]
|
||||
[app.main.refs :as refs]
|
||||
@@ -30,10 +31,13 @@
|
||||
[app.main.ui.dashboard.subscription :refer [dashboard-cta*
|
||||
get-subscription-type
|
||||
menu-team-icon*
|
||||
nitrate-sidebar*
|
||||
show-subscription-dashboard-banner?
|
||||
subscription-sidebar*]]
|
||||
[app.main.ui.dashboard.team-form]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
||||
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.main.ui.nitrate.nitrate-form]
|
||||
[app.util.dom :as dom]
|
||||
@@ -74,6 +78,8 @@
|
||||
(def ^:private exit-icon
|
||||
(deprecated-icon/icon-xref :exit (stl/css :exit-icon)))
|
||||
|
||||
(def ^:private ^:svg-id penpot-logo-icon "penpot-logo-icon")
|
||||
|
||||
(mf/defc sidebar-project*
|
||||
{::mf/private true}
|
||||
[{:keys [item is-selected]}]
|
||||
@@ -299,7 +305,7 @@
|
||||
(if (:nitrate-licence profile)
|
||||
;; TODO update when org creation route is ready
|
||||
(dom/open-new-window "/control-center/org/create")
|
||||
(st/emit! (modal/show :nitrate-form {})))))]
|
||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form)))))]
|
||||
|
||||
[:> dropdown-menu* props
|
||||
|
||||
@@ -497,18 +503,23 @@
|
||||
|
||||
(mf/defc sidebar-org-switch*
|
||||
[{:keys [team profile]}]
|
||||
(let [teams (->> (mf/deref refs/teams)
|
||||
vals
|
||||
(group-by :organization-id)
|
||||
(map (fn [[_group entries]] (first entries)))
|
||||
vec
|
||||
(d/index-by :id))
|
||||
(let [teams (mf/deref refs/teams)
|
||||
orgs (mf/with-memo [teams]
|
||||
(let [orgs (->> teams
|
||||
vals
|
||||
(group-by :organization-id)
|
||||
(map (fn [[_group entries]] (first entries)))
|
||||
vec
|
||||
(d/index-by :id))]
|
||||
(update-vals orgs
|
||||
(fn [t]
|
||||
(assoc t :name (str "ORG: " (:organization-name t)))))))
|
||||
|
||||
teams (update-vals teams
|
||||
(fn [t]
|
||||
(assoc t :name (str "ORG: " (:organization-name t)))))
|
||||
empty? (= (count orgs) 1)
|
||||
|
||||
team (assoc team :name (str "ORG: " (:organization-name team)))
|
||||
|
||||
current-org (mf/with-memo [team]
|
||||
(assoc team :name (str "ORG: " (:organization-name team))))
|
||||
|
||||
show-teams-menu*
|
||||
(mf/use-state false)
|
||||
@@ -530,36 +541,53 @@
|
||||
(dom/prevent-default event)
|
||||
(dom/stop-propagation event)
|
||||
(some-> (dom/get-current-target event)
|
||||
(dom/click!)))))
|
||||
(dom/click)))))
|
||||
close-teams-menu
|
||||
(mf/use-fn #(reset! show-teams-menu* false))]
|
||||
(mf/use-fn #(reset! show-teams-menu* false))
|
||||
|
||||
[:div {:class (stl/css :sidebar-team-switch)}
|
||||
[:div {:class (stl/css :switch-content)}
|
||||
[:button {:class (stl/css :current-team)
|
||||
:on-click on-show-teams-click
|
||||
:on-key-down on-show-teams-keydown}
|
||||
on-create-org-click
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(if (:nitrate-licence profile)
|
||||
;; TODO update when org creation route is ready
|
||||
(dom/open-new-window "/control-center/org/create")
|
||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form)))))]
|
||||
(if empty?
|
||||
[:div {:class (stl/css :nitrate-orgs-empty)}
|
||||
[:span {:class (stl/css :nitrate-penpot-icon)}
|
||||
[:> raw-svg* {:id penpot-logo-icon}]]
|
||||
"Penpot"
|
||||
[:> button* {:variant "ghost"
|
||||
:type "button"
|
||||
:class (stl/css :nitrate-create-org)
|
||||
:on-click on-create-org-click} (tr "dashboard.create-new-org")]]
|
||||
|
||||
[:div {:class (stl/css :team-name)}
|
||||
[:img {:src (cf/resolve-team-photo-url team)
|
||||
:class (stl/css :team-picture)
|
||||
:alt (:name team)}]
|
||||
[:span {:class (stl/css :team-text) :title (:name team)} (:name team)]]
|
||||
[:div {:class (stl/css :sidebar-team-switch)}
|
||||
[:div {:class (stl/css :switch-content)}
|
||||
[:button {:class (stl/css :current-team)
|
||||
:on-click on-show-teams-click
|
||||
:on-key-down on-show-teams-keydown}
|
||||
|
||||
arrow-icon]]
|
||||
[:div {:class (stl/css :team-name)}
|
||||
[:img {:src (cf/resolve-team-photo-url current-org)
|
||||
:class (stl/css :team-picture)
|
||||
:alt (:name current-org)}]
|
||||
[:span {:class (stl/css :team-text) :title (:name current-org)} (:name current-org)]]
|
||||
|
||||
;; Teams Dropdown
|
||||
arrow-icon]]
|
||||
|
||||
[:> teams-selector-dropdown* {:show show-teams-menu?
|
||||
:on-close close-teams-menu
|
||||
:id "organizations-list"
|
||||
:class (stl/css :dropdown :teams-dropdown)
|
||||
:team team
|
||||
:profile profile
|
||||
:teams teams
|
||||
:show-default-team false
|
||||
:allow-create-teams false
|
||||
:allow-create-org true}]]))
|
||||
;; Teams Dropdown
|
||||
|
||||
[:> teams-selector-dropdown* {:show show-teams-menu?
|
||||
:on-close close-teams-menu
|
||||
:id "organizations-list"
|
||||
:class (stl/css :dropdown :teams-dropdown)
|
||||
:team current-org
|
||||
:profile profile
|
||||
:teams orgs
|
||||
:show-default-team false
|
||||
:allow-create-teams false
|
||||
:allow-create-org true}]])))
|
||||
|
||||
(mf/defc sidebar-team-switch*
|
||||
[{:keys [team profile]}]
|
||||
@@ -601,7 +629,7 @@
|
||||
(dom/prevent-default event)
|
||||
(dom/stop-propagation event)
|
||||
(some-> (dom/get-current-target event)
|
||||
(dom/click!)))))
|
||||
(dom/click)))))
|
||||
|
||||
close-team-options-menu
|
||||
(mf/use-fn #(reset! show-team-options-menu* false))
|
||||
@@ -621,7 +649,7 @@
|
||||
(dom/stop-propagation event)
|
||||
|
||||
(some-> (dom/get-current-target event)
|
||||
(dom/click!)))))
|
||||
(dom/click)))))
|
||||
|
||||
close-teams-menu
|
||||
(mf/use-fn #(reset! show-teams-menu* false))]
|
||||
@@ -705,6 +733,8 @@
|
||||
overflow* (mf/use-state false)
|
||||
overflow? (deref overflow*)
|
||||
|
||||
nitrate? (contains? cf/flags :nitrate)
|
||||
|
||||
go-projects
|
||||
(mf/use-fn #(st/emit! (dcm/go-to-dashboard-recent)))
|
||||
|
||||
@@ -793,70 +823,71 @@
|
||||
(reset! overflow* (> scroll-height client-height))))
|
||||
|
||||
[:*
|
||||
[:div {:class (stl/css-case :sidebar-content true)
|
||||
:ref container}
|
||||
(when (contains? cf/flags :nitrate)
|
||||
[:> sidebar-org-switch* {:team team :profile profile}])
|
||||
[:> sidebar-team-switch* {:team team :profile profile}]
|
||||
[:div {:ref container}
|
||||
(when nitrate?
|
||||
[:div {:class (stl/css :nitrate-orgs-container)}
|
||||
[:> sidebar-org-switch* {:team team :profile profile}]])
|
||||
[:div {:class (stl/css-case :sidebar-content true :sidebar-content-nitrate nitrate?)}
|
||||
[:> sidebar-team-switch* {:team team :profile profile}]
|
||||
|
||||
[:> sidebar-search* {:search-term search-term
|
||||
:team-id (:id team)}]
|
||||
[:> sidebar-search* {:search-term search-term
|
||||
:team-id (:id team)}]
|
||||
|
||||
[:div {:class (stl/css :sidebar-content-section)}
|
||||
[:ul {:class (stl/css :sidebar-nav)}
|
||||
[:li {:class (stl/css-case :recent-projects true
|
||||
:sidebar-nav-item true
|
||||
:current projects?)}
|
||||
[:& link {:action go-projects
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-projects-with-key}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.projects")]]]
|
||||
[:div {:class (stl/css :sidebar-content-section)}
|
||||
[:ul {:class (stl/css :sidebar-nav)}
|
||||
[:li {:class (stl/css-case :recent-projects true
|
||||
:sidebar-nav-item true
|
||||
:current projects?)}
|
||||
[:& link {:action go-projects
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-projects-with-key}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.projects")]]]
|
||||
|
||||
[:li {:class (stl/css-case :current drafts?
|
||||
:sidebar-nav-item true)}
|
||||
[:& link {:action go-drafts
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-drafts-with-key}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.drafts")]]]]]
|
||||
[:li {:class (stl/css-case :current drafts?
|
||||
:sidebar-nav-item true)}
|
||||
[:& link {:action go-drafts
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-drafts-with-key}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.drafts")]]]]]
|
||||
|
||||
|
||||
[:div {:class (stl/css :sidebar-content-section)}
|
||||
[:div {:class (stl/css :sidebar-section-title)}
|
||||
(tr "labels.sources")]
|
||||
[:ul {:class (stl/css :sidebar-nav)}
|
||||
[:li {:class (stl/css-case :sidebar-nav-item true
|
||||
:current fonts?)}
|
||||
[:& link {:action go-fonts
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-fonts-with-key
|
||||
:data-testid "fonts"}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.fonts")]]]
|
||||
[:li {:class (stl/css-case :current libs?
|
||||
:sidebar-nav-item true)}
|
||||
[:& link {:action go-libs
|
||||
:data-testid "libs-link-sidebar"
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-libs-with-key}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]]
|
||||
[:div {:class (stl/css :sidebar-content-section)}
|
||||
[:div {:class (stl/css :sidebar-section-title)}
|
||||
(tr "labels.sources")]
|
||||
[:ul {:class (stl/css :sidebar-nav)}
|
||||
[:li {:class (stl/css-case :sidebar-nav-item true
|
||||
:current fonts?)}
|
||||
[:& link {:action go-fonts
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-fonts-with-key
|
||||
:data-testid "fonts"}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.fonts")]]]
|
||||
[:li {:class (stl/css-case :current libs?
|
||||
:sidebar-nav-item true)}
|
||||
[:& link {:action go-libs
|
||||
:data-testid "libs-link-sidebar"
|
||||
:class (stl/css :sidebar-link)
|
||||
:keyboard-action go-libs-with-key}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]]
|
||||
|
||||
|
||||
[:div {:class (stl/css :sidebar-content-section)
|
||||
:data-testid "pinned-projects"}
|
||||
[:div {:class (stl/css :sidebar-section-title)}
|
||||
(tr "labels.pinned-projects")]
|
||||
(if (some? pinned-projects)
|
||||
[:ul {:class (stl/css :sidebar-nav :pinned-projects)}
|
||||
(for [item pinned-projects]
|
||||
[:> sidebar-project*
|
||||
{:item item
|
||||
:key (dm/str (:id item))
|
||||
:id (:id item)
|
||||
:team-id (:id team)
|
||||
:is-selected (= (:id item) (:id project))}])]
|
||||
[:div {:class (stl/css :sidebar-empty-placeholder)}
|
||||
pin-icon
|
||||
[:span {:class (stl/css :empty-text)} (tr "dashboard.no-projects-placeholder")]])]]
|
||||
[:div {:class (stl/css-case :separator true :overflow-separator overflow?)}]]))
|
||||
[:div {:class (stl/css :sidebar-content-section)
|
||||
:data-testid "pinned-projects"}
|
||||
[:div {:class (stl/css :sidebar-section-title)}
|
||||
(tr "labels.pinned-projects")]
|
||||
(if (some? pinned-projects)
|
||||
[:ul {:class (stl/css :sidebar-nav :pinned-projects)}
|
||||
(for [item pinned-projects]
|
||||
[:> sidebar-project*
|
||||
{:item item
|
||||
:key (dm/str (:id item))
|
||||
:id (:id item)
|
||||
:team-id (:id team)
|
||||
:is-selected (= (:id item) (:id project))}])]
|
||||
[:div {:class (stl/css :sidebar-empty-placeholder)}
|
||||
pin-icon
|
||||
[:span {:class (stl/css :empty-text)} (tr "dashboard.no-projects-placeholder")]])]]
|
||||
[:div {:class (stl/css-case :separator true :overflow-separator overflow?)}]]]))
|
||||
|
||||
(mf/defc help-learning-menu*
|
||||
{::mf/props :obj
|
||||
@@ -1056,10 +1087,13 @@
|
||||
(dom/open-new-window "https://penpot.app/pricing")))]
|
||||
|
||||
[:*
|
||||
(when (contains? cf/flags :subscriptions)
|
||||
(if (show-subscription-dashboard-banner? profile)
|
||||
[:> dashboard-cta* {:profile profile}]
|
||||
[:> subscription-sidebar* {:profile profile}]))
|
||||
(if (contains? cf/flags :nitrate)
|
||||
(when-not (:nitrate-licence profile)
|
||||
[:> nitrate-sidebar* {:profile profile}])
|
||||
(when (contains? cf/flags :subscriptions)
|
||||
(if (show-subscription-dashboard-banner? profile)
|
||||
[:> dashboard-cta* {:profile profile}]
|
||||
[:> subscription-sidebar* {:profile profile}])))
|
||||
|
||||
;; TODO remove this block when subscriptions is full implemented
|
||||
(when (contains? cf/flags :subscriptions-old)
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-content-nitrate {
|
||||
padding: var(--sp-m) 0 0 0;
|
||||
border-block-start: $b-1 solid var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: var(--sp-xxs);
|
||||
width: 94%;
|
||||
@@ -514,3 +519,44 @@
|
||||
@include t.use-typography("body-small");
|
||||
color: var(--color-accent-tertiary);
|
||||
}
|
||||
|
||||
.nitrate-orgs-container {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: calc(2 * var(--sp-xxxl));
|
||||
max-height: calc(2 * var(--sp-xxxl));
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-xs) var(--sp-l) var(--sp-xs) var(--sp-s);
|
||||
// border-block-end: $b-1 solid var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.nitrate-orgs-empty {
|
||||
@include t.use-typography("body-medium");
|
||||
color: var(--color-foreground-primary);
|
||||
width: 100%;
|
||||
margin: var(--sp-xs) var(--sp-l);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
.nitrate-create-org {
|
||||
margin-inline-start: auto;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nitrate-penpot-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
height: var(--sp-xxxl);
|
||||
width: var(--sp-xxxl);
|
||||
background-color: var(--color-foreground-primary);
|
||||
|
||||
svg {
|
||||
fill: var(--icon-stroke-color);
|
||||
width: var(--sp-xxl);
|
||||
height: var(--sp-xxl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.config :as cf]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.nitrate :as dnt]
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.dropdown-menu :refer [dropdown-menu-item*]]
|
||||
@@ -115,6 +116,26 @@
|
||||
:has-dropdown false
|
||||
:is-highlighted false}]))))
|
||||
|
||||
(mf/defc nitrate-sidebar*
|
||||
[]
|
||||
(let [handle-click
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form))))]
|
||||
|
||||
;; TODO add translations for this texts when we have the definitive ones
|
||||
[:div {:class (stl/css :nitrate-banner :highlighted)}
|
||||
|
||||
[:div {:class (stl/css :nitrate-content)}
|
||||
[:span {:class (stl/css :nitrate-title)} "Unlock Nitrate features"]]
|
||||
[:div {:class (stl/css :nitrate-content)}
|
||||
|
||||
[:span {:class (stl/css :nitrate-info)} "Some further information and explanation."]
|
||||
[:> button* {:variant "primary"
|
||||
:type "button"
|
||||
:class (stl/css :cta-bottom-button :nitrate-bottom-button)
|
||||
:on-click handle-click} "UPGRADE TO NITRATE"]]]))
|
||||
|
||||
(mf/defc team*
|
||||
[{:keys [is-owner team]}]
|
||||
(let [subscription (:subscription team)
|
||||
|
||||
@@ -205,3 +205,32 @@
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.nitrate-banner {
|
||||
display: flex;
|
||||
border-radius: var(--sp-s);
|
||||
flex-direction: column;
|
||||
margin: var(--sp-m);
|
||||
background: var(--color-background-quaternary);
|
||||
border: $b-1 solid var(--color-accent-primary-muted);
|
||||
padding: var(--sp-l);
|
||||
}
|
||||
|
||||
.nitrate-title {
|
||||
@include t.use-typography("body-large");
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
|
||||
.nitrate-info {
|
||||
@include t.use-typography("body-medium");
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.nitrate-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nitrate-bottom-button {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ $sz-32: px2rem(32);
|
||||
$sz-36: px2rem(36);
|
||||
$sz-40: px2rem(40);
|
||||
$sz-48: px2rem(48);
|
||||
$sz-64: px2rem(64);
|
||||
$sz-88: px2rem(88);
|
||||
$sz-96: px2rem(96);
|
||||
$sz-120: px2rem(120);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
[:map
|
||||
[:class {:optional true} :string]
|
||||
[:tooltip-class {:optional true} [:maybe :string]]
|
||||
[:type {:optional true} [:maybe [:enum "button" "submit" "reset"]]]
|
||||
[:icon-class {:optional true} :string]
|
||||
[:icon
|
||||
[:and :string [:fn #(contains? icon-list %)]]]
|
||||
@@ -29,7 +30,7 @@
|
||||
(mf/defc icon-button*
|
||||
{::mf/schema schema:icon-button
|
||||
::mf/memo true}
|
||||
[{:keys [class icon icon-class variant aria-label children tooltip-placement tooltip-class] :rest props}]
|
||||
[{:keys [class icon icon-class variant aria-label children tooltip-placement tooltip-class type] :rest props}]
|
||||
(let [variant
|
||||
(d/nilv variant "primary")
|
||||
|
||||
@@ -47,6 +48,7 @@
|
||||
props
|
||||
(mf/spread-props props
|
||||
{:class [class button-class]
|
||||
:type (d/nilv type "button")
|
||||
:aria-labelledby tooltip-id})]
|
||||
|
||||
[:> tooltip* {:content aria-label
|
||||
|
||||
@@ -11,6 +11,7 @@ $mint-250: #00d1b8;
|
||||
$mint-700: #426158;
|
||||
$mint-150-60: #7efff599;
|
||||
$mint-250-10: #00d1b81a;
|
||||
$mint-250-70: #00d1b8b3;
|
||||
|
||||
$green-200: #a7e8d9;
|
||||
$green-500: #2d9f8f;
|
||||
@@ -33,6 +34,7 @@ $purple-500: #a977d1;
|
||||
$purple-600: #8c33eb;
|
||||
$purple-700: #6911d4;
|
||||
$purple-600-10: #8c33eb1a;
|
||||
$purple-600-70: #8c33ebb3;
|
||||
$purple-700-60: #6911d499;
|
||||
|
||||
$aqua-200: #ddf7ff;
|
||||
@@ -77,6 +79,7 @@ $grayish-red: #bfbfbf;
|
||||
--color-accent-quaternary: #{$pink-400};
|
||||
--color-accent-overlay: #{$purple-700-60};
|
||||
--color-accent-select: #{$purple-600-10};
|
||||
--color-accent-background-select: #{$purple-600-70};
|
||||
--color-accent-action: #{$purple-400};
|
||||
--color-accent-action-hover: #{$purple-500};
|
||||
--color-accent-off: #{$gray-50};
|
||||
@@ -128,6 +131,7 @@ $grayish-red: #bfbfbf;
|
||||
--color-accent-quaternary: #{$pink-400};
|
||||
--color-accent-overlay: #{$mint-150-60};
|
||||
--color-accent-select: #{$mint-250-10};
|
||||
--color-accent-background-select: #{$mint-250-70};
|
||||
--color-accent-action: #{$purple-400};
|
||||
--color-accent-action-hover: #{$purple-500};
|
||||
--color-accent-off: #{$gray-50};
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.constants :refer [max-input-length]]
|
||||
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
|
||||
[app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
|
||||
@@ -52,10 +51,11 @@
|
||||
:has-hint has-hint
|
||||
:hint-type hint-type
|
||||
:variant variant})]
|
||||
[:div {:class (dm/str class " " (stl/css-case :input-wrapper true
|
||||
:variant-dense (= variant "dense")
|
||||
:variant-comfortable (= variant "comfortable")
|
||||
:has-hint has-hint))}
|
||||
|
||||
[:div {:class [class (stl/css-case :input-wrapper true
|
||||
:variant-dense (= variant "dense")
|
||||
:variant-comfortable (= variant "comfortable")
|
||||
:has-hint has-hint)]}
|
||||
(when has-label
|
||||
[:> label* {:for id :is-optional is-optional} label])
|
||||
[:> input-field* props]
|
||||
@@ -64,4 +64,3 @@
|
||||
:class hint-class
|
||||
:message hint-message
|
||||
:type hint-type}])]))
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
(def ^:private schema:options-dropdown
|
||||
[:map
|
||||
[:ref {:optional true} fn?]
|
||||
[:class {:optional true} :string]
|
||||
[:wrapper-ref {:optional true} :any]
|
||||
[:on-click fn?]
|
||||
[:options [:vector schema:option]]
|
||||
[:selected {:optional true} :any]
|
||||
@@ -60,6 +62,7 @@
|
||||
(case type
|
||||
:group
|
||||
[:li {:class (stl/css :group-option)
|
||||
:role "presentation"
|
||||
:key (weak-key option)}
|
||||
[:> icon*
|
||||
{:icon-id i/arrow-down
|
||||
@@ -72,7 +75,7 @@
|
||||
[:hr {:key (weak-key option) :class (stl/css :option-separator)}]
|
||||
|
||||
:empty
|
||||
[:li {:key (weak-key option) :class (stl/css :option-empty)}
|
||||
[:li {:key (weak-key option) :class (stl/css :option-empty) :role "presentation"}
|
||||
(get option :label)]
|
||||
|
||||
;; Token option
|
||||
@@ -83,6 +86,7 @@
|
||||
:name name
|
||||
:resolved (get option :resolved-value)
|
||||
:ref ref
|
||||
:role "option"
|
||||
:focused (= id focused)
|
||||
:on-click on-click}]
|
||||
|
||||
@@ -94,6 +98,7 @@
|
||||
:aria-label (get option :aria-label)
|
||||
:icon (get option :icon)
|
||||
:ref ref
|
||||
:role "option"
|
||||
:focused (= id focused)
|
||||
:dimmed (true? (:dimmed option))
|
||||
:on-click on-click}]))))
|
||||
@@ -101,15 +106,16 @@
|
||||
|
||||
(mf/defc options-dropdown*
|
||||
{::mf/schema schema:options-dropdown}
|
||||
[{:keys [ref on-click options selected focused empty-to-end align] :rest props}]
|
||||
[{:keys [ref on-click options selected focused empty-to-end align wrapper-ref class] :rest props}]
|
||||
(let [align
|
||||
(d/nilv align :left)
|
||||
|
||||
props
|
||||
(mf/spread-props props
|
||||
{:class (stl/css-case :option-list true
|
||||
:left-align (= align :left)
|
||||
:right-align (= align :right))
|
||||
{:class [class (stl/css-case :option-list true
|
||||
:left-align (= align :left)
|
||||
:right-align (= align :right))]
|
||||
:ref wrapper-ref
|
||||
:tab-index "-1"
|
||||
:role "listbox"})
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
:id id
|
||||
:on-click on-click
|
||||
:data-id id
|
||||
:aria-label name
|
||||
:data-testid "dropdown-option"}
|
||||
|
||||
(if selected
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
:on-click on-icon-click}])
|
||||
(if aria-label
|
||||
[:> tooltip* {:content aria-label
|
||||
:class (stl/css :tooltip-wrapper)
|
||||
:id tooltip-id}
|
||||
[:> "input" props]]
|
||||
[:> "input" props])
|
||||
|
||||
@@ -120,3 +120,7 @@
|
||||
color: var(--color-foreground-secondary);
|
||||
min-inline-size: var(--sp-l);
|
||||
}
|
||||
|
||||
.tooltip-wrapper {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC
|
||||
|
||||
@use "ds/typography.scss" as t;
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
|
||||
@mixin textEllipsis {
|
||||
display: block;
|
||||
max-width: 99%;
|
||||
@@ -20,3 +24,73 @@
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/// Custom Scrollbar Mixin
|
||||
/// @param {Color} $thumb-color - Base thumb color
|
||||
/// @param {Color} $thumb-hover-color - Thumb color on hover
|
||||
/// @param {Length} $size - Scrollbar size (width/height)
|
||||
/// @param {Length} $radius - Thumb border radius
|
||||
/// @param {Length} $border - Inner transparent border size
|
||||
/// @param {Bool} $include-selection - Include ::selection styles
|
||||
/// @param {Bool} $include-placeholder - Include placeholder styles
|
||||
@mixin custom-scrollbar(
|
||||
$thumb-color: #aab5ba4d,
|
||||
$thumb-hover-color: #aab5bab3,
|
||||
$size: $sz-12,
|
||||
$radius: $br-8,
|
||||
$border: $b-2,
|
||||
$include-selection: true,
|
||||
$include-placeholder: true
|
||||
) {
|
||||
// Firefox
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #{$thumb-color} transparent;
|
||||
|
||||
&:hover {
|
||||
scrollbar-color: #{$thumb-hover-color} transparent;
|
||||
}
|
||||
|
||||
// Webkit (legacy support)
|
||||
&::-webkit-scrollbar {
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track,
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $thumb-color;
|
||||
background-clip: content-box;
|
||||
border: $border solid transparent;
|
||||
border-radius: $radius;
|
||||
|
||||
&:hover {
|
||||
background-color: $thumb-hover-color;
|
||||
}
|
||||
}
|
||||
|
||||
@if $include-selection {
|
||||
&::selection {
|
||||
background: var(--color-accent-background-select);
|
||||
color: var(--color-static-white);
|
||||
}
|
||||
}
|
||||
|
||||
@if $include-placeholder {
|
||||
&::placeholder {
|
||||
@include t.use-typography("body-small");
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
// Legacy webkit
|
||||
&::-webkit-input-placeholder {
|
||||
@include t.use-typography("body-small");
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.ds.controls.input :refer [input*]]
|
||||
[app.main.ui.ds.controls.select :refer [select*]]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.forms :as fm]
|
||||
[app.util.keyboard :as k]
|
||||
@@ -47,6 +48,23 @@
|
||||
|
||||
[:> input* props]))
|
||||
|
||||
(mf/defc form-select*
|
||||
[{:keys [name] :as props}]
|
||||
(let [select-name name
|
||||
form (mf/use-ctx context)
|
||||
value (get-in @form [:data select-name] "")
|
||||
|
||||
handle-change
|
||||
(fn [event]
|
||||
(let [value (if (string? event) event (dom/get-target-val event))]
|
||||
(fm/on-input-change form select-name value)))
|
||||
|
||||
props
|
||||
(mf/spread-props props {:on-change handle-change
|
||||
:value value})]
|
||||
|
||||
[:> select* props]))
|
||||
|
||||
(mf/defc form-submit*
|
||||
[{:keys [disabled on-submit] :rest props}]
|
||||
(let [form (mf/use-ctx context)
|
||||
@@ -79,4 +97,4 @@
|
||||
(when (fn? on-submit)
|
||||
(on-submit form event))))]
|
||||
[:> (mf/provider context) {:value form}
|
||||
[:form {:class class :on-submit on-submit'} children]]))
|
||||
[:form {:class class :on-submit on-submit'} children]]))
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
[app.common.types.component :as ctk]
|
||||
[app.main.data.viewer :as dv]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner*]]
|
||||
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner]]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.keyboard :as kbd]
|
||||
[okulary.core :as l]
|
||||
@@ -26,6 +26,7 @@
|
||||
(mf/defc layer-item
|
||||
[{:keys [item selected objects depth component-child? hide-toggle?] :as props}]
|
||||
(let [id (:id item)
|
||||
hidden? (:hidden item)
|
||||
selected? (contains? selected id)
|
||||
item-ref (mf/use-ref nil)
|
||||
depth (+ depth 1)
|
||||
@@ -67,17 +68,18 @@
|
||||
(when (and (= (count selected) 1) selected?)
|
||||
(dom/scroll-into-view-if-needed! (mf/ref-val item-ref) true))))
|
||||
|
||||
[:> layer-item-inner*
|
||||
[:& layer-item-inner
|
||||
{:ref item-ref
|
||||
:item item
|
||||
:depth depth
|
||||
:is-read-only true
|
||||
:is-highlighted false
|
||||
:is-selected selected?
|
||||
:is-component-tree component-tree?
|
||||
:is-filtered false
|
||||
:is-expanded expanded?
|
||||
:hide-toggle hide-toggle?
|
||||
:read-only? true
|
||||
:highlighted? false
|
||||
:selected? selected?
|
||||
:component-tree? component-tree?
|
||||
:hidden? hidden?
|
||||
:filtered? false
|
||||
:expanded? expanded?
|
||||
:hide-toggle? hide-toggle?
|
||||
:on-select-shape select-shape
|
||||
:on-toggle-collapse toggle-collapse}
|
||||
|
||||
|
||||
@@ -7,42 +7,86 @@
|
||||
(ns app.main.ui.nitrate.nitrate-form
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.ui.components.forms :as fm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
|
||||
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
|
||||
[app.util.dom :as dom]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
;; FIXME: rename to `form` (remove the nitrate prefix from namespace,
|
||||
;; because it is already under nitrate)
|
||||
(def ^:private schema:nitrate-form
|
||||
[:map {:title "NitrateForm"}
|
||||
[:subscription [::sm/one-of #{:monthly :yearly}]]])
|
||||
|
||||
(mf/defc nitrate-form-modal*
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :nitrate-form}
|
||||
[]
|
||||
(let [on-click
|
||||
::mf/register-as :nitrate-form
|
||||
::mf/wrap-props true}
|
||||
[connectivity]
|
||||
|
||||
(let [online? (:licenses connectivity)
|
||||
initial (mf/with-memo []
|
||||
{:subscription "yearly"})
|
||||
form (fm/use-form :schema schema:nitrate-form
|
||||
:initial initial)
|
||||
on-click
|
||||
(mf/use-fn
|
||||
(mf/deps form)
|
||||
(fn []
|
||||
(dom/open-new-window "/control-center/licenses/start")))]
|
||||
(let [params (:clean-data @form)]
|
||||
(dom/open-new-window (str "/control-center/licenses/start?subscription=" (name (:subscription params)))))))]
|
||||
|
||||
[:div {:class (stl/css :modal-overlay)}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:div {:class (stl/css :nitrate-form)}
|
||||
[:div {:class (stl/css :modal-dialog :subscription-success)}
|
||||
[:button {:class (stl/css :close-btn) :on-click modal/hide!}
|
||||
[:> icon* {:icon-id "close"
|
||||
:size "m"}]]
|
||||
[:div {:class (stl/css :modal-success-content)}
|
||||
[:div {:class (stl/css :modal-start)}
|
||||
;; TODO this svg is a placeholder. Use the proper one when created
|
||||
[:> raw-svg* {:id "logo-subscription"}]]
|
||||
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h2 {:class (stl/css :modal-title)}
|
||||
"BUY NITRATE"]
|
||||
[:div {:class (stl/css :modal-end)}
|
||||
[:div {:class (stl/css :modal-title)}
|
||||
"Unlock Nitrate Features"]
|
||||
|
||||
[:button {:class (stl/css :modal-close-btn)
|
||||
:on-click modal/hide!} deprecated-icon/close]]
|
||||
[:p {:class (stl/css :modal-text-large)}
|
||||
"Prow scuttle parrel provost."]
|
||||
[:p {:class (stl/css :modal-text-large)}
|
||||
"Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl."]
|
||||
[:p {:class (stl/css :modal-text-large)}
|
||||
"Deadlights jack lad schooner scallywag dance the hempen jig carouser broadside cable strike colors."]
|
||||
(if online?
|
||||
[:& fm/form {:form form}
|
||||
[:p {:class (stl/css :modal-text-large)}
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
"Nitrate is so cool! You should buy it!"]
|
||||
[:& fm/radio-buttons
|
||||
{:options [{:label "Price Tag Montly" :value "monthly"}
|
||||
{:label "Price Tag Yearly (Discount)" :value "yearly"}]
|
||||
:name :subscription
|
||||
:class (stl/css :radio-btns)}]]
|
||||
|
||||
[:div {:class (stl/css :modal-footer)}
|
||||
[:div {:class (stl/css :action-buttons)}
|
||||
[:> button* {:variant "primary"
|
||||
:on-click on-click}
|
||||
"BUY NOW!"]]]]]]))
|
||||
[:p {:class (stl/css :modal-text-large :modal-buttons-section)}
|
||||
[:div {:class (stl/css :modal-buttons-section)}
|
||||
[:> button* {:variant "primary"
|
||||
:on-click on-click
|
||||
:class (stl/css :modal-button)}
|
||||
"UPGRADE TO NITRATE"]
|
||||
[:div {:class (stl/css :modal-text-small :modal-info)}
|
||||
"Cancel anytime before your next billing cycle."]]]
|
||||
|
||||
|
||||
[:p {:class (stl/css :modal-text-medium)}
|
||||
[:a {:class (stl/css :link)}
|
||||
"See my current plan"]]]
|
||||
|
||||
[:div {:class (stl/css :contact)}
|
||||
[:p {:class (stl/css :modal-text-large)}
|
||||
"Contact us to upgrade to Nitrate:"]
|
||||
[:p {:class (stl/css :modal-text-large)}
|
||||
[:a {:class (stl/css :link) :href "mailto:sales@penpot.app"}
|
||||
"sales@penpot.app"]]])]]]]))
|
||||
|
||||
|
||||
|
||||
@@ -5,48 +5,94 @@
|
||||
// Copyright (c) KALEIDOS INC
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
@use "ds/typography.scss" as t;
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/spacing.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/_utils.scss" as *;
|
||||
|
||||
.modal-overlay {
|
||||
@extend .modal-overlay-base;
|
||||
z-index: var(--z-index-notifications);
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
.modal-dialog {
|
||||
@extend .modal-container-base;
|
||||
max-block-size: initial;
|
||||
min-inline-size: px2rem(648);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.uppercaseTitleTipography;
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
.close-btn {
|
||||
@extend .modal-close-btn-base;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin-bottom: deprecated.$s-24;
|
||||
.modal-title {
|
||||
@include t.use-typography("title-large");
|
||||
margin-block-end: var(--sp-xxxl);
|
||||
color: var(--modal-title-foreground-color);
|
||||
display: flex;
|
||||
gap: var(--sp-m);
|
||||
}
|
||||
|
||||
.nitrate-form {
|
||||
min-width: deprecated.$s-400;
|
||||
.modal-text-large {
|
||||
@include t.use-typography("body-large");
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
@extend .modal-action-btns;
|
||||
.modal-text-medium {
|
||||
@include t.use-typography("body-medium");
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
@extend .modal-cancel-btn;
|
||||
.modal-text-small {
|
||||
@include t.use-typography("body-small");
|
||||
}
|
||||
|
||||
.accept-btn {
|
||||
@extend .modal-accept-btn;
|
||||
.modal-info {
|
||||
margin-block-start: var(--sp-s);
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
@extend .modal-danger-btn;
|
||||
.modal-content,
|
||||
.modal-end {
|
||||
color: var(--color-foreground-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-success-content {
|
||||
display: flex;
|
||||
gap: $sz-40;
|
||||
}
|
||||
|
||||
.modal-start {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
max-inline-size: $sz-224;
|
||||
|
||||
svg {
|
||||
inline-size: 100%;
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
@media (max-inline-size: 992px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.radio-btns {
|
||||
label {
|
||||
@include t.use-typography("body-large");
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--sp-l) 0 0 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.contact {
|
||||
margin-block-start: $sz-96;
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
["/feedback" :settings-feedback]
|
||||
["/options" :settings-options]
|
||||
["/subscriptions" :settings-subscription]
|
||||
["/access-tokens" :settings-access-tokens]
|
||||
["/integrations" :settings-integrations]
|
||||
["/notifications" :settings-notifications]]
|
||||
|
||||
["/frame-preview" :frame-preview]
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.hooks :as hooks]
|
||||
[app.main.ui.modal :refer [modal-container*]]
|
||||
[app.main.ui.settings.access-tokens :refer [access-tokens-page]]
|
||||
[app.main.ui.settings.change-email]
|
||||
[app.main.ui.settings.delete-account]
|
||||
[app.main.ui.settings.feedback :refer [feedback-page*]]
|
||||
[app.main.ui.settings.integrations :refer [integrations-page*]]
|
||||
[app.main.ui.settings.notifications :refer [notifications-page*]]
|
||||
[app.main.ui.settings.options :refer [options-page]]
|
||||
[app.main.ui.settings.password :refer [password-page]]
|
||||
@@ -73,8 +73,8 @@
|
||||
:settings-subscription
|
||||
[:> subscription-page* {:profile profile}]
|
||||
|
||||
:settings-access-tokens
|
||||
[:& access-tokens-page]
|
||||
:settings-integrations
|
||||
[:> integrations-page*]
|
||||
|
||||
:settings-notifications
|
||||
[:& notifications-page* {:profile profile}])]]]]))
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.main.ui.settings.access-tokens
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.profile :as du]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
|
||||
[app.main.ui.components.forms :as fm]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.util.clipboard :as clipboard]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[app.util.keyboard :as kbd]
|
||||
[okulary.core :as l]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def ^:private clipboard-icon
|
||||
(deprecated-icon/icon-xref :clipboard (stl/css :clipboard-icon)))
|
||||
|
||||
(def ^:private close-icon
|
||||
(deprecated-icon/icon-xref :close (stl/css :close-icon)))
|
||||
|
||||
(def ^:private menu-icon
|
||||
(deprecated-icon/icon-xref :menu (stl/css :menu-icon)))
|
||||
|
||||
(def tokens-ref
|
||||
(l/derived :access-tokens st/state))
|
||||
|
||||
(def token-created-ref
|
||||
(l/derived :access-token-created st/state))
|
||||
|
||||
(def ^:private schema:form
|
||||
[:map {:title "AccessTokenForm"}
|
||||
[:name [::sm/text {:max 250}]]
|
||||
[:expiration-date [::sm/text {:max 250}]]])
|
||||
|
||||
(def initial-data
|
||||
{:name "" :expiration-date "never"})
|
||||
|
||||
(mf/defc access-token-modal
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :access-token}
|
||||
[]
|
||||
(let [form (fm/use-form
|
||||
:initial initial-data
|
||||
:schema schema:form)
|
||||
|
||||
created (mf/deref token-created-ref)
|
||||
created? (mf/use-state false)
|
||||
|
||||
on-success
|
||||
(mf/use-fn
|
||||
(mf/deps created)
|
||||
(fn [_]
|
||||
(let [message (tr "dashboard.access-tokens.create.success")]
|
||||
(st/emit! (du/fetch-access-tokens)
|
||||
(ntf/success message)
|
||||
(reset! created? true)))))
|
||||
|
||||
on-close
|
||||
(mf/use-fn
|
||||
(mf/deps created)
|
||||
(fn [_]
|
||||
(reset! created? false)
|
||||
(st/emit! (modal/hide))))
|
||||
|
||||
on-error
|
||||
(mf/use-fn
|
||||
(fn [_]
|
||||
(st/emit! (ntf/error (tr "errors.generic"))
|
||||
(modal/hide))))
|
||||
|
||||
on-submit
|
||||
(mf/use-fn
|
||||
(fn [form]
|
||||
(let [cdata (:clean-data @form)
|
||||
mdata {:on-success (partial on-success form)
|
||||
:on-error (partial on-error form)}
|
||||
expiration (:expiration-date cdata)
|
||||
params (cond-> {:name (:name cdata)
|
||||
:perms (:perms cdata)}
|
||||
(not= "never" expiration) (assoc :expiration expiration))]
|
||||
(st/emit! (du/create-access-token
|
||||
(with-meta params mdata))))))
|
||||
|
||||
copy-token
|
||||
(mf/use-fn
|
||||
(mf/deps created)
|
||||
(fn [event]
|
||||
(dom/prevent-default event)
|
||||
(clipboard/to-clipboard (:token created))
|
||||
(st/emit! (ntf/show {:level :info
|
||||
:type :toast
|
||||
:content (tr "dashboard.access-tokens.copied-success")
|
||||
:timeout 7000}))))]
|
||||
|
||||
[:div {:class (stl/css :modal-overlay)}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:& fm/form {:form form :on-submit on-submit}
|
||||
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h2 {:class (stl/css :modal-title)} (tr "modals.create-access-token.title")]
|
||||
|
||||
[:button {:class (stl/css :modal-close-btn)
|
||||
:on-click on-close}
|
||||
close-icon]]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :fields-row)}
|
||||
[:& fm/input {:type "text"
|
||||
:auto-focus? true
|
||||
:form form
|
||||
:name :name
|
||||
:disabled @created?
|
||||
:label (tr "modals.create-access-token.name.label")
|
||||
:show-success? true
|
||||
:placeholder (tr "modals.create-access-token.name.placeholder")}]]
|
||||
|
||||
[:div {:class (stl/css :fields-row)}
|
||||
[:div {:class (stl/css :select-title)}
|
||||
(tr "modals.create-access-token.expiration-date.label")]
|
||||
[:& fm/select {:options [{:label (tr "dashboard.access-tokens.expiration-never") :value "never" :key "never"}
|
||||
{:label (tr "dashboard.access-tokens.expiration-30-days") :value "720h" :key "720h"}
|
||||
{:label (tr "dashboard.access-tokens.expiration-60-days") :value "1440h" :key "1440h"}
|
||||
{:label (tr "dashboard.access-tokens.expiration-90-days") :value "2160h" :key "2160h"}
|
||||
{:label (tr "dashboard.access-tokens.expiration-180-days") :value "4320h" :key "4320h"}]
|
||||
:default "never"
|
||||
:disabled @created?
|
||||
:name :expiration-date}]
|
||||
(when @created?
|
||||
[:span {:class (stl/css :token-created-info)}
|
||||
(if (:expires-at created)
|
||||
(tr "dashboard.access-tokens.token-will-expire" (ct/format-inst (:expires-at created) "PPP"))
|
||||
(tr "dashboard.access-tokens.token-will-not-expire"))])]
|
||||
|
||||
[:div {:class (stl/css :fields-row)}
|
||||
(when @created?
|
||||
[:div {:class (stl/css :custon-input-wrapper)}
|
||||
[:input {:type "text"
|
||||
:value (:token created "")
|
||||
:class (stl/css :custom-input-token)
|
||||
:read-only true}]
|
||||
[:button {:title (tr "modals.create-access-token.copy-token")
|
||||
:class (stl/css :copy-btn)
|
||||
:on-click copy-token}
|
||||
clipboard-icon]])
|
||||
#_(when @created?
|
||||
[:button {:class (stl/css :copy-btn)
|
||||
:title (tr "modals.create-access-token.copy-token")
|
||||
:on-click copy-token}
|
||||
[:span {:class (stl/css :token-value)} (:token created "")]
|
||||
[:span {:class (stl/css :icon)}
|
||||
i/clipboard]])]]
|
||||
|
||||
[:div {:class (stl/css :modal-footer)}
|
||||
[:div {:class (stl/css :action-buttons)}
|
||||
|
||||
(if @created?
|
||||
[:input {:class (stl/css :cancel-button)
|
||||
:type "button"
|
||||
:value (tr "labels.close")
|
||||
:on-click modal/hide!}]
|
||||
[:*
|
||||
[:input {:class (stl/css :cancel-button)
|
||||
:type "button"
|
||||
:value (tr "labels.cancel")
|
||||
:on-click modal/hide!}]
|
||||
[:> fm/submit-button*
|
||||
{:large? false :label (tr "modals.create-access-token.submit-label")}]])]]]]]))
|
||||
|
||||
(mf/defc access-tokens-hero
|
||||
[]
|
||||
(let [on-click (mf/use-fn #(st/emit! (modal/show :access-token {})))]
|
||||
[:div {:class (stl/css :access-tokens-hero)}
|
||||
[:h2 {:class (stl/css :hero-title)} (tr "dashboard.access-tokens.personal")]
|
||||
[:p {:class (stl/css :hero-desc)} (tr "dashboard.access-tokens.personal.description")]
|
||||
|
||||
[:button {:class (stl/css :hero-btn)
|
||||
:on-click on-click}
|
||||
(tr "dashboard.access-tokens.create")]]))
|
||||
|
||||
(mf/defc access-token-actions
|
||||
[{:keys [on-delete]}]
|
||||
(let [local (mf/use-state {:menu-open false})
|
||||
show? (:menu-open @local)
|
||||
options (mf/with-memo [on-delete]
|
||||
[{:name (tr "labels.delete")
|
||||
:id "access-token-delete"
|
||||
:handler on-delete}])
|
||||
|
||||
menu-ref (mf/use-ref)
|
||||
|
||||
on-menu-close
|
||||
(mf/use-fn #(swap! local assoc :menu-open false))
|
||||
|
||||
on-menu-click
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(dom/prevent-default event)
|
||||
(swap! local assoc :menu-open true)))
|
||||
|
||||
on-keydown
|
||||
(mf/use-fn
|
||||
(mf/deps on-menu-click)
|
||||
(fn [event]
|
||||
(when (kbd/enter? event)
|
||||
(dom/stop-propagation event)
|
||||
(on-menu-click event))))]
|
||||
|
||||
[:button {:class (stl/css :menu-btn)
|
||||
:tab-index "0"
|
||||
:ref menu-ref
|
||||
:on-click on-menu-click
|
||||
:on-key-down on-keydown}
|
||||
menu-icon
|
||||
[:> context-menu*
|
||||
{:on-close on-menu-close
|
||||
:show show?
|
||||
:fixed true
|
||||
:min-width true
|
||||
:top "auto"
|
||||
:left "auto"
|
||||
:options options}]]))
|
||||
|
||||
(mf/defc access-token-item
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [token] :as props}]
|
||||
(let [expires-at (:expires-at token)
|
||||
expires-txt (some-> expires-at (ct/format-inst "PPP"))
|
||||
expired? (and (some? expires-at) (> (ct/now) expires-at))
|
||||
|
||||
delete-fn
|
||||
(mf/use-fn
|
||||
(mf/deps token)
|
||||
(fn []
|
||||
(let [params {:id (:id token)}
|
||||
mdata {:on-success #(st/emit! (du/fetch-access-tokens))}]
|
||||
(st/emit! (du/delete-access-token (with-meta params mdata))))))
|
||||
|
||||
on-delete
|
||||
(mf/use-fn
|
||||
(mf/deps delete-fn)
|
||||
(fn []
|
||||
(st/emit! (modal/show
|
||||
{:type :confirm
|
||||
:title (tr "modals.delete-acces-token.title")
|
||||
:message (tr "modals.delete-acces-token.message")
|
||||
:accept-label (tr "modals.delete-acces-token.accept")
|
||||
:on-accept delete-fn}))))]
|
||||
|
||||
[:div {:class (stl/css :table-row)}
|
||||
[:div {:class (stl/css :table-field :field-name)}
|
||||
(str (:name token))]
|
||||
|
||||
[:div {:class (stl/css-case :expiration-date true
|
||||
:expired expired?)}
|
||||
(cond
|
||||
(nil? expires-at) (tr "dashboard.access-tokens.no-expiration")
|
||||
expired? (tr "dashboard.access-tokens.expired-on" expires-txt)
|
||||
:else (tr "dashboard.access-tokens.expires-on" expires-txt))]
|
||||
[:div {:class (stl/css :table-field :actions)}
|
||||
[:& access-token-actions
|
||||
{:on-delete on-delete}]]]))
|
||||
|
||||
(mf/defc access-tokens-page
|
||||
[]
|
||||
(let [tokens (mf/deref tokens-ref)]
|
||||
(mf/with-effect []
|
||||
(dom/set-html-title (tr "title.settings.access-tokens"))
|
||||
(st/emit! (du/fetch-access-tokens)))
|
||||
|
||||
[:div {:class (stl/css :dashboard-access-tokens)}
|
||||
[:& access-tokens-hero]
|
||||
(if (empty? tokens)
|
||||
[:div {:class (stl/css :access-tokens-empty)}
|
||||
[:div (tr "dashboard.access-tokens.empty.no-access-tokens")]
|
||||
[:div (tr "dashboard.access-tokens.empty.add-one")]]
|
||||
[:div {:class (stl/css :dashboard-table)}
|
||||
[:div {:class (stl/css :table-rows)}
|
||||
(for [token tokens]
|
||||
[:& access-token-item {:token token :key (:id token)}])]])]))
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
// ACCESS TOKENS PAGE
|
||||
.dashboard-access-tokens {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
margin: deprecated.$s-80 auto deprecated.$s-120 auto;
|
||||
gap: deprecated.$s-32;
|
||||
width: deprecated.$s-800;
|
||||
}
|
||||
|
||||
// hero
|
||||
.access-tokens-hero {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
gap: deprecated.$s-32;
|
||||
width: deprecated.$s-500;
|
||||
font-size: deprecated.$fs-14;
|
||||
margin: deprecated.$s-16 auto 0 auto;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
@include deprecated.bigTitleTipography;
|
||||
color: var(--title-foreground-color-hover);
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
color: var(--title-foreground-color);
|
||||
margin-bottom: 0;
|
||||
font-size: deprecated.$fs-14;
|
||||
}
|
||||
|
||||
.hero-btn {
|
||||
@extend .button-primary;
|
||||
}
|
||||
|
||||
// table empty
|
||||
.access-tokens-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
height: deprecated.$s-156;
|
||||
max-width: deprecated.$s-1000;
|
||||
width: 100%;
|
||||
padding: deprecated.$s-32;
|
||||
border: deprecated.$s-1 solid var(--panel-border-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
color: var(--dashboard-list-text-foreground-color);
|
||||
}
|
||||
|
||||
// Access tokens table
|
||||
.dashboard-table {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.table-rows {
|
||||
display: grid;
|
||||
grid-auto-rows: deprecated.$s-64;
|
||||
gap: deprecated.$s-16;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: deprecated.$s-1000;
|
||||
margin-top: deprecated.$s-16;
|
||||
color: var(--title-foreground-color);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 43% 1fr auto;
|
||||
align-items: center;
|
||||
height: deprecated.$s-64;
|
||||
width: 100%;
|
||||
padding: 0 deprecated.$s-16;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--dashboard-list-background-color);
|
||||
color: var(--dashboard-list-foreground-color);
|
||||
}
|
||||
|
||||
.field-name {
|
||||
@include deprecated.textEllipsis;
|
||||
display: grid;
|
||||
width: 43%;
|
||||
min-width: deprecated.$s-300;
|
||||
}
|
||||
|
||||
.expiration-date {
|
||||
@include deprecated.flexCenter;
|
||||
min-width: deprecated.$s-76;
|
||||
width: fit-content;
|
||||
height: deprecated.$s-24;
|
||||
border-radius: deprecated.$br-8;
|
||||
color: var(--dashboard-list-text-foreground-color);
|
||||
}
|
||||
|
||||
.expired {
|
||||
@include deprecated.headlineSmallTypography;
|
||||
padding: 0 deprecated.$s-6;
|
||||
color: var(--pill-foreground-color);
|
||||
background-color: var(--status-widget-background-color-warning);
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: relative;
|
||||
}
|
||||
.menu-icon {
|
||||
@extend .button-icon;
|
||||
stroke: var(--icon-foreground);
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
@include deprecated.buttonStyle;
|
||||
}
|
||||
|
||||
// Create access token modal
|
||||
.modal-overlay {
|
||||
@extend .modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
@extend .modal-container-base;
|
||||
min-width: deprecated.$s-408;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.uppercaseTitleTipography;
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
.modal-close-btn {
|
||||
@extend .modal-close-btn-base;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@include deprecated.flexColumn;
|
||||
gap: deprecated.$s-24;
|
||||
@include deprecated.bodySmallTypography;
|
||||
margin-bottom: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.select-title {
|
||||
@include deprecated.bodySmallTypography;
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.custon-input-wrapper {
|
||||
@include deprecated.flexRow;
|
||||
border-radius: deprecated.$br-8;
|
||||
height: deprecated.$s-32;
|
||||
background-color: var(--input-background-color);
|
||||
}
|
||||
|
||||
.custom-input-token {
|
||||
@extend .input-element;
|
||||
@include deprecated.bodySmallTypography;
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
&:focus {
|
||||
outline: none;
|
||||
border: deprecated.$s-1 solid var(--input-border-color-active);
|
||||
}
|
||||
}
|
||||
|
||||
.token-value {
|
||||
@include deprecated.textEllipsis;
|
||||
@include deprecated.bodySmallTypography;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
@include deprecated.flexCenter;
|
||||
@extend .button-secondary;
|
||||
height: deprecated.$s-28;
|
||||
width: deprecated.$s-28;
|
||||
}
|
||||
|
||||
.clipboard-icon {
|
||||
@extend .button-icon-small;
|
||||
}
|
||||
|
||||
.token-created-info {
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
@extend .modal-action-btns;
|
||||
button {
|
||||
@extend .modal-accept-btn;
|
||||
}
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
@extend .modal-cancel-btn;
|
||||
}
|
||||
619
frontend/src/app/main/ui/settings/integrations.cljs
Normal file
619
frontend/src/app/main/ui/settings/integrations.cljs
Normal file
@@ -0,0 +1,619 @@
|
||||
;; 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.settings.integrations
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.profile :as du]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
[app.main.ui.ds.controls.input :refer [input*]]
|
||||
[app.main.ui.ds.controls.switch :refer [switch*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
|
||||
[app.main.ui.ds.foundations.typography :as t]
|
||||
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
|
||||
[app.main.ui.ds.foundations.typography.text :refer [text*]]
|
||||
[app.main.ui.ds.notifications.shared.notification-pill :refer [notification-pill*]]
|
||||
[app.main.ui.ds.tooltip :refer [tooltip*]]
|
||||
[app.main.ui.forms :as fc]
|
||||
[app.util.clipboard :as clipboard]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.forms :as fm]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[okulary.core :as l]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def tokens-ref
|
||||
(l/derived :access-tokens st/state))
|
||||
|
||||
(def token-created-ref
|
||||
(l/derived :access-token-created st/state))
|
||||
|
||||
(def notification-timeout 7000)
|
||||
|
||||
(def ^:private schema:form-access-token
|
||||
[:map
|
||||
[:name [::sm/text {:max 250}]]
|
||||
[:expiration-date [::sm/text {:max 250}]]])
|
||||
|
||||
(def ^:private schema:form-mcp-key
|
||||
[:map
|
||||
[:expiration-date [::sm/text {:max 250}]]])
|
||||
|
||||
(def form-initial-data-access-token
|
||||
{:name ""
|
||||
:expiration-date "never"})
|
||||
|
||||
(def form-initial-data-mcp-key
|
||||
{:expiration-date "never"})
|
||||
|
||||
(mf/defc input-copy*
|
||||
{::mf/private true}
|
||||
[{:keys [value on-copy-to-clipboard]}]
|
||||
[:div {:class (stl/css :input-copy)}
|
||||
[:> input* {:type "text"
|
||||
:default-value value
|
||||
:read-only true}]
|
||||
[:div {:class (stl/css :input-copy-button-wrapper)}
|
||||
[:> icon-button* {:variant "secondary"
|
||||
:class (stl/css :input-copy-button)
|
||||
:aria-label (tr "integrations.copy-to-clipboard")
|
||||
:on-click on-copy-to-clipboard
|
||||
:icon i/clipboard}]]])
|
||||
|
||||
(mf/defc token-created*
|
||||
{::mf/private true}
|
||||
[{:keys [title mcp-key?]}]
|
||||
(let [token-created (mf/deref token-created-ref)
|
||||
|
||||
on-copy-to-clipboard
|
||||
(mf/use-fn
|
||||
(mf/deps token-created)
|
||||
(fn [event]
|
||||
(dom/prevent-default event)
|
||||
(clipboard/to-clipboard (:token token-created))
|
||||
(st/emit! (ntf/show {:level :info
|
||||
:type :toast
|
||||
:content (tr "integrations.notification.success.copied")
|
||||
:timeout notification-timeout}))))]
|
||||
|
||||
[:div {:class (stl/css :modal-form)}
|
||||
[:> text* {:as "h2"
|
||||
:typography t/headline-large
|
||||
:class (stl/css :color-primary)}
|
||||
title]
|
||||
|
||||
[:> notification-pill* {:level :info
|
||||
:type :context}
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-small
|
||||
:class (stl/css :color-primary)}
|
||||
(tr "integrations.info.non-recuperable")]]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:> input-copy* {:value (:token token-created "")
|
||||
:on-copy-to-clipboard on-copy-to-clipboard}]
|
||||
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-small
|
||||
:class (stl/css :color-secondary)}
|
||||
(if (:expires-at token-created)
|
||||
(tr "integrations.token-will-expire" (ct/format-inst (:expires-at token-created) "PPP"))
|
||||
(tr "integrations.token-will-not-expire"))]]
|
||||
|
||||
(when mcp-key?
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-small
|
||||
:class (stl/css :color-primary)}
|
||||
(tr "integrations.info.mcp-client-config")]
|
||||
[:textarea {:class (stl/css :textarea)
|
||||
:wrap "off"
|
||||
:rows 7
|
||||
:read-only true}
|
||||
(dm/str
|
||||
"{\n"
|
||||
" \"mcpServers\": {\n"
|
||||
" \"penpot\": {\n"
|
||||
" \"url\": \"" cf/mcp-server-url "?userToken=" (:token token-created "") "\"\n"
|
||||
" }\n"
|
||||
" }"
|
||||
"\n}")]])
|
||||
|
||||
[:div {:class (stl/css :modal-footer)}
|
||||
[:> button* {:variant "secondary"
|
||||
:on-click modal/hide!}
|
||||
(tr "labels.close")]]]))
|
||||
|
||||
(mf/defc create-token*
|
||||
{::mf/private true}
|
||||
[{:keys [title info mcp-key? on-created]}]
|
||||
(let [form (fm/use-form
|
||||
:initial (if mcp-key?
|
||||
form-initial-data-mcp-key
|
||||
form-initial-data-access-token)
|
||||
:schema (if mcp-key?
|
||||
schema:form-mcp-key
|
||||
schema:form-access-token))
|
||||
|
||||
on-error
|
||||
(mf/use-fn
|
||||
#(st/emit! (ntf/error (tr "errors.generic"))
|
||||
(modal/hide)))
|
||||
|
||||
on-success
|
||||
(mf/use-fn
|
||||
#(st/emit! (du/fetch-access-tokens)
|
||||
(ntf/success (tr "integrations.notification.success.created"))
|
||||
(on-created)))
|
||||
|
||||
on-submit
|
||||
(mf/use-fn
|
||||
(fn [form]
|
||||
(let [cdata (:clean-data @form)
|
||||
mdata {:on-success (partial on-success form)
|
||||
:on-error (partial on-error form)}
|
||||
expiration (:expiration-date cdata)
|
||||
params (cond-> {:name (:name cdata)
|
||||
:perms (:perms cdata)}
|
||||
(not= "never" expiration) (assoc :expiration expiration)
|
||||
(true? mcp-key?) (assoc :type "mcp"
|
||||
:name "MCP key"))]
|
||||
(st/emit! (du/create-access-token (with-meta params mdata))))))]
|
||||
|
||||
[:> fc/form* {:form form
|
||||
:class (stl/css :modal-form)
|
||||
:on-submit on-submit}
|
||||
|
||||
[:> text* {:as "h2"
|
||||
:typography t/headline-large
|
||||
:class (stl/css :color-primary)}
|
||||
title]
|
||||
|
||||
(when (some? info)
|
||||
[:> notification-pill* {:level :info
|
||||
:type :context}
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-small
|
||||
:class (stl/css :color-primary)}
|
||||
info]])
|
||||
|
||||
(if mcp-key?
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-medium
|
||||
:class (stl/css :color-secondary)}
|
||||
(tr "integrations.info.mcp-server")]]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:> fc/form-input* {:type "text"
|
||||
:auto-focus? true
|
||||
:form form
|
||||
:name :name
|
||||
:label (tr "integrations.name.label")
|
||||
:placeholder (tr "integrations.name.placeholder")}]])
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:> text* {:as "label"
|
||||
:typography t/body-small
|
||||
:for :expiration-date
|
||||
:class (stl/css :color-primary)}
|
||||
(tr "integrations.expiration-date.label")]
|
||||
[:> fc/form-select* {:options [{:label (tr "integrations.expiration-never") :value "never" :id "never"}
|
||||
{:label (tr "integrations.expiration-30-days") :value "720h" :id "720h"}
|
||||
{:label (tr "integrations.expiration-60-days") :value "1440h" :id "1440h"}
|
||||
{:label (tr "integrations.expiration-90-days") :value "2160h" :id "2160h"}
|
||||
{:label (tr "integrations.expiration-180-days") :value "4320h" :id "4320h"}]
|
||||
:default-selected "never"
|
||||
:name :expiration-date}]]
|
||||
|
||||
[:div {:class (stl/css :modal-footer)}
|
||||
[:> button* {:variant "secondary"
|
||||
:on-click modal/hide!}
|
||||
(tr "labels.cancel")]
|
||||
[:> fc/form-submit* {:variant "primary"}
|
||||
title]]]))
|
||||
|
||||
(mf/defc create-access-token-modal
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :create-access-token}
|
||||
[]
|
||||
(let [created? (mf/use-state false)
|
||||
|
||||
on-close
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(reset! created? false)
|
||||
(st/emit! (modal/hide))))
|
||||
|
||||
on-created
|
||||
(mf/use-fn
|
||||
#(reset! created? true))]
|
||||
|
||||
[:div {:class (stl/css :modal-overlay)}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:div {:class (stl/css :modal-close-button)}
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "labels.close")
|
||||
:on-click on-close
|
||||
:icon i/close}]]
|
||||
|
||||
(if @created?
|
||||
[:> token-created* {:title (tr "integrations.create-access-token.title.created")}]
|
||||
[:> create-token* {:title (tr "integrations.create-access-token.title")
|
||||
:on-created on-created}])]]))
|
||||
|
||||
(mf/defc generate-mcp-key-modal
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :generate-mcp-key}
|
||||
[]
|
||||
(let [created? (mf/use-state false)
|
||||
|
||||
on-close
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(reset! created? false)
|
||||
(st/emit! (modal/hide))))
|
||||
|
||||
on-created
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(st/emit! (du/update-profile-props {:mcp-status true})
|
||||
(ev/event {::ev/name "generate-mcp-key"
|
||||
::ev/origin "integrations"})
|
||||
(ev/event {::ev/name "enable-mcp"
|
||||
::ev/origin "integrations"
|
||||
:source "key-creation"}))
|
||||
(reset! created? true)))]
|
||||
|
||||
[:div {:class (stl/css :modal-overlay)}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:div {:class (stl/css :modal-close-button)}
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "labels.close")
|
||||
:on-click on-close
|
||||
:icon i/close}]]
|
||||
|
||||
(if @created?
|
||||
[:> token-created* {:title (tr "integrations.generate-mcp-key.title.created")
|
||||
:mcp-key? true}]
|
||||
[:> create-token* {:title (tr "integrations.generate-mcp-key.title")
|
||||
:mcp-key? true
|
||||
:on-created on-created}])]]))
|
||||
|
||||
(mf/defc regenerate-mcp-key-modal
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :regenerate-mcp-key}
|
||||
[]
|
||||
(let [created? (mf/use-state false)
|
||||
|
||||
tokens (mf/deref tokens-ref)
|
||||
mcp-key (some #(when (= (:type %) "mcp") %) tokens)
|
||||
mcp-key-id (:id mcp-key)
|
||||
|
||||
on-close
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(reset! created? false)
|
||||
(st/emit! (modal/hide))))
|
||||
|
||||
on-created
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(st/emit! (du/delete-access-token {:id mcp-key-id})
|
||||
(du/update-profile-props {:mcp-status true})
|
||||
(ev/event {::ev/name "regenerate-mcp-key"
|
||||
::ev/origin "integrations"}))
|
||||
(reset! created? true)))]
|
||||
|
||||
[:div {:class (stl/css :modal-overlay)}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:div {:class (stl/css :modal-close-button)}
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "labels.close")
|
||||
:on-click on-close
|
||||
:icon i/close}]]
|
||||
|
||||
(if @created?
|
||||
[:> token-created* {:title (tr "integrations.regenerate-mcp-key.title.created")
|
||||
:mcp-key? true}]
|
||||
[:> create-token* {:title (tr "integrations.regenerate-mcp-key.title")
|
||||
:info (tr "integrations.regenerate-mcp-key.info")
|
||||
:mcp-key? true
|
||||
:on-created on-created}])]]))
|
||||
|
||||
(mf/defc token-item*
|
||||
{::mf/private true
|
||||
::mf/wrap [mf/memo]}
|
||||
[{:keys [name expires-at on-delete]}]
|
||||
(let [expires-txt (some-> expires-at (ct/format-inst "PPP"))
|
||||
expired? (and (some? expires-at) (> (ct/now) expires-at))
|
||||
|
||||
menu-open* (mf/use-state false)
|
||||
menu-open? (deref menu-open*)
|
||||
|
||||
handle-menu-close
|
||||
(mf/use-fn
|
||||
#(reset! menu-open* false))
|
||||
|
||||
handle-menu-click
|
||||
(mf/use-fn
|
||||
#(reset! menu-open* (not menu-open?)))
|
||||
|
||||
handle-open-confirm-modal
|
||||
(mf/use-fn
|
||||
(mf/deps on-delete)
|
||||
(fn []
|
||||
(st/emit! (modal/show {:type :confirm
|
||||
:title (tr "integrations.delete-token.title")
|
||||
:message (tr "integrations.delete-token.message")
|
||||
:accept-label (tr "integrations.delete-token.accept")
|
||||
:on-accept on-delete}))))
|
||||
|
||||
options
|
||||
(mf/with-memo [on-delete]
|
||||
[{:name (tr "labels.delete")
|
||||
:id "token-delete"
|
||||
:handler handle-open-confirm-modal}])]
|
||||
|
||||
[:div {:class (stl/css :item)}
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-medium
|
||||
:title name
|
||||
:class (stl/css :item-title)}
|
||||
name]
|
||||
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-small
|
||||
:class (stl/css-case :item-subtitle true
|
||||
:warning expired?)}
|
||||
(cond
|
||||
(nil? expires-at) (tr "integrations.no-expiration")
|
||||
expired? (tr "integrations.expired-on" expires-txt)
|
||||
:else (tr "integrations.expires-on" expires-txt))]
|
||||
|
||||
[:div {:class (stl/css :item-actions)}
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:class (stl/css :item-button)
|
||||
:aria-pressed menu-open?
|
||||
:aria-label (tr "labels.options")
|
||||
:on-click handle-menu-click
|
||||
:icon i/menu}]
|
||||
[:> context-menu* {:on-close handle-menu-close
|
||||
:show menu-open?
|
||||
:min-width true
|
||||
:top -10
|
||||
:left -138
|
||||
:options options}]]]))
|
||||
|
||||
(mf/defc mcp-server-section*
|
||||
{::mf/private true}
|
||||
[]
|
||||
(let [tokens (mf/deref tokens-ref)
|
||||
profile (mf/deref refs/profile)
|
||||
|
||||
mcp-key (some #(when (= (:type %) "mcp") %) tokens)
|
||||
mcp-active? (d/nilv (-> profile :props :mcp-status) false)
|
||||
|
||||
expires-at (:expires-at mcp-key)
|
||||
expired? (and (some? expires-at) (> (ct/now) expires-at))
|
||||
|
||||
tooltip-id
|
||||
(mf/use-id)
|
||||
|
||||
handle-mcp-status-change
|
||||
(mf/use-fn
|
||||
(fn [mcp-status]
|
||||
(st/emit! (du/update-profile-props {:mcp-status mcp-status})
|
||||
(ntf/show {:level :info
|
||||
:type :toast
|
||||
:content (if (true? mcp-status)
|
||||
(tr "integrations.notification.success.mcp-server-enabled")
|
||||
(tr "integrations.notification.success.mcp-server-disabled"))
|
||||
:timeout notification-timeout})
|
||||
(ev/event {::ev/name (if (true? mcp-status) "enable-mcp" "disable-mcp")
|
||||
::ev/origin "integrations"
|
||||
:source "toggle"}))))
|
||||
|
||||
handle-initial-mcp-status
|
||||
(mf/use-fn
|
||||
#(st/emit! (modal/show {:type :generate-mcp-key})))
|
||||
|
||||
handle-regenerate-mcp-key
|
||||
(mf/use-fn
|
||||
#(st/emit! (modal/show {:type :regenerate-mcp-key})))
|
||||
|
||||
handle-delete
|
||||
(mf/use-fn
|
||||
(mf/deps mcp-key)
|
||||
(fn []
|
||||
(let [params {:id (:id mcp-key)}
|
||||
mdata {:on-success #(st/emit! (du/fetch-access-tokens))}]
|
||||
(st/emit! (du/delete-access-token (with-meta params mdata))
|
||||
(du/update-profile-props {:mcp-status false})))))
|
||||
|
||||
on-copy-to-clipboard
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(dom/prevent-default event)
|
||||
(clipboard/to-clipboard cf/mcp-server-url)
|
||||
(st/emit! (ntf/show {:level :info
|
||||
:type :toast
|
||||
:content (tr "integrations.notification.success.copied-link")
|
||||
:timeout notification-timeout})
|
||||
(ev/event {::ev/name "copy-mcp-url"
|
||||
::ev/origin "integrations"}))))]
|
||||
|
||||
[:section {:class (stl/css :mcp-server-section)}
|
||||
[:div
|
||||
[:div {:class (stl/css :title)}
|
||||
[:> heading* {:level 2
|
||||
:typography t/title-medium
|
||||
:class (stl/css :color-primary :mcp-server-title)}
|
||||
(tr "integrations.mcp-server.title")]
|
||||
[:> text* {:as "span"
|
||||
:typography t/body-small
|
||||
:class (stl/css :beta)}
|
||||
(tr "integrations.mcp-server.title.beta")]]
|
||||
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-medium
|
||||
:class (stl/css :color-secondary)}
|
||||
(tr "integrations.mcp-server.description")]]
|
||||
|
||||
[:div
|
||||
[:> text* {:as "h3"
|
||||
:typography t/headline-small
|
||||
:class (stl/css :color-primary)}
|
||||
(tr "integrations.mcp-server.status")]
|
||||
|
||||
[:div {:class (stl/css :mcp-server-block)}
|
||||
(when expired?
|
||||
[:> notification-pill* {:level :error
|
||||
:type :context}
|
||||
[:div {:class (stl/css :mcp-server-notification)}
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-medium
|
||||
:class (stl/css :color-primary)}
|
||||
(tr "integrations.mcp-server.status.expired.0")]
|
||||
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-medium
|
||||
:class (stl/css :color-primary)}
|
||||
(tr "integrations.mcp-server.status.expired.1")]]])
|
||||
|
||||
[:div {:class (stl/css :mcp-server-switch)}
|
||||
[:> switch* {:label (if mcp-active?
|
||||
(tr "integrations.mcp-server.status.enabled")
|
||||
(tr "integrations.mcp-server.status.disabled"))
|
||||
:default-checked mcp-active?
|
||||
:on-change handle-mcp-status-change}]
|
||||
(when (and (false? mcp-active?) (nil? mcp-key))
|
||||
[:div {:class (stl/css :mcp-server-switch-cover)
|
||||
:on-click handle-initial-mcp-status}])]]]
|
||||
|
||||
(when (some? mcp-key)
|
||||
[:div {:class (stl/css :mcp-server-key)}
|
||||
[:> text* {:as "h3"
|
||||
:typography t/headline-small
|
||||
:class (stl/css :color-primary)}
|
||||
(tr "integrations.mcp-server.mcp-keys.title")]
|
||||
|
||||
[:div {:class (stl/css :mcp-server-block)}
|
||||
[:div {:class (stl/css :mcp-server-regenerate)}
|
||||
[:> button* {:variant "primary"
|
||||
:class (stl/css :fit-content)
|
||||
:on-click handle-regenerate-mcp-key}
|
||||
(tr "integrations.mcp-server.mcp-keys.regenerate")]
|
||||
[:> tooltip* {:content (tr "integrations.mcp-server.mcp-keys.tootip")
|
||||
:id tooltip-id}
|
||||
[:> icon* {:icon-id i/info
|
||||
:class (stl/css :color-secondary)}]]]
|
||||
|
||||
[:div {:class (stl/css :list)}
|
||||
[:> token-item* {:key (:id mcp-key)
|
||||
:name (:name mcp-key)
|
||||
:expires-at (:expires-at mcp-key)
|
||||
:on-delete handle-delete}]]]])
|
||||
|
||||
[:> notification-pill* {:level :default
|
||||
:type :context}
|
||||
[:div {:class (stl/css :mcp-server-notification)}
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-medium
|
||||
:class (stl/css :color-secondary)}
|
||||
(tr "integrations.mcp-server.mcp-keys.info")]
|
||||
|
||||
[:> input-copy* {:value (dm/str cf/mcp-server-url "?userToken=")
|
||||
:on-copy-to-clipboard on-copy-to-clipboard}]
|
||||
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-medium
|
||||
:class (stl/css :color-secondary)}
|
||||
[:a {:href cf/mcp-help-center-uri
|
||||
:class (stl/css :mcp-server-notification-link)}
|
||||
(tr "integrations.mcp-server.mcp-keys.help") [:> icon* {:icon-id i/open-link}]]]]]]))
|
||||
|
||||
(mf/defc access-tokens-section*
|
||||
{::mf/private true}
|
||||
[]
|
||||
(let [tokens (mf/deref tokens-ref)
|
||||
|
||||
handle-click
|
||||
(mf/use-fn
|
||||
#(st/emit! (modal/show {:type :create-access-token})))
|
||||
|
||||
handle-delete
|
||||
(mf/use-fn
|
||||
(fn [token-id]
|
||||
(let [params {:id token-id}
|
||||
mdata {:on-success #(st/emit! (du/fetch-access-tokens))}]
|
||||
(st/emit! (du/delete-access-token (with-meta params mdata))))))]
|
||||
|
||||
[:section {:class (stl/css :access-tokens-section)}
|
||||
[:> heading* {:level 2
|
||||
:typography t/title-medium
|
||||
:class (stl/css :color-primary)}
|
||||
(tr "integrations.access-tokens.personal")]
|
||||
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-medium
|
||||
:class (stl/css :color-secondary)}
|
||||
(tr "integrations.access-tokens.personal.description")]
|
||||
|
||||
[:> button* {:variant "primary"
|
||||
:class (stl/css :fit-content)
|
||||
:on-click handle-click}
|
||||
(tr "integrations.access-tokens.create")]
|
||||
|
||||
(if (empty? tokens)
|
||||
[:div {:class (stl/css :frame)}
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-medium
|
||||
:class (stl/css :color-secondary :text-center)}
|
||||
[:div (tr "integrations.access-tokens.empty.no-access-tokens")]
|
||||
[:div (tr "integrations.access-tokens.empty.add-one")]]]
|
||||
|
||||
[:div {:class (stl/css :list)}
|
||||
(for [token tokens]
|
||||
(when (nil? (:type token))
|
||||
[:> token-item* {:key (:id token)
|
||||
:name (:name token)
|
||||
:expires-at (:expires-at token)
|
||||
:on-delete (partial handle-delete (:id token))}]))])]))
|
||||
|
||||
(mf/defc integrations-page*
|
||||
[]
|
||||
(mf/with-effect []
|
||||
(dom/set-html-title (tr "title.settings.integrations"))
|
||||
(st/emit! (du/fetch-access-tokens)))
|
||||
|
||||
[:div {:class (stl/css :integrations)}
|
||||
[:> heading* {:level 1
|
||||
:typography t/title-large
|
||||
:class (stl/css :color-primary)}
|
||||
(tr "integrations.title")]
|
||||
|
||||
(when (contains? cf/flags :mcp)
|
||||
[:> mcp-server-section*])
|
||||
|
||||
(when (and (contains? cf/flags :mcp)
|
||||
(contains? cf/flags :access-tokens))
|
||||
[:hr {:class (stl/css :separator)}])
|
||||
|
||||
(when (contains? cf/flags :access-tokens)
|
||||
[:> access-tokens-section*])])
|
||||
239
frontend/src/app/main/ui/settings/integrations.scss
Normal file
239
frontend/src/app/main/ui/settings/integrations.scss
Normal file
@@ -0,0 +1,239 @@
|
||||
// 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
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/mixins.scss" as *;
|
||||
@use "ds/spacing.scss" as *;
|
||||
@use "ds/typography.scss" as t;
|
||||
|
||||
.color-primary {
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
|
||||
.color-secondary {
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fit-content {
|
||||
inline-size: fit-content;
|
||||
}
|
||||
|
||||
.beta {
|
||||
color: var(--color-accent-primary);
|
||||
border: $b-1 solid var(--color-accent-primary);
|
||||
inline-size: fit-content;
|
||||
padding: var(--sp-xxs) var(--sp-s);
|
||||
border-radius: $br-4;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
@extend .modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
@extend .modal-container-base;
|
||||
inline-size: $sz-400;
|
||||
max-block-size: fit-content;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xxxl);
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
position: absolute;
|
||||
top: var(--sp-s);
|
||||
right: var(--sp-s);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
.input-copy {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-copy-button-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
|
||||
.input-copy-button {
|
||||
border-radius: 0 $br-8 $br-8 0;
|
||||
}
|
||||
|
||||
.integrations {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
margin: $sz-88 auto $sz-120 auto;
|
||||
gap: $sz-32;
|
||||
inline-size: $sz-500;
|
||||
}
|
||||
|
||||
.access-tokens-section {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
gap: var(--sp-m);
|
||||
}
|
||||
|
||||
.mcp-server-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-l);
|
||||
}
|
||||
|
||||
.mcp-server-key {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mcp-server-notification {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-m);
|
||||
padding-right: var(--sp-xxl);
|
||||
}
|
||||
|
||||
.mcp-server-notification-link {
|
||||
cursor: pointer;
|
||||
color: var(--color-accent-primary);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
|
||||
.mcp-server-title {
|
||||
margin: var(--sp-s) 0;
|
||||
}
|
||||
|
||||
.mcp-server-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-l);
|
||||
}
|
||||
|
||||
.mcp-server-regenerate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
.mcp-server-switch {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mcp-server-switch-cover {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline: 0;
|
||||
}
|
||||
|
||||
.separator {
|
||||
border: $b-1 solid var(--color-background-quaternary);
|
||||
margin: var(--sp-s) 0;
|
||||
}
|
||||
|
||||
.frame {
|
||||
border: $b-1 solid var(--color-background-quaternary);
|
||||
padding: var(--sp-m);
|
||||
border-radius: $br-8;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
grid-auto-rows: $sz-64;
|
||||
gap: var(--sp-m);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: 45% 1fr auto;
|
||||
align-items: center;
|
||||
background-color: var(--color-background-tertiary);
|
||||
border-radius: $br-8;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
@include textEllipsis;
|
||||
align-content: center;
|
||||
block-size: $sz-64;
|
||||
padding: 0 var(--sp-l);
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
|
||||
.item-subtitle {
|
||||
align-content: center;
|
||||
block-size: $sz-64;
|
||||
color: var(--color-foreground-secondary);
|
||||
|
||||
&.warning {
|
||||
padding: var(--sp-s) var(--sp-m);
|
||||
block-size: fit-content;
|
||||
inline-size: fit-content;
|
||||
color: var(--color-foreground-primary);
|
||||
background-color: var(--color-background-warning);
|
||||
border: $b-1 solid var(--color-accent-warning);
|
||||
border-radius: $br-8;
|
||||
}
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-button {
|
||||
block-size: $sz-64;
|
||||
inline-size: $sz-48;
|
||||
border-radius: 0 var(--sp-s) var(--sp-s) 0;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
@include t.use-typography("body-small");
|
||||
border-radius: $br-8;
|
||||
background-color: var(--color-background-tertiary);
|
||||
color: var(--color-foreground-secondary);
|
||||
padding: var(--sp-xs) var(--sp-s);
|
||||
border: 0;
|
||||
resize: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: $b-1 solid var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,8 @@
|
||||
(def ^:private go-settings-subscription
|
||||
#(st/emit! (rt/nav :settings-subscription)))
|
||||
|
||||
(def ^:private go-settings-access-tokens
|
||||
#(st/emit! (rt/nav :settings-access-tokens)))
|
||||
(def ^:private go-settings-integrations
|
||||
#(st/emit! (rt/nav :settings-integrations)))
|
||||
|
||||
(def ^:private go-settings-notifications
|
||||
#(st/emit! (rt/nav :settings-notifications)))
|
||||
@@ -66,7 +66,7 @@
|
||||
options? (= section :settings-options)
|
||||
feedback? (= section :settings-feedback)
|
||||
subscription? (= section :settings-subscription)
|
||||
access-tokens? (= section :settings-access-tokens)
|
||||
integrations? (= section :settings-integrations)
|
||||
notifications? (= section :settings-notifications)
|
||||
team-id (or (dtm/get-last-team-id)
|
||||
(:default-team-id profile))
|
||||
@@ -115,12 +115,13 @@
|
||||
:data-testid "settings-subscription"}
|
||||
[:span {:class (stl/css :element-title)} (tr "subscription.labels")]])
|
||||
|
||||
(when (contains? cf/flags :access-tokens)
|
||||
[:li {:class (stl/css-case :current access-tokens?
|
||||
(when (or (contains? cf/flags :access-tokens)
|
||||
(contains? cf/flags :mcp))
|
||||
[:li {:class (stl/css-case :current integrations?
|
||||
:settings-item true)
|
||||
:on-click go-settings-access-tokens
|
||||
:data-testid "settings-access-tokens"}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.access-tokens")]])
|
||||
:on-click go-settings-integrations
|
||||
:data-testid "settings-integrations"}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.integrations")]])
|
||||
|
||||
[:hr {:class (stl/css :sidebar-separator)}]
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.main.data.auth :as da]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.nitrate :as dnt]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
@@ -415,11 +417,13 @@
|
||||
(fn [subscription-type current-subscription]
|
||||
(st/emit! (ev/event {::ev/name "open-subscription-modal"
|
||||
::ev/origin "settings:in-app"}))
|
||||
(st/emit!
|
||||
(modal/show :management-dialog
|
||||
{:subscription-type subscription-type
|
||||
:current-subscription current-subscription
|
||||
:editors subscription-editors :subscribe-to-trial (not (:type subscription))}))))]
|
||||
(if (= subscription-type "nitrate")
|
||||
(st/emit! (dnt/show-nitrate-popup :nitrate-dialog))
|
||||
(st/emit!
|
||||
(modal/show :management-dialog
|
||||
{:subscription-type subscription-type
|
||||
:current-subscription current-subscription
|
||||
:editors subscription-editors :subscribe-to-trial (not (:type subscription))})))))]
|
||||
|
||||
(mf/with-effect []
|
||||
(dom/set-html-title (tr "subscription.labels")))
|
||||
@@ -562,7 +566,7 @@
|
||||
:recommended (= subscription-type "professional")
|
||||
:show-button-cta (= subscription-type "professional")}])
|
||||
|
||||
(when (not= subscription-type "enterprise")
|
||||
(when (and (not= subscription-type "enterprise") (not (contains? cf/flags :nitrate)))
|
||||
[:> plan-card* {:card-title (tr "subscription.settings.enterprise")
|
||||
:card-title-icon i/character-e
|
||||
:price-value "$950"
|
||||
@@ -575,5 +579,104 @@
|
||||
:cta-link #(open-subscription-modal "enterprise" subscription)
|
||||
:cta-text-with-icon (tr "subscription.settings.more-information")
|
||||
:cta-link-with-icon go-to-pricing-page
|
||||
:show-button-cta (= subscription-type "professional")}])]]]))
|
||||
:show-button-cta (= subscription-type "professional")}])
|
||||
|
||||
;; TODO add translations for this texts when we have the definitive ones
|
||||
(when (and (contains? cf/flags :nitrate) (not (:nitrate-licence profile)))
|
||||
[:> plan-card* {:card-title "Business Nitrate"
|
||||
:card-title-icon i/character-n
|
||||
:price-value "$25"
|
||||
:price-period "org member"
|
||||
:benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits")
|
||||
:benefits ["Crea organizaciones y añade personas, que usarán Penpot con las reglas que configures."
|
||||
"Acceso exclusivo al Control Center"
|
||||
"Lorem ipsum"]
|
||||
:cta-text (tr "subscription.settings.subscribe")
|
||||
:cta-link #(open-subscription-modal "nitrate" subscription)
|
||||
:cta-text-with-icon (tr "subscription.settings.more-information")
|
||||
:cta-link-with-icon go-to-pricing-page}])]]]))
|
||||
|
||||
|
||||
(def ^:private schema:nitrate-form
|
||||
[:map {:title "NitrateForm"}
|
||||
[:subscription [::sm/one-of #{:monthly :yearly}]]])
|
||||
|
||||
(mf/defc subscribe-nitrate-dialog
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :nitrate-dialog}
|
||||
[connectivity]
|
||||
;; TODO add translations for this texts when we have the definitive ones
|
||||
(let [online? (:licenses connectivity)
|
||||
initial (mf/with-memo []
|
||||
{:subscription "yearly"})
|
||||
form (fm/use-form :schema schema:nitrate-form
|
||||
:initial initial)
|
||||
|
||||
handle-close-dialog
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(modal/hide!)))
|
||||
|
||||
on-submit
|
||||
(mf/use-fn
|
||||
(mf/deps form)
|
||||
(fn []
|
||||
(let [params (:clean-data @form)]
|
||||
(dom/open-new-window (str "/control-center/licenses/start?subscription=" (name (:subscription params)))))))]
|
||||
|
||||
[:div {:class (stl/css :modal-overlay)}
|
||||
[:div {:class (stl/css :modal-dialog)}
|
||||
[:button {:class (stl/css :close-btn) :on-click handle-close-dialog}
|
||||
[:> icon* {:icon-id "close"
|
||||
:size "m"}]]
|
||||
[:div {:class (stl/css :modal-title :subscription-title)}
|
||||
"Subcribe to the Business Nitrate plan"]
|
||||
|
||||
(if online?
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
|
||||
|
||||
|
||||
[:div {:class (stl/css :modal-text)}
|
||||
"Lorem ipsum lorem ipsum:"]
|
||||
|
||||
|
||||
[:& fm/form {:on-submit on-submit
|
||||
:class (stl/css :seats-form)
|
||||
:form form}
|
||||
|
||||
[:*
|
||||
[:div {:class (stl/css :editors-wrapper)}
|
||||
[:div {:class (stl/css :fields-row)}
|
||||
[:& fm/radio-buttons
|
||||
{:options [{:label "Price Tag Yearly (Discount)" :value "yearly"}
|
||||
{:label "Price Tag Montly" :value "monthly"}]
|
||||
:name :subscription
|
||||
:class (stl/css :radio-btns)}]]]
|
||||
[:div {:class (stl/css :modal-text)}
|
||||
"You won’t be charged right now. Payment will be processed at the end of the trial. Cancel anytime."]
|
||||
|
||||
|
||||
|
||||
[:div {:class (stl/css :modal-footer)}
|
||||
[:div {:class (stl/css :action-buttons)}
|
||||
[:input
|
||||
{:class (stl/css :cancel-button)
|
||||
:type "button"
|
||||
:value (tr "ds.confirm-cancel")
|
||||
:on-click handle-close-dialog}]
|
||||
|
||||
[:> fm/submit-button*
|
||||
{:label "TRY 14 DAYS FOR FREE"
|
||||
:class (stl/css :primary-button)}]]]]]]
|
||||
[:div {:class (stl/css :modal-content :modal-contact-content)}
|
||||
[:div {:class (stl/css :modal-text)}
|
||||
"Lorem ipsum lorem ipsum Lorem ipsum lorem ipsum Lorem ipsum lorem ipsum"]
|
||||
[:div {:class (stl/css :modal-text)}
|
||||
"Contact us to upgrade to Nitrate:"]
|
||||
[:div {:class (stl/css :modal-text)}
|
||||
[:a {:class (stl/css :link) :href "mailto:sales@penpot.app"}
|
||||
"sales@penpot.app"]]])]]))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -329,3 +329,22 @@
|
||||
.show-editors-button {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.radio-btns {
|
||||
label {
|
||||
@include t.use-typography("body-large");
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0 var(--sp-xl) 0;
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
.modal-contact-content {
|
||||
gap: var(--sp-xl);
|
||||
}
|
||||
|
||||
@@ -150,7 +150,9 @@
|
||||
{::mf/props :obj
|
||||
::mf/private true}
|
||||
[{:keys [shapes]}]
|
||||
(let [do-copy #(st/emit! (dw/copy-selected))
|
||||
(let [multiple? (> (count shapes) 1)
|
||||
|
||||
do-copy #(st/emit! (dw/copy-selected))
|
||||
do-copy-link #(st/emit! (dw/copy-link-to-clipboard))
|
||||
|
||||
do-cut #(st/emit! (dw/copy-selected)
|
||||
@@ -178,6 +180,9 @@
|
||||
handle-copy-text
|
||||
(mf/use-callback #(st/emit! (dw/copy-selected-text)))
|
||||
|
||||
handle-copy-as-image
|
||||
(mf/use-callback #(st/emit! (dw/copy-as-image)))
|
||||
|
||||
handle-hover-copy-paste
|
||||
(mf/use-callback
|
||||
(fn []
|
||||
@@ -222,6 +227,11 @@
|
||||
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-svg")
|
||||
:on-click handle-copy-svg}]
|
||||
|
||||
(when (some cfh/frame-shape? shapes)
|
||||
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-as-image")
|
||||
:disabled multiple?
|
||||
:on-click handle-copy-as-image}])
|
||||
|
||||
[:> menu-separator* {}]
|
||||
|
||||
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-text")
|
||||
@@ -229,7 +239,7 @@
|
||||
|
||||
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-props")
|
||||
:shortcut (sc/get-tooltip :copy-props)
|
||||
:disabled (> (count shapes) 1)
|
||||
:disabled multiple?
|
||||
:on-click handle-copy-props}]
|
||||
[:> menu-entry* {:title (tr "workspace.shape.menu.paste-props")
|
||||
:shortcut (sc/get-tooltip :paste-props)
|
||||
|
||||
@@ -131,8 +131,7 @@
|
||||
|
||||
on-style-change
|
||||
(fn [event]
|
||||
(let [
|
||||
styles (styles/get-styles-from-event event)]
|
||||
(let [styles (styles/get-styles-from-event event)]
|
||||
(st/emit! (dwt/v2-update-text-editor-styles shape-id styles))))
|
||||
|
||||
on-needs-layout
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.main.ui.workspace.shapes.text.v3-editor
|
||||
"Contenteditable DOM element for WASM text editor input"
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.math :as mth]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.css-cursors :as cur]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.text-editor :as text-editor]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.object :as obj]
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]))
|
||||
|
||||
(def caret-blink-interval-ms 250)
|
||||
|
||||
(defn- sync-wasm-text-editor-content!
|
||||
"Sync WASM text editor content back to the shape via the standard
|
||||
commit pipeline. Called after every text-modifying input."
|
||||
[& {:keys [finalize?]}]
|
||||
(when-let [{:keys [shape-id content]}
|
||||
(text-editor/text-editor-sync-content)]
|
||||
(st/emit! (dwt/v2-update-text-shape-content
|
||||
shape-id content
|
||||
:update-name? true
|
||||
:finalize? finalize?))))
|
||||
|
||||
(defn- font-family-from-font-id [font-id]
|
||||
(if (str/includes? font-id "gfont-noto-sans")
|
||||
(let [lang (str/replace font-id #"gfont\-noto\-sans\-" "")]
|
||||
(if (>= (count lang) 3) (str/capital lang) (str/upper lang)))
|
||||
"Noto Color Emoji"))
|
||||
|
||||
(mf/defc text-editor
|
||||
"Contenteditable element positioned over the text shape to capture input events."
|
||||
{::mf/wrap-props false}
|
||||
[props]
|
||||
(let [shape (obj/get props "shape")
|
||||
shape-id (dm/get-prop shape :id)
|
||||
|
||||
clip-id (dm/str "text-edition-clip" shape-id)
|
||||
|
||||
contenteditable-ref (mf/use-ref nil)
|
||||
composing? (mf/use-state false)
|
||||
|
||||
fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false)
|
||||
fallback-families (map (fn [font]
|
||||
(font-family-from-font-id (:font-id font))) fallback-fonts)
|
||||
|
||||
;; Calculate screen position from shape bounds
|
||||
bounds (gsh/shape->rect shape)
|
||||
|
||||
x (mth/min (dm/get-prop bounds :x)
|
||||
(dm/get-prop shape :x))
|
||||
y (mth/min (dm/get-prop bounds :y)
|
||||
(dm/get-prop shape :y))
|
||||
width (mth/max (dm/get-prop bounds :width)
|
||||
(dm/get-prop shape :width))
|
||||
height (mth/max (dm/get-prop bounds :height)
|
||||
(dm/get-prop shape :height))
|
||||
|
||||
[{:keys [x y width height]} transform]
|
||||
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
|
||||
selrect-transform (mf/deref refs/workspace-selrect)
|
||||
[selrect transform] (dsh/get-selrect selrect-transform shape)
|
||||
selrect-height (:height selrect)
|
||||
selrect-width (:width selrect)
|
||||
max-width (max width selrect-width)
|
||||
max-height (max height selrect-height)
|
||||
valign (-> shape :content :vertical-align)
|
||||
y (:y selrect)
|
||||
y (case valign
|
||||
"bottom" (+ y (- selrect-height height))
|
||||
"center" (+ y (/ (- selrect-height height) 2))
|
||||
y)]
|
||||
[(assoc selrect :y y :width max-width :height max-height) transform])
|
||||
|
||||
on-composition-start
|
||||
(mf/use-fn
|
||||
(fn [_event]
|
||||
(reset! composing? true)))
|
||||
|
||||
on-composition-end
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(reset! composing? false)
|
||||
(let [data (.-data event)]
|
||||
(when data
|
||||
(text-editor/text-editor-insert-text data)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-composition"))
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(set! (.-textContent node) "")))))
|
||||
|
||||
on-paste
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(dom/prevent-default event)
|
||||
(let [clipboard-data (.-clipboardData event)
|
||||
text (.getData clipboard-data "text/plain")]
|
||||
(when (and text (seq text))
|
||||
(text-editor/text-editor-insert-text text)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-paste"))
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(set! (.-textContent node) "")))))
|
||||
|
||||
on-copy
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(when (text-editor/text-editor-is-active?)
|
||||
(dom/prevent-default event)
|
||||
(when (text-editor/text-editor-get-selection)
|
||||
(let [text (text-editor/text-editor-export-selection)]
|
||||
(.setData (.-clipboardData event) "text/plain" text))))))
|
||||
|
||||
on-key-down
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(js/console.log (.-type event) (.-key event))
|
||||
(when (and (text-editor/text-editor-is-active?)
|
||||
(not @composing?))
|
||||
|
||||
(let [key (.-key event)
|
||||
ctrl? (or (.-ctrlKey event) (.-metaKey event))
|
||||
shift? (.-shiftKey event)]
|
||||
|
||||
(cond
|
||||
;; Escape: finalize and stop
|
||||
(= key "Escape")
|
||||
(do
|
||||
(dom/prevent-default event)
|
||||
(sync-wasm-text-editor-content! :finalize? true)
|
||||
(text-editor/text-editor-stop))
|
||||
|
||||
;; Ctrl+A: select all (key is "a" or "A" depending on platform)
|
||||
(and ctrl? (= (str/lower key) "a"))
|
||||
(do
|
||||
(dom/prevent-default event)
|
||||
(text-editor/text-editor-select-all)
|
||||
(wasm.api/request-render "text-select-all"))
|
||||
|
||||
;; Enter
|
||||
(= key "Enter")
|
||||
(do
|
||||
(dom/prevent-default event)
|
||||
(text-editor/text-editor-insert-paragraph)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-paragraph"))
|
||||
|
||||
;; Backspace
|
||||
(= key "Backspace")
|
||||
(do
|
||||
(dom/prevent-default event)
|
||||
(text-editor/text-editor-delete-backward)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-delete-backward"))
|
||||
|
||||
;; Delete
|
||||
(= key "Delete")
|
||||
(do
|
||||
(dom/prevent-default event)
|
||||
(text-editor/text-editor-delete-forward)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-delete-forward"))
|
||||
|
||||
;; Arrow keys
|
||||
(= key "ArrowLeft")
|
||||
(do
|
||||
(dom/prevent-default event)
|
||||
(text-editor/text-editor-move-cursor 0 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "ArrowRight")
|
||||
(do
|
||||
(dom/prevent-default event)
|
||||
(text-editor/text-editor-move-cursor 1 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "ArrowUp")
|
||||
(do
|
||||
(dom/prevent-default event)
|
||||
(text-editor/text-editor-move-cursor 2 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "ArrowDown")
|
||||
(do
|
||||
(dom/prevent-default event)
|
||||
(text-editor/text-editor-move-cursor 3 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "Home")
|
||||
(do
|
||||
(dom/prevent-default event)
|
||||
(text-editor/text-editor-move-cursor 4 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
(= key "End")
|
||||
(do
|
||||
(dom/prevent-default event)
|
||||
(text-editor/text-editor-move-cursor 5 shift?)
|
||||
(wasm.api/request-render "text-cursor-move"))
|
||||
|
||||
;; Let contenteditable handle text input via on-input
|
||||
:else nil)))))
|
||||
|
||||
on-input
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(js/console.log "event" event)
|
||||
(let [native-event (.-nativeEvent event)
|
||||
input-type (.-inputType native-event)
|
||||
data (.-data native-event)]
|
||||
;; Skip composition-related input events - composition-end handles those
|
||||
(when (and (not @composing?)
|
||||
(not= input-type "insertCompositionText"))
|
||||
(when (and data (seq data))
|
||||
(text-editor/text-editor-insert-text data)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-input"))
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(set! (.-textContent node) ""))))))
|
||||
|
||||
on-pointer-down
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(js/console.log (.-type event))
|
||||
(let [native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
(wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt)))))
|
||||
|
||||
on-pointer-move
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(js/console.log (.-type event))
|
||||
(let [native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
(wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt)))))
|
||||
|
||||
on-pointer-up
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(js/console.log (.-type event))
|
||||
(let [native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
(wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt)))))
|
||||
|
||||
on-click
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(js/console.log (.-type event))
|
||||
(let [native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)]
|
||||
(wasm.api/text-editor-set-cursor-from-offset (.-x off-pt) (.-y off-pt)))))
|
||||
|
||||
on-focus
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(js/console.log (.-type event) event)))
|
||||
|
||||
on-blur
|
||||
(mf/use-fn
|
||||
(fn [^js event]
|
||||
(js/console.log (.-type event) event)
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/text-editor-stop)))
|
||||
|
||||
style #js {:pointerEvents "all"
|
||||
"--editor-container-width" (dm/str width "px")
|
||||
"--editor-container-height" (dm/str height "px")
|
||||
"--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}]
|
||||
|
||||
;; Focus contenteditable on mount
|
||||
(mf/use-effect
|
||||
(mf/deps contenteditable-ref)
|
||||
(fn []
|
||||
(when-let [node (mf/ref-val contenteditable-ref)]
|
||||
(js/console.log "focusing")
|
||||
(.focus node))))
|
||||
|
||||
(mf/use-effect
|
||||
(fn []
|
||||
(let [timeout-id (atom nil)
|
||||
schedule-blink (fn schedule-blink []
|
||||
(when (text-editor/text-editor-is-active?)
|
||||
(wasm.api/request-render "cursor-blink"))
|
||||
(reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))]
|
||||
(schedule-blink)
|
||||
(fn []
|
||||
(when @timeout-id
|
||||
(js/clearTimeout @timeout-id))))))
|
||||
|
||||
;; Composition and input events
|
||||
[:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id)
|
||||
:transform (dm/str transform)
|
||||
:data-testid "text-editor"}
|
||||
[:defs
|
||||
[:clipPath {:id clip-id}
|
||||
[:rect {:x x :y y :width width :height height}]]]
|
||||
|
||||
[:foreignObject {:x x :y y :width width :height height}
|
||||
[:div {:on-click on-click
|
||||
:on-pointer-down on-pointer-down
|
||||
:on-pointer-move on-pointer-move
|
||||
:on-pointer-up on-pointer-up
|
||||
:class (stl/css :text-editor)
|
||||
:style style}
|
||||
[:div
|
||||
{:ref contenteditable-ref
|
||||
:contentEditable true
|
||||
:suppressContentEditableWarning true
|
||||
:on-composition-start on-composition-start
|
||||
:on-composition-end on-composition-end
|
||||
:on-key-down on-key-down
|
||||
:on-input on-input
|
||||
:on-paste on-paste
|
||||
:on-copy on-copy
|
||||
:on-focus on-focus
|
||||
:on-blur on-blur
|
||||
;; FIXME on-click
|
||||
;; :on-click on-click
|
||||
:id "text-editor-wasm-input"
|
||||
:class (dm/str (cur/get-dynamic "text" (:rotation shape))
|
||||
" "
|
||||
(stl/css :text-editor-container))
|
||||
:data-testid "text-editor-container"}]]]]))
|
||||
@@ -1,12 +0,0 @@
|
||||
.text-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.text-editor-container {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.math :as mth]
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.types.components-list :as ctkl]
|
||||
[app.common.types.container :as ctn]
|
||||
@@ -38,8 +37,6 @@
|
||||
(defonce ^:private sidebar-hover-queue (atom {:enter #{} :leave #{}}))
|
||||
(defonce ^:private sidebar-hover-pending? (atom false))
|
||||
|
||||
(def ^:const default-chunk-size 50)
|
||||
|
||||
(defn- schedule-sidebar-hover-flush []
|
||||
(when (compare-and-set! sidebar-hover-pending? false true)
|
||||
(ts/raf
|
||||
@@ -51,11 +48,12 @@
|
||||
(when (seq enter)
|
||||
(apply st/emit! (map dw/highlight-shape enter))))))))
|
||||
|
||||
(mf/defc layer-item-inner*
|
||||
[{:keys [item depth parent-size name-ref children ref style rename-id
|
||||
(mf/defc layer-item-inner
|
||||
{::mf/wrap-props false}
|
||||
[{:keys [item depth parent-size name-ref children ref style
|
||||
;; Flags
|
||||
is-read-only is-highlighted is-selected is-component-tree
|
||||
is-filtered is-expanded dnd-over dnd-over-top dnd-over-bot hide-toggle
|
||||
read-only? highlighted? selected? component-tree?
|
||||
filtered? expanded? dnd-over? dnd-over-top? dnd-over-bot? hide-toggle?
|
||||
;; Callbacks
|
||||
on-select-shape on-context-menu on-pointer-enter on-pointer-leave on-zoom-to-selected
|
||||
on-toggle-collapse on-enable-drag on-disable-drag on-toggle-visibility on-toggle-blocking]}]
|
||||
@@ -66,7 +64,7 @@
|
||||
hidden? (:hidden item)
|
||||
has-shapes? (-> item :shapes seq boolean)
|
||||
touched? (-> item :touched seq boolean)
|
||||
root-board? (and (cfh/frame-shape? item)
|
||||
parent-board? (and (cfh/frame-shape? item)
|
||||
(= uuid/zero (:parent-id item)))
|
||||
absolute? (ctl/item-absolute? item)
|
||||
is-variant? (ctk/is-variant? item)
|
||||
@@ -75,11 +73,9 @@
|
||||
variant-name (when is-variant? (:variant-name item))
|
||||
variant-error (when is-variant? (:variant-error item))
|
||||
|
||||
component-id (get item :component-id)
|
||||
data (mf/deref refs/workspace-data)
|
||||
variant-properties (-> (ctkl/get-component data component-id)
|
||||
(get :variant-properties))
|
||||
|
||||
data (deref refs/workspace-data)
|
||||
component (ctkl/get-component data (:component-id item))
|
||||
variant-properties (:variant-properties component)
|
||||
icon-shape (usi/get-shape-icon item)]
|
||||
|
||||
[:*
|
||||
@@ -89,30 +85,30 @@
|
||||
:on-context-menu on-context-menu
|
||||
:data-testid "layer-row"
|
||||
:role "checkbox"
|
||||
:aria-checked is-selected
|
||||
:aria-checked selected?
|
||||
:class (stl/css-case
|
||||
:layer-row true
|
||||
:highlight is-highlighted
|
||||
:highlight highlighted?
|
||||
:component (ctk/instance-head? item)
|
||||
:masked (:masked-group item)
|
||||
:selected is-selected
|
||||
:selected selected?
|
||||
:type-frame (cfh/frame-shape? item)
|
||||
:type-bool (cfh/bool-shape? item)
|
||||
:type-comp (or is-component-tree is-variant-container?)
|
||||
:type-comp (or component-tree? is-variant-container?)
|
||||
:hidden hidden?
|
||||
:dnd-over dnd-over
|
||||
:dnd-over-top dnd-over-top
|
||||
:dnd-over-bot dnd-over-bot
|
||||
:root-board root-board?)
|
||||
:dnd-over dnd-over?
|
||||
:dnd-over-top dnd-over-top?
|
||||
:dnd-over-bot dnd-over-bot?
|
||||
:root-board parent-board?)
|
||||
:style style}
|
||||
[:span {:class (stl/css-case
|
||||
:tab-indentation true
|
||||
:filtered is-filtered)
|
||||
:filtered filtered?)
|
||||
:style {"--depth" depth}}]
|
||||
[:div {:class (stl/css-case
|
||||
:element-list-body true
|
||||
:filtered is-filtered
|
||||
:selected is-selected
|
||||
:filtered filtered?
|
||||
:selected selected?
|
||||
:icon-layer (= (:type item) :icon))
|
||||
:style {"--depth" depth}
|
||||
:on-pointer-enter on-pointer-enter
|
||||
@@ -121,12 +117,12 @@
|
||||
|
||||
(if (< 0 (count (:shapes item)))
|
||||
[:div {:class (stl/css :button-content)}
|
||||
(when (and (not hide-toggle) (not is-filtered))
|
||||
(when (and (not hide-toggle?) (not filtered?))
|
||||
[:button {:class (stl/css-case
|
||||
:toggle-content true
|
||||
:inverse is-expanded)
|
||||
:inverse expanded?)
|
||||
:data-testid "toggle-content"
|
||||
:aria-expanded is-expanded
|
||||
:aria-expanded expanded?
|
||||
:on-click on-toggle-collapse}
|
||||
deprecated-icon/arrow])
|
||||
|
||||
@@ -137,7 +133,7 @@
|
||||
[:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]]
|
||||
|
||||
[:div {:class (stl/css :button-content)}
|
||||
(when (not ^boolean is-filtered)
|
||||
(when (not ^boolean filtered?)
|
||||
[:span {:class (stl/css :toggle-content)}])
|
||||
[:div {:class (stl/css :icon-shape)
|
||||
:on-double-click on-zoom-to-selected}
|
||||
@@ -146,26 +142,25 @@
|
||||
[:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]])
|
||||
|
||||
[:> layer-name* {:ref name-ref
|
||||
:rename-id rename-id
|
||||
:shape-id id
|
||||
:shape-name name
|
||||
:is-shape-touched touched?
|
||||
:disabled-double-click is-read-only
|
||||
:disabled-double-click read-only?
|
||||
:on-start-edit on-disable-drag
|
||||
:on-stop-edit on-enable-drag
|
||||
:depth depth
|
||||
:is-blocked blocked?
|
||||
:parent-size parent-size
|
||||
:is-selected is-selected
|
||||
:type-comp (or is-component-tree is-variant-container?)
|
||||
:is-selected selected?
|
||||
:type-comp (or component-tree? is-variant-container?)
|
||||
:type-frame (cfh/frame-shape? item)
|
||||
:variant-id variant-id
|
||||
:variant-name variant-name
|
||||
:variant-properties variant-properties
|
||||
:variant-error variant-error
|
||||
:component-id component-id
|
||||
:component-id (:id component)
|
||||
:is-hidden hidden?}]]
|
||||
(when (not ^boolean is-read-only)
|
||||
(when (not read-only?)
|
||||
[:div {:class (stl/css-case
|
||||
:element-actions true
|
||||
:is-parent has-shapes?
|
||||
@@ -190,86 +185,41 @@
|
||||
|
||||
children]))
|
||||
|
||||
(mf/defc layer-item*
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [index item selected objects rename-id
|
||||
is-sortable is-filtered depth is-component-child
|
||||
highlighted style render-children parent-size]
|
||||
:or {render-children true}}]
|
||||
(let [id (get item :id)
|
||||
blocked? (get item :blocked)
|
||||
hidden? (get item :hidden)
|
||||
|
||||
shapes (get item :shapes)
|
||||
shapes (mf/with-memo [shapes objects]
|
||||
(loop [counter 0
|
||||
shapes (seq shapes)
|
||||
result (list)]
|
||||
|
||||
(if-let [id (first shapes)]
|
||||
(if-let [obj (get objects id)]
|
||||
(do
|
||||
;; NOTE: this is a bit hacky, but reduces substantially
|
||||
;; the allocation; If we use enumeration, we allocate
|
||||
;; new sequence and add one iteration on each render,
|
||||
;; independently if objects are changed or not. If we
|
||||
;; store counter on metadata, we still need to create a
|
||||
;; new allocation for each shape; with this method we
|
||||
;; bypass this by mutating a private property on the
|
||||
;; object removing extra allocation and extra iteration
|
||||
;; on every request.
|
||||
(unchecked-set obj "__$__counter" counter)
|
||||
(recur (inc counter)
|
||||
(rest shapes)
|
||||
(conj result obj)))
|
||||
(recur (inc counter)
|
||||
(rest shapes)
|
||||
result))
|
||||
|
||||
(-> result vec not-empty))))
|
||||
;; Memoized for performance
|
||||
(mf/defc layer-item
|
||||
{::mf/props :obj
|
||||
::mf/wrap [mf/memo]}
|
||||
[{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted style render-children?]
|
||||
:or {render-children? true}}]
|
||||
(let [id (:id item)
|
||||
blocked? (:blocked item)
|
||||
hidden? (:hidden item)
|
||||
|
||||
drag-disabled* (mf/use-state false)
|
||||
drag-disabled? (deref drag-disabled*)
|
||||
|
||||
scroll-middle-ref (mf/use-ref true)
|
||||
scroll-to-middle? (mf/use-var true)
|
||||
expanded-iref (mf/with-memo [id]
|
||||
(l/derived #(dm/get-in % [:expanded id]) refs/workspace-local))
|
||||
is-expanded (mf/deref expanded-iref)
|
||||
(-> (l/in [:expanded id])
|
||||
(l/derived refs/workspace-local)))
|
||||
expanded? (mf/deref expanded-iref)
|
||||
|
||||
is-selected (contains? selected id)
|
||||
is-highlighted (contains? highlighted id)
|
||||
selected? (contains? selected id)
|
||||
highlighted? (contains? highlighted id)
|
||||
|
||||
container? (or (cfh/frame-shape? item)
|
||||
(cfh/group-shape? item))
|
||||
|
||||
is-read-only (mf/use-ctx ctx/workspace-read-only?)
|
||||
root-board? (and (cfh/frame-shape? item)
|
||||
read-only? (mf/use-ctx ctx/workspace-read-only?)
|
||||
parent-board? (and (cfh/frame-shape? item)
|
||||
(= uuid/zero (:parent-id item)))
|
||||
|
||||
name-node-ref (mf/use-ref)
|
||||
|
||||
depth (+ depth 1)
|
||||
|
||||
is-component-tree (or ^boolean is-component-child
|
||||
^boolean (ctk/instance-root? item)
|
||||
^boolean (ctk/instance-head? item))
|
||||
|
||||
enable-drag (mf/use-fn #(reset! drag-disabled* false))
|
||||
disable-drag (mf/use-fn #(reset! drag-disabled* true))
|
||||
|
||||
;; Lazy loading of child elements via IntersectionObserver
|
||||
children-count* (mf/use-state 0)
|
||||
children-count (deref children-count*)
|
||||
|
||||
lazy-ref (mf/use-ref nil)
|
||||
observer-ref (mf/use-ref nil)
|
||||
|
||||
toggle-collapse
|
||||
(mf/use-fn
|
||||
(mf/deps is-expanded)
|
||||
(mf/deps expanded?)
|
||||
(fn [event]
|
||||
(dom/stop-propagation event)
|
||||
(if (and is-expanded (kbd/shift? event))
|
||||
(if (and expanded? (kbd/shift? event))
|
||||
(st/emit! (dwc/collapse-all))
|
||||
(st/emit! (dwc/toggle-collapse id)))))
|
||||
|
||||
@@ -294,13 +244,13 @@
|
||||
|
||||
select-shape
|
||||
(mf/use-fn
|
||||
(mf/deps id is-filtered objects)
|
||||
(mf/deps id filtered? objects)
|
||||
(fn [event]
|
||||
(dom/prevent-default event)
|
||||
(mf/set-ref-val! scroll-middle-ref false)
|
||||
(reset! scroll-to-middle? false)
|
||||
(cond
|
||||
(kbd/shift? event)
|
||||
(if is-filtered
|
||||
(if filtered?
|
||||
(st/emit! (dw/shift-select-shapes id objects))
|
||||
(st/emit! (dw/shift-select-shapes id)))
|
||||
|
||||
@@ -335,11 +285,11 @@
|
||||
|
||||
on-context-menu
|
||||
(mf/use-fn
|
||||
(mf/deps item is-read-only)
|
||||
(mf/deps item read-only?)
|
||||
(fn [event]
|
||||
(dom/prevent-default event)
|
||||
(dom/stop-propagation event)
|
||||
(when-not is-read-only
|
||||
(when-not read-only?
|
||||
(let [pos (dom/get-client-position event)]
|
||||
(st/emit! (dw/show-shape-context-menu {:position pos :shape item}))))))
|
||||
|
||||
@@ -352,7 +302,7 @@
|
||||
|
||||
on-drop
|
||||
(mf/use-fn
|
||||
(mf/deps id objects is-expanded selected)
|
||||
(mf/deps id objects expanded? selected)
|
||||
(fn [side _data]
|
||||
(let [single? (= (count selected) 1)
|
||||
same? (and single? (= (first selected) id))]
|
||||
@@ -365,34 +315,32 @@
|
||||
(= side :center)
|
||||
id
|
||||
|
||||
(and is-expanded (= side :bot) (d/not-empty? (:shapes shape)))
|
||||
(and expanded? (= side :bot) (d/not-empty? (:shapes shape)))
|
||||
id
|
||||
|
||||
:else
|
||||
(cfh/get-parent-id objects id))
|
||||
|
||||
[parent-id _]
|
||||
(ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files)
|
||||
[parent-id _] (ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files)
|
||||
|
||||
parent (get objects parent-id)
|
||||
current-index (d/index-of (:shapes parent) id)
|
||||
|
||||
to-index (cond
|
||||
(= side :center) 0
|
||||
(and is-expanded (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent))
|
||||
(and expanded? (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent))
|
||||
;; target not found in parent (while lazy loading)
|
||||
(neg? current-index) nil
|
||||
(= side :top) (inc current-index)
|
||||
:else current-index)]
|
||||
|
||||
(when (some? to-index)
|
||||
(st/emit! (dw/relocate-selected-shapes parent-id to-index))))))))
|
||||
|
||||
on-hold
|
||||
(mf/use-fn
|
||||
(mf/deps id is-expanded)
|
||||
(mf/deps id expanded?)
|
||||
(fn []
|
||||
(when-not is-expanded
|
||||
(when-not expanded?
|
||||
(st/emit! (dwc/toggle-collapse id)))))
|
||||
|
||||
zoom-to-selected
|
||||
@@ -413,114 +361,112 @@
|
||||
:data {:id (:id item)
|
||||
:index index
|
||||
:name (:name item)}
|
||||
;; We don't want to change the structure of component copies
|
||||
:draggable? (and ^boolean is-sortable
|
||||
^boolean (not is-read-only)
|
||||
^boolean (not (ctn/has-any-copy-parent? objects item))))]
|
||||
:draggable? (and
|
||||
sortable?
|
||||
(not read-only?)
|
||||
(not (ctn/has-any-copy-parent? objects item)))) ;; We don't want to change the structure of component copies
|
||||
|
||||
(mf/with-effect [is-selected selected]
|
||||
ref (mf/use-ref)
|
||||
depth (+ depth 1)
|
||||
component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item))
|
||||
|
||||
enable-drag (mf/use-fn #(reset! drag-disabled* false))
|
||||
disable-drag (mf/use-fn #(reset! drag-disabled* true))
|
||||
|
||||
;; Lazy loading of child elements via IntersectionObserver
|
||||
children-count* (mf/use-state 0)
|
||||
children-count (deref children-count*)
|
||||
lazy-ref (mf/use-ref nil)
|
||||
observer-var (mf/use-var nil)
|
||||
chunk-size 50]
|
||||
|
||||
(mf/with-effect [selected? selected]
|
||||
(let [single? (= (count selected) 1)
|
||||
node (mf/ref-val name-node-ref)
|
||||
scroll-node (dom/get-parent-with-data node "scroll-container")
|
||||
parent-node (dom/get-parent-at node 2)
|
||||
first-child-node (dom/get-first-child parent-node)
|
||||
scroll-to-middle? (mf/ref-val scroll-middle-ref)
|
||||
node (mf/ref-val ref)
|
||||
scroll-node (dom/get-parent-with-data node "scroll-container")
|
||||
parent-node (dom/get-parent-at node 2)
|
||||
first-child-node (dom/get-first-child parent-node)
|
||||
|
||||
subid
|
||||
(when (and ^boolean single?
|
||||
^boolean is-selected
|
||||
^boolean scroll-to-middle?)
|
||||
(when (and single? selected? @scroll-to-middle?)
|
||||
(ts/schedule
|
||||
100
|
||||
#(when (and node scroll-node)
|
||||
(let [scroll-distance-ratio (dom/get-scroll-distance-ratio node scroll-node)
|
||||
scroll-behavior (if (> scroll-distance-ratio 1) "instant" "smooth")]
|
||||
(dom/scroll-into-view-if-needed! first-child-node #js {:block "center" :behavior scroll-behavior :inline "start"})
|
||||
(mf/set-ref-val! scroll-middle-ref true)))))]
|
||||
(reset! scroll-to-middle? true)))))]
|
||||
|
||||
#(when (some? subid)
|
||||
(rx/dispose! subid))))
|
||||
|
||||
;; Setup scroll-driven lazy loading when expanded
|
||||
;; and ensures selected children are loaded immediately
|
||||
(mf/with-effect [is-expanded shapes selected]
|
||||
(let [total (count shapes)]
|
||||
(if ^boolean is-expanded
|
||||
(mf/with-effect [expanded? (:shapes item) selected]
|
||||
(let [shapes-vec (:shapes item)
|
||||
total (count shapes-vec)]
|
||||
(if expanded?
|
||||
(let [;; Children are rendered in reverse order, so index 0 in render = last in shapes-vec
|
||||
;; Find if any selected id is a direct child and get its render index
|
||||
selected-child-render-idx
|
||||
(when (> total default-chunk-size)
|
||||
(some (fn [sel-id]
|
||||
(let [idx (.indexOf shapes sel-id)]
|
||||
(when (>= idx 0) idx)))
|
||||
selected))
|
||||
|
||||
(when (and (> total chunk-size) (seq selected))
|
||||
(let [shapes-reversed (vec (reverse shapes-vec))]
|
||||
(some (fn [sel-id]
|
||||
(let [idx (.indexOf shapes-reversed sel-id)]
|
||||
(when (>= idx 0) idx)))
|
||||
selected)))
|
||||
;; Load at least enough to include the selected child plus extra
|
||||
;; for context (so it can be centered in the scroll view)
|
||||
min-count
|
||||
(if selected-child-render-idx
|
||||
(+ selected-child-render-idx default-chunk-size)
|
||||
default-chunk-size)
|
||||
|
||||
current-count
|
||||
@children-count*
|
||||
|
||||
new-count
|
||||
(mth/min total (mth/max current-count default-chunk-size min-count))]
|
||||
|
||||
min-count (if selected-child-render-idx
|
||||
(+ selected-child-render-idx chunk-size)
|
||||
chunk-size)
|
||||
current @children-count*
|
||||
new-count (min total (max current chunk-size min-count))]
|
||||
(reset! children-count* new-count))
|
||||
|
||||
(reset! children-count* 0))
|
||||
|
||||
(fn []
|
||||
(when-let [obs (mf/ref-val observer-ref)]
|
||||
(.disconnect obs)
|
||||
(mf/set-ref-val! obs nil)))))
|
||||
(reset! children-count* 0))))
|
||||
|
||||
;; Re-observe sentinel whenever children-count changes (sentinel moves)
|
||||
;; and (shapes item) to reconnect observer after shape changes
|
||||
(mf/with-effect [children-count is-expanded shapes]
|
||||
(let [total (count shapes)
|
||||
name-node (mf/ref-val name-node-ref)
|
||||
scroll-node (dom/get-parent-with-data name-node "scroll-container")
|
||||
lazy-node (mf/ref-val lazy-ref)]
|
||||
|
||||
(mf/with-effect [children-count expanded? (:shapes item)]
|
||||
(let [total (count (:shapes item))
|
||||
node (mf/ref-val ref)
|
||||
scroll-node (dom/get-parent-with-data node "scroll-container")
|
||||
lazy-node (mf/ref-val lazy-ref)]
|
||||
;; Disconnect previous observer
|
||||
(when-let [obs (mf/ref-val observer-ref)]
|
||||
(when-let [obs ^js @observer-var]
|
||||
(.disconnect obs)
|
||||
(mf/set-ref-val! observer-ref nil))
|
||||
|
||||
(reset! observer-var nil))
|
||||
;; Setup new observer if there are more children to load
|
||||
(when (and ^boolean is-expanded
|
||||
^boolean (< children-count total)
|
||||
^boolean scroll-node
|
||||
^boolean lazy-node)
|
||||
(when (and expanded?
|
||||
(< children-count total)
|
||||
scroll-node
|
||||
lazy-node)
|
||||
(let [cb (fn [entries]
|
||||
(when (and (pos? (alength entries))
|
||||
(.-isIntersecting ^js (aget entries 0)))
|
||||
(when (and (seq entries)
|
||||
(.-isIntersecting (first entries)))
|
||||
;; Load next chunk when sentinel intersects
|
||||
(let [next-count (mth/min total (+ children-count default-chunk-size))]
|
||||
(let [current @children-count*
|
||||
next-count (min total (+ current chunk-size))]
|
||||
(reset! children-count* next-count))))
|
||||
observer (js/IntersectionObserver. cb #js {:root scroll-node})]
|
||||
(.observe observer lazy-node)
|
||||
(mf/set-ref-val! observer-ref observer)))))
|
||||
(reset! observer-var observer)))))
|
||||
|
||||
[:> layer-item-inner*
|
||||
[:& layer-item-inner
|
||||
{:ref dref
|
||||
:item item
|
||||
:depth depth
|
||||
:parent-size parent-size
|
||||
:name-ref name-node-ref
|
||||
:rename-id rename-id
|
||||
:is-read-only is-read-only
|
||||
:is-highlighted is-highlighted
|
||||
:is-selected is-selected
|
||||
:is-component-tree is-component-tree
|
||||
:is-filtered is-filtered
|
||||
:is-expanded is-expanded
|
||||
:dnd-over (= (:over dprops) :center)
|
||||
:dnd-over-top (= (:over dprops) :top)
|
||||
:dnd-over-bot (= (:over dprops) :bot)
|
||||
:name-ref ref
|
||||
:read-only? read-only?
|
||||
:highlighted? highlighted?
|
||||
:selected? selected?
|
||||
:component-tree? component-tree?
|
||||
:filtered? filtered?
|
||||
:expanded? expanded?
|
||||
:dnd-over? (= (:over dprops) :center)
|
||||
:dnd-over-top? (= (:over dprops) :top)
|
||||
:dnd-over-bot? (= (:over dprops) :bot)
|
||||
:on-select-shape select-shape
|
||||
:on-context-menu on-context-menu
|
||||
:on-pointer-enter on-pointer-enter
|
||||
@@ -533,28 +479,29 @@
|
||||
:on-toggle-blocking toggle-blocking
|
||||
:style style}
|
||||
|
||||
(when (and ^boolean render-children
|
||||
^boolean shapes
|
||||
^boolean is-expanded)
|
||||
(when (and render-children?
|
||||
(:shapes item)
|
||||
expanded?)
|
||||
[:div {:class (stl/css-case
|
||||
:element-children true
|
||||
:parent-selected is-selected
|
||||
:sticky-children root-board?)
|
||||
:parent-selected selected?
|
||||
:sticky-children parent-board?)
|
||||
:data-testid (dm/str "children-" id)}
|
||||
(for [item (take children-count shapes)]
|
||||
[:> layer-item*
|
||||
{:item item
|
||||
:rename-id rename-id
|
||||
:highlighted highlighted
|
||||
:selected selected
|
||||
:index (unchecked-get item "__$__counter")
|
||||
:objects objects
|
||||
:key (dm/str (get item :id))
|
||||
:is-sortable is-sortable
|
||||
:depth depth
|
||||
:parent-size parent-size
|
||||
:is-component-child is-component-tree}])
|
||||
|
||||
(when (< children-count (count shapes))
|
||||
(let [all-children (reverse (d/enumerate (:shapes item)))
|
||||
visible (take children-count all-children)]
|
||||
(for [[index id] visible]
|
||||
(when-let [item (get objects id)]
|
||||
[:& layer-item
|
||||
{:item item
|
||||
:highlighted highlighted
|
||||
:selected selected
|
||||
:index index
|
||||
:objects objects
|
||||
:key (dm/str id)
|
||||
:sortable? sortable?
|
||||
:depth depth
|
||||
:parent-size parent-size
|
||||
:component-child? component-tree?}])))
|
||||
(when (< children-count (count (:shapes item)))
|
||||
[:div {:ref lazy-ref
|
||||
:class (stl/css :lazy-load-sentinel)}])])]))
|
||||
|
||||
@@ -16,35 +16,39 @@
|
||||
[app.util.dom :as dom]
|
||||
[app.util.keyboard :as kbd]
|
||||
[cuerdas.core :as str]
|
||||
[okulary.core :as l]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def ^:private ^:const space-for-icons 110)
|
||||
(def ^:private space-for-icons 110)
|
||||
|
||||
(def lens:shape-for-rename
|
||||
(-> (l/in [:workspace-local :shape-for-rename])
|
||||
(l/derived st/state)))
|
||||
|
||||
(mf/defc layer-name*
|
||||
[{:keys [shape-id rename-id shape-name is-shape-touched disabled-double-click
|
||||
{::mf/wrap-props false
|
||||
::mf/forward-ref true}
|
||||
[{:keys [shape-id shape-name is-shape-touched disabled-double-click
|
||||
on-start-edit on-stop-edit depth parent-size is-selected
|
||||
type-comp type-frame component-id is-hidden is-blocked
|
||||
variant-id variant-name variant-properties variant-error ref]}]
|
||||
|
||||
variant-id variant-name variant-properties variant-error]} external-ref]
|
||||
(let [edition* (mf/use-state false)
|
||||
edition? (deref edition*)
|
||||
|
||||
local-ref (mf/use-ref)
|
||||
ref (d/nilv ref local-ref)
|
||||
ref (d/nilv external-ref local-ref)
|
||||
|
||||
shape-name
|
||||
(if variant-id
|
||||
(d/nilv variant-error variant-name)
|
||||
shape-name)
|
||||
shape-for-rename (mf/deref lens:shape-for-rename)
|
||||
|
||||
default-value
|
||||
(mf/with-memo [variant-id variant-error variant-properties]
|
||||
(if variant-id
|
||||
(or variant-error (ctv/properties-map->formula variant-properties))
|
||||
shape-name))
|
||||
shape-name (if variant-id
|
||||
(d/nilv variant-error variant-name)
|
||||
shape-name)
|
||||
|
||||
has-path?
|
||||
(str/includes? shape-name "/")
|
||||
default-value (if variant-id
|
||||
(or variant-error (ctv/properties-map->formula variant-properties))
|
||||
shape-name)
|
||||
|
||||
has-path? (str/includes? shape-name "/")
|
||||
|
||||
start-edit
|
||||
(mf/use-fn
|
||||
@@ -81,11 +85,10 @@
|
||||
(when (kbd/enter? event) (accept-edit))
|
||||
(when (kbd/esc? event) (cancel-edit))))
|
||||
|
||||
parent-size
|
||||
(dm/str (- parent-size space-for-icons) "px")]
|
||||
parent-size (dm/str (- parent-size space-for-icons) "px")]
|
||||
|
||||
(mf/with-effect [rename-id edition? start-edit shape-id]
|
||||
(when (and (= rename-id shape-id)
|
||||
(mf/with-effect [shape-for-rename edition? start-edit shape-id]
|
||||
(when (and (= shape-for-rename shape-id)
|
||||
(not ^boolean edition?))
|
||||
(start-edit)))
|
||||
|
||||
@@ -107,24 +110,21 @@
|
||||
:auto-focus true
|
||||
:id (dm/str "layer-name-" shape-id)
|
||||
:default-value (d/nilv default-value "")}]
|
||||
|
||||
[:*
|
||||
[:span {:class (stl/css-case
|
||||
:element-name true
|
||||
:left-ellipsis has-path?
|
||||
:selected is-selected
|
||||
:hidden is-hidden
|
||||
:type-comp type-comp
|
||||
:type-frame type-frame)
|
||||
:id (dm/str "layer-name-" shape-id)
|
||||
:style {"--depth" depth "--parent-size" parent-size}
|
||||
:ref ref
|
||||
:on-double-click start-edit}
|
||||
|
||||
(if ^boolean (dbg/enabled? :show-ids)
|
||||
(dm/str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24))
|
||||
[:span
|
||||
{:class (stl/css-case
|
||||
:element-name true
|
||||
:left-ellipsis has-path?
|
||||
:selected is-selected
|
||||
:hidden is-hidden
|
||||
:type-comp type-comp
|
||||
:type-frame type-frame)
|
||||
:id (dm/str "layer-name-" shape-id)
|
||||
:style {"--depth" depth "--parent-size" parent-size}
|
||||
:ref ref
|
||||
:on-double-click start-edit}
|
||||
(if (dbg/enabled? :show-ids)
|
||||
(str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24))
|
||||
(d/nilv shape-name ""))]
|
||||
|
||||
(when (and ^boolean (dbg/enabled? :show-touched)
|
||||
^boolean is-shape-touched)
|
||||
(when (and (dbg/enabled? :show-touched) ^boolean is-shape-touched)
|
||||
[:span {:class (stl/css :element-name-touched)} "*"])])))
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
||||
[app.main.ui.hooks :as hooks]
|
||||
[app.main.ui.notifications.badge :refer [badge-notification]]
|
||||
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item*]]
|
||||
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item]]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.globals :as globals]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
@@ -31,160 +31,92 @@
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]
|
||||
[goog.events :as events]
|
||||
[okulary.core :as l]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def ^:private ref:highlighted-shapes
|
||||
(l/derived (fn [local]
|
||||
(-> local
|
||||
(get :highlighted)
|
||||
(not-empty)))
|
||||
refs/workspace-local))
|
||||
|
||||
(def ^:private ref:shape-for-rename
|
||||
(l/derived (l/key :shape-for-rename) refs/workspace-local))
|
||||
|
||||
(defn- use-selected-shapes
|
||||
"A convencience hook wrapper for get selected shapes"
|
||||
[]
|
||||
(let [selected (mf/deref refs/selected-shapes)]
|
||||
(hooks/use-equal-memo selected)))
|
||||
[rumext.v2 :as mf])
|
||||
(:import
|
||||
goog.events.EventType))
|
||||
|
||||
;; This components is a piece for sharding equality check between top
|
||||
;; level frames and try to avoid rerender frames that are does not
|
||||
;; affected by the selected set.
|
||||
(mf/defc frame-wrapper*
|
||||
(mf/defc frame-wrapper
|
||||
{::mf/props :obj}
|
||||
[{:keys [selected] :as props}]
|
||||
(let [pending-selected-ref
|
||||
(mf/use-ref selected)
|
||||
|
||||
current-selected
|
||||
(mf/use-state selected)
|
||||
|
||||
props
|
||||
(mf/spread-object props {:selected @current-selected})
|
||||
(let [pending-selected (mf/use-var selected)
|
||||
current-selected (mf/use-state selected)
|
||||
props (mf/spread-object props {:selected @current-selected})
|
||||
|
||||
set-selected
|
||||
(mf/with-memo []
|
||||
(throttle-fn 50 #(when-let [pending-selected (mf/ref-val pending-selected-ref)]
|
||||
(reset! current-selected pending-selected))))]
|
||||
(mf/use-memo
|
||||
(fn []
|
||||
(throttle-fn
|
||||
50
|
||||
#(when-let [pending-selected @pending-selected]
|
||||
(reset! current-selected pending-selected)))))]
|
||||
|
||||
(mf/with-effect [selected set-selected]
|
||||
(mf/set-ref-val! pending-selected-ref selected)
|
||||
(^function set-selected)
|
||||
(reset! pending-selected selected)
|
||||
(set-selected)
|
||||
(fn []
|
||||
(mf/set-ref-val! pending-selected-ref nil)
|
||||
(rx/dispose! set-selected)))
|
||||
(reset! pending-selected nil)
|
||||
#(rx/dispose! set-selected)))
|
||||
|
||||
[:> layer-item* props]))
|
||||
|
||||
(mf/defc layers-tree*
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [objects is-filtered parent-size] :as props}]
|
||||
(let [selected (use-selected-shapes)
|
||||
highlighted (mf/deref ref:highlighted-shapes)
|
||||
root (get objects uuid/zero)
|
||||
|
||||
rename-id (mf/deref ref:shape-for-rename)
|
||||
|
||||
shapes (get root :shapes)
|
||||
shapes (mf/with-memo [shapes objects]
|
||||
(loop [counter 0
|
||||
shapes (seq shapes)
|
||||
result (list)]
|
||||
(if-let [id (first shapes)]
|
||||
(if-let [obj (get objects id)]
|
||||
(do
|
||||
;; NOTE: this is a bit hacky, but reduces substantially
|
||||
;; the allocation; If we use enumeration, we allocate
|
||||
;; new sequence and add one iteration on each render,
|
||||
;; independently if objects are changed or not. If we
|
||||
;; store counter on metadata, we still need to create a
|
||||
;; new allocation for each shape; with this method we
|
||||
;; bypass this by mutating a private property on the
|
||||
;; object removing extra allocation and extra iteration
|
||||
;; on every request.
|
||||
(unchecked-set obj "__$__counter" counter)
|
||||
(recur (inc counter)
|
||||
(rest shapes)
|
||||
(conj result obj)))
|
||||
(recur (inc counter)
|
||||
(rest shapes)
|
||||
result))
|
||||
result)))]
|
||||
[:> layer-item props]))
|
||||
|
||||
(mf/defc layers-tree
|
||||
{::mf/wrap [mf/memo #(mf/throttle % 200)]
|
||||
::mf/wrap-props false}
|
||||
[{:keys [objects filtered? parent-size] :as props}]
|
||||
(let [selected (mf/deref refs/selected-shapes)
|
||||
selected (hooks/use-equal-memo selected)
|
||||
highlighted (mf/deref refs/highlighted-shapes)
|
||||
highlighted (hooks/use-equal-memo highlighted)
|
||||
root (get objects uuid/zero)]
|
||||
[:div {:class (stl/css :element-list) :data-testid "layer-item"}
|
||||
[:> hooks/sortable-container* {}
|
||||
(for [obj shapes]
|
||||
(if (cfh/frame-shape? obj)
|
||||
[:> frame-wrapper*
|
||||
{:item obj
|
||||
:rename-id rename-id
|
||||
:selected selected
|
||||
:highlighted highlighted
|
||||
:index (unchecked-get obj "__$__counter")
|
||||
:objects objects
|
||||
:key (dm/str (get obj :id))
|
||||
:is-sortable true
|
||||
:is-filtered is-filtered
|
||||
:parent-size parent-size
|
||||
:depth -1}]
|
||||
[:> layer-item*
|
||||
{:item obj
|
||||
:rename-id rename-id
|
||||
:selected selected
|
||||
:highlighted highlighted
|
||||
:index (unchecked-get obj "__$__counter")
|
||||
:objects objects
|
||||
:key (dm/str (get obj :id))
|
||||
:is-sortable true
|
||||
:is-filtered is-filtered
|
||||
:depth -1
|
||||
:parent-size parent-size}]))]]))
|
||||
(for [[index id] (reverse (d/enumerate (:shapes root)))]
|
||||
(when-let [obj (get objects id)]
|
||||
(if (cfh/frame-shape? obj)
|
||||
[:& frame-wrapper
|
||||
{:item obj
|
||||
:selected selected
|
||||
:highlighted highlighted
|
||||
:index index
|
||||
:objects objects
|
||||
:key id
|
||||
:sortable? true
|
||||
:filtered? filtered?
|
||||
:parent-size parent-size
|
||||
:depth -1}]
|
||||
[:& layer-item
|
||||
{:item obj
|
||||
:selected selected
|
||||
:highlighted highlighted
|
||||
:index index
|
||||
:objects objects
|
||||
:key id
|
||||
:sortable? true
|
||||
:filtered? filtered?
|
||||
:depth -1
|
||||
:parent-size parent-size}])))]]))
|
||||
|
||||
(mf/defc layers-tree-wrapper*
|
||||
{::mf/private true}
|
||||
[{:keys [objects] :as props}]
|
||||
;; This is a performance sensitive componet, so we use lower-level primitives for
|
||||
;; reduce residual allocation for this specific case
|
||||
(let [state-tmp (mf/useState objects)
|
||||
objects' (aget state-tmp 0)
|
||||
set-objects (aget state-tmp 1)
|
||||
|
||||
subject-s (mf/with-memo []
|
||||
(rx/subject))
|
||||
changes-s (mf/with-memo [subject-s]
|
||||
(->> subject-s
|
||||
(rx/debounce 500)))
|
||||
|
||||
props (mf/spread-props props {:objects objects'})]
|
||||
|
||||
(mf/with-effect [objects subject-s]
|
||||
(rx/push! subject-s objects))
|
||||
|
||||
(mf/with-effect [changes-s]
|
||||
(let [sub (rx/subscribe changes-s set-objects)]
|
||||
#(rx/dispose! sub)))
|
||||
|
||||
[:> layers-tree* props]))
|
||||
|
||||
(mf/defc filters-tree*
|
||||
{::mf/wrap [mf/memo #(mf/throttle % 300)]
|
||||
::mf/private true}
|
||||
(mf/defc filters-tree
|
||||
{::mf/wrap [mf/memo #(mf/throttle % 200)]
|
||||
::mf/wrap-props false}
|
||||
[{:keys [objects parent-size]}]
|
||||
(let [selected (use-selected-shapes)
|
||||
root (get objects uuid/zero)]
|
||||
(let [selected (mf/deref refs/selected-shapes)
|
||||
selected (hooks/use-equal-memo selected)
|
||||
root (get objects uuid/zero)]
|
||||
[:ul {:class (stl/css :element-list)}
|
||||
(for [[index id] (d/enumerate (:shapes root))]
|
||||
(when-let [obj (get objects id)]
|
||||
[:> layer-item*
|
||||
[:& layer-item
|
||||
{:item obj
|
||||
:selected selected
|
||||
:index index
|
||||
:objects objects
|
||||
:key id
|
||||
:is-sortable false
|
||||
:is-filtered true
|
||||
:sortable? false
|
||||
:filtered? true
|
||||
:depth -1
|
||||
:parent-size parent-size}]))]))
|
||||
|
||||
@@ -200,7 +132,6 @@
|
||||
keys
|
||||
(filter #(not= uuid/zero %))
|
||||
vec)]
|
||||
|
||||
(update reparented-objects uuid/zero assoc :shapes reparented-shapes)))
|
||||
|
||||
;; --- Layers Toolbox
|
||||
@@ -346,11 +277,9 @@
|
||||
(swap! state* update :num-items + 100))))]
|
||||
|
||||
(mf/with-effect []
|
||||
(let [key1 (events/listen globals/document "keydown" on-key-down)
|
||||
key2 (events/listen globals/document "click" hide-menu)]
|
||||
(fn []
|
||||
(events/unlistenByKey key1)
|
||||
(events/unlistenByKey key2))))
|
||||
(let [keys [(events/listen globals/document EventType.KEYDOWN on-key-down)
|
||||
(events/listen globals/document EventType.CLICK hide-menu)]]
|
||||
(fn [] (doseq [key keys] (events/unlistenByKey key)))))
|
||||
|
||||
[filtered-objects
|
||||
handle-show-more
|
||||
@@ -535,8 +464,6 @@
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [size-parent]}]
|
||||
(let [page (mf/deref refs/workspace-page)
|
||||
page-id (get page :id)
|
||||
|
||||
focus (mf/deref refs/workspace-focus-selected)
|
||||
|
||||
objects (hooks/with-focus-objects (:objects page) focus)
|
||||
@@ -546,8 +473,7 @@
|
||||
observer-var (mf/use-var nil)
|
||||
lazy-load-ref (mf/use-ref nil)
|
||||
|
||||
[filtered-objects show-more filter-component]
|
||||
(use-search page objects)
|
||||
[filtered-objects show-more filter-component] (use-search page objects)
|
||||
|
||||
intersection-callback
|
||||
(fn [entries]
|
||||
@@ -593,25 +519,25 @@
|
||||
[:div {:class (stl/css :tool-window-content)
|
||||
:data-scroll-container true
|
||||
:ref on-render-container}
|
||||
[:> filters-tree* {:objects filtered-objects
|
||||
:key (dm/str page-id)
|
||||
:parent-size size-parent}]
|
||||
[:& filters-tree {:objects filtered-objects
|
||||
:key (dm/str (:id page))
|
||||
:parent-size size-parent}]
|
||||
[:div {:ref lazy-load-ref}]]
|
||||
[:div {:on-scroll on-scroll
|
||||
:class (stl/css :tool-window-content)
|
||||
:data-scroll-container true
|
||||
:style {:display (when (some? filtered-objects) "none")}}
|
||||
|
||||
[:> layers-tree-wrapper* {:objects filtered-objects
|
||||
:key (dm/str page-id)
|
||||
:is-filtered true
|
||||
:parent-size size-parent}]]]
|
||||
[:& layers-tree {:objects filtered-objects
|
||||
:key (dm/str (:id page))
|
||||
:filtered? true
|
||||
:parent-size size-parent}]]]
|
||||
|
||||
[:div {:on-scroll on-scroll
|
||||
:class (stl/css :tool-window-content)
|
||||
:data-scroll-container true
|
||||
:style {:display (when (some? filtered-objects) "none")}}
|
||||
[:> layers-tree-wrapper* {:objects objects
|
||||
:key (dm/str page-id)
|
||||
:is-filtered false
|
||||
:parent-size size-parent}]])]))
|
||||
[:& layers-tree {:objects objects
|
||||
:key (dm/str (:id page))
|
||||
:filtered? false
|
||||
:parent-size size-parent}]])]))
|
||||
|
||||
@@ -92,19 +92,6 @@
|
||||
(def ^:private xf:map-type (map :type))
|
||||
(def ^:private xf:mapcat-type-to-options (mapcat type->options))
|
||||
|
||||
(defn fixed-decimal-value
|
||||
"Fixes the amount of decimals that are kept"
|
||||
([value]
|
||||
(fixed-decimal-value value 2))
|
||||
|
||||
([value decimals]
|
||||
(cond
|
||||
(string? value)
|
||||
(fixed-decimal-value (parse-double value) decimals)
|
||||
|
||||
(number? value)
|
||||
(parse-double (.toFixed value decimals)))))
|
||||
|
||||
(mf/defc measures-menu*
|
||||
[{:keys [ids values applied-tokens type shapes]}]
|
||||
(let [token-numeric-inputs
|
||||
@@ -313,7 +300,7 @@
|
||||
(mf/deps ids)
|
||||
(fn [value]
|
||||
(if (or (string? value) (number? value))
|
||||
(let [value (fixed-decimal-value value)]
|
||||
(do
|
||||
(st/emit! (udw/trigger-bounding-box-cloaking ids))
|
||||
(st/emit! (udw/increase-rotation ids value)))
|
||||
(st/emit! (udw/trigger-bounding-box-cloaking ids)
|
||||
|
||||
@@ -143,8 +143,7 @@
|
||||
(let [token-ids (set tokens-in-path-ids)
|
||||
remaining-tokens (filter (fn [token]
|
||||
(not (contains? token-ids (:id token))))
|
||||
selected-token-set-tokens)
|
||||
_ (prn "Remaining tokens:" remaining-tokens)]
|
||||
selected-token-set-tokens)]
|
||||
(seq remaining-tokens))))
|
||||
|
||||
delete-token
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.workspace.tokens.management.forms.controls.color-input :as color]
|
||||
[app.main.ui.workspace.tokens.management.forms.controls.combobox :as combobox]
|
||||
[app.main.ui.workspace.tokens.management.forms.controls.fonts-combobox :as fonts]
|
||||
[app.main.ui.workspace.tokens.management.forms.controls.input :as input]
|
||||
[app.main.ui.workspace.tokens.management.forms.controls.select :as select]))
|
||||
@@ -16,4 +17,6 @@
|
||||
(dm/export fonts/fonts-combobox*)
|
||||
(dm/export fonts/composite-fonts-combobox*)
|
||||
|
||||
(dm/export select/select-indexed*)
|
||||
(dm/export select/select-indexed*)
|
||||
|
||||
(dm/export combobox/value-combobox*)
|
||||
@@ -13,8 +13,10 @@
|
||||
[app.common.types.color :as cl]
|
||||
[app.common.types.token :as cto]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.config :as cf]
|
||||
[app.main.data.style-dictionary :as sd]
|
||||
[app.main.data.tinycolor :as tinycolor]
|
||||
[app.main.data.tokenscript :as ts]
|
||||
[app.main.data.workspace.tokens.format :as dwtf]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.ui.ds.controls.input :as ds]
|
||||
@@ -70,11 +72,15 @@
|
||||
(dissoc (:name prev-token))
|
||||
(update (:name token) #(ctob/make-token (merge % prev-token token))))]
|
||||
|
||||
(->> tokens
|
||||
(sd/resolve-tokens-interactive)
|
||||
(->> (if (contains? cf/flags :tokenscript)
|
||||
(rx/of (ts/resolve-tokens tokens))
|
||||
(sd/resolve-tokens-interactive tokens))
|
||||
(rx/mapcat
|
||||
(fn [resolved-tokens]
|
||||
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))]
|
||||
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))
|
||||
resolved-value (if (contains? cf/flags :tokenscript)
|
||||
(ts/tokenscript-symbols->penpot-unit resolved-value)
|
||||
resolved-value)]
|
||||
(if resolved-value
|
||||
(rx/of {:value resolved-value})
|
||||
(rx/of {:error (first errors)}))))))))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user