mirror of
https://github.com/penpot/penpot.git
synced 2026-02-25 03:07:14 -05:00
Compare commits
3 Commits
develop
...
azazeln28-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2e689a645 | ||
|
|
32d4026641 | ||
|
|
4477b2b4a0 |
1
.github/workflows/build-develop.yml
vendored
1
.github/workflows/build-develop.yml
vendored
@@ -1,7 +1,6 @@
|
||||
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,7 +1,6 @@
|
||||
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,7 +1,6 @@
|
||||
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,7 +1,6 @@
|
||||
name: _TAG
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
21
CHANGES.md
21
CHANGES.md
@@ -1,25 +1,5 @@
|
||||
# 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
|
||||
@@ -51,7 +31,6 @@
|
||||
- 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 unable to finish the create account form using keyboard [Taiga #11333](https://tree.taiga.io/project/penpot/issue/11333)
|
||||
|
||||
## 2.13.3
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
::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 check-input
|
||||
(def ^:private check-input
|
||||
(sm/check-fn schema:input))
|
||||
|
||||
(defn validate-media-type!
|
||||
@@ -381,22 +381,6 @@
|
||||
(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]
|
||||
@@ -446,27 +430,4 @@
|
||||
|
||||
(= stype :ttf)
|
||||
(-> (assoc "font/otf" (ttf->otf 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))))))))
|
||||
(assoc "font/ttf" sfnt)))))))))
|
||||
|
||||
@@ -463,10 +463,8 @@
|
||||
: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]
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE access_token
|
||||
ADD COLUMN type text NULL;
|
||||
@@ -87,10 +87,6 @@
|
||||
[: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)]
|
||||
@@ -101,11 +97,6 @@
|
||||
(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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -114,8 +105,7 @@
|
||||
[_ cfg]
|
||||
(when (contains? cf/flags :nitrate)
|
||||
{:get-team-org (partial get-team-org cfg)
|
||||
:is-valid-user (partial is-valid-user cfg)
|
||||
:connectivity (partial get-connectivity cfg)}))
|
||||
:is-valid-user (partial is-valid-user cfg)}))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; UTILS
|
||||
@@ -138,7 +128,3 @@
|
||||
(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,13 +73,9 @@
|
||||
(if (nil? result)
|
||||
204
|
||||
200))
|
||||
|
||||
headers (::http/headers mdata {})
|
||||
headers (cond-> headers
|
||||
(and (yres/stream-body? result)
|
||||
(not (contains? headers "content-type")))
|
||||
headers (cond-> (::http/headers mdata {})
|
||||
(yres/stream-body? result)
|
||||
(assoc "content-type" "application/octet-stream"))]
|
||||
|
||||
{::yres/status status
|
||||
::yres/headers headers
|
||||
::yres/body result}))]
|
||||
@@ -262,7 +258,6 @@
|
||||
'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 type]
|
||||
[{:keys [::db/conn] :as cfg} profile-id name expiration]
|
||||
(let [token-id (uuid/next)
|
||||
expires-at (some-> expiration (ct/in-future))
|
||||
created-at (ct/now)
|
||||
@@ -36,7 +36,6 @@
|
||||
{:id token-id
|
||||
:name name
|
||||
:token token
|
||||
:type type
|
||||
:profile-id profile-id
|
||||
:created-at created-at
|
||||
:updated-at created-at
|
||||
@@ -51,18 +50,17 @@
|
||||
(def ^:private schema:create-access-token
|
||||
[:map {:title "create-access-token"}
|
||||
[:name [:string {:max 250 :min 1}]]
|
||||
[:expiration {:optional true} ::ct/duration]
|
||||
[:type {:optional true} :string]])
|
||||
[:expiration {:optional true} ::ct/duration]])
|
||||
|
||||
(sv/defmethod ::create-access-token
|
||||
{::doc/added "1.18"
|
||||
::sm/params schema:create-access-token}
|
||||
[cfg {:keys [::rpc/profile-id name expiration type]}]
|
||||
[cfg {:keys [::rpc/profile-id name expiration]}]
|
||||
|
||||
(quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
|
||||
::quotes/profile-id profile-id})
|
||||
|
||||
(db/tx-run! cfg create-access-token profile-id name expiration type))
|
||||
(db/tx-run! cfg create-access-token profile-id name expiration))
|
||||
|
||||
(def ^:private schema:delete-access-token
|
||||
[:map {:title "delete-access-token"}
|
||||
@@ -85,22 +83,5 @@
|
||||
(->> (db/query pool :access-token
|
||||
{:profile-id profile-id}
|
||||
{:order-by [[:expires-at :asc] [:created-at :asc]]
|
||||
:columns [:id :name :perms :type :created-at :updated-at :expires-at]})
|
||||
:columns [:id :name :perms :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,14 +9,12 @@
|
||||
[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]
|
||||
@@ -36,9 +34,7 @@
|
||||
java.io.InputStream
|
||||
java.io.OutputStream
|
||||
java.io.SequenceInputStream
|
||||
java.util.Collections
|
||||
java.util.zip.ZipEntry
|
||||
java.util.zip.ZipOutputStream))
|
||||
java.util.Collections))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@@ -300,98 +296,3 @@
|
||||
(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")}))))
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
(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,7 +48,6 @@
|
||||
(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 nil)
|
||||
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
|
||||
handler (#'app.http.access-token/wrap-authz identity th/*system*)]
|
||||
|
||||
(let [response (handler nil)]
|
||||
|
||||
@@ -107,18 +107,4 @@
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(let [results (:result out)]
|
||||
(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)))))))
|
||||
|
||||
(t/is (= 2 (count results))))))))
|
||||
|
||||
@@ -93,41 +93,6 @@
|
||||
: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)
|
||||
|
||||
Binary file not shown.
@@ -152,9 +152,7 @@
|
||||
:redis-cache
|
||||
|
||||
;; Activates the nitrate module
|
||||
:nitrate
|
||||
|
||||
:mcp})
|
||||
:nitrate})
|
||||
|
||||
(def all-flags
|
||||
(set/union email login varia))
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
(def font-types
|
||||
#{"font/ttf"
|
||||
"font/woff"
|
||||
"font/woff2"
|
||||
"font/otf"
|
||||
"font/opentype"})
|
||||
|
||||
@@ -82,22 +81,21 @@
|
||||
(defn parse-font-weight
|
||||
[variant]
|
||||
(cond
|
||||
(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))
|
||||
(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))
|
||||
|
||||
(defn parse-font-style
|
||||
[variant]
|
||||
(if (or (re-seq #"(?i)(?:^|[-_\s])(italic)(?:[-_\s]|$)" variant)
|
||||
(re-seq #"(?i)italic$" variant))
|
||||
(if (re-seq #"(?i)(?:italic)" variant)
|
||||
"italic"
|
||||
"normal"))
|
||||
|
||||
|
||||
@@ -9,39 +9,6 @@
|
||||
[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"))
|
||||
|
||||
@@ -119,10 +119,6 @@
|
||||
(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))
|
||||
@@ -151,9 +147,6 @@
|
||||
(let [f (obj/get global "initializeExternalConfigInfo")]
|
||||
(when (fn? f) (f))))
|
||||
|
||||
(def mcp-server-url (-> public-uri u/ensure-path-slash (u/join "mcp") str))
|
||||
(def mcp-help-center-uri "https://help.penpot.app/technical-guide/")
|
||||
|
||||
;; --- Helper Functions
|
||||
|
||||
(defn ^boolean check-browser? [candidate]
|
||||
|
||||
@@ -99,65 +99,46 @@
|
||||
map with temporal ID's associated to each font entry."
|
||||
[blobs team-id]
|
||||
(letfn [(prepare [{:keys [font type name data] :as params}]
|
||||
(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"))
|
||||
(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?})
|
||||
;; 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})))
|
||||
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?}))
|
||||
|
||||
(join [res {:keys [content] :as font}]
|
||||
(let [key-fn (juxt :font-family :font-weight :font-style)
|
||||
@@ -185,18 +166,14 @@
|
||||
(case sg
|
||||
"117 124 124 117" "font/otf"
|
||||
"0 1 0 0" "font/ttf"
|
||||
"167 117 106 106" "font/woff"
|
||||
"167 117 106 62" "font/woff2")))
|
||||
"167 117 106 106" "font/woff")))
|
||||
|
||||
(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))))
|
||||
(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)))
|
||||
|
||||
(read-blob [blob]
|
||||
(->> (wa/read-file-as-array-buffer blob)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
(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 {}))))))))
|
||||
|
||||
|
||||
@@ -65,23 +65,8 @@
|
||||
(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] :as params}]
|
||||
[{:keys [plugin-id name description host code icon permissions]}]
|
||||
(try
|
||||
(st/emit! (save-current-plugin plugin-id)
|
||||
(reset-plugin-flags plugin-id))
|
||||
|
||||
@@ -498,3 +498,4 @@
|
||||
(->> (rp/cmd! :delete-access-token params)
|
||||
(rx/tap on-success)
|
||||
(rx/catch on-error))))))
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
[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]
|
||||
@@ -213,8 +212,7 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (dp/check-open-plugin)
|
||||
(fdf/fix-deleted-fonts-for-local-library file-id)
|
||||
(mcp/init-mcp-connexion)))))
|
||||
(fdf/fix-deleted-fonts-for-local-library file-id)))))
|
||||
|
||||
(defn- bundle-fetched
|
||||
[{:keys [file file-id thumbnails] :as bundle}]
|
||||
@@ -1448,7 +1446,6 @@
|
||||
(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,55 +1039,3 @@
|
||||
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,7 +6,6 @@
|
||||
|
||||
(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]
|
||||
@@ -29,9 +28,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))
|
||||
@@ -44,43 +43,32 @@
|
||||
(> 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 [p2
|
||||
(cond-> point lock? (adjust-ratio initial))
|
||||
(let [draw-rect (cond-> (grc/make-rect initial (cond-> point lock? (adjust-ratio initial)))
|
||||
snap-pixel?
|
||||
(-> (update :width max 1)
|
||||
(update :height max 1)))
|
||||
|
||||
p1
|
||||
(if mod?
|
||||
(gpt/point (- (* 2 (:x initial)) (:x p2))
|
||||
(- (* 2 (:y initial)) (:y p2)))
|
||||
initial)
|
||||
shape-rect (grc/make-rect x y width height)
|
||||
|
||||
draw-rect
|
||||
(cond-> (grc/make-rect p1 p2)
|
||||
snap-pixel?
|
||||
(-> (update :width d/max 1)
|
||||
(update :height d/max 1)))
|
||||
scalev (gpt/point (/ (:width draw-rect)
|
||||
(:width shape-rect))
|
||||
(/ (:height draw-rect)
|
||||
(:height shape-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))]
|
||||
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
|
||||
@@ -140,7 +128,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-alt)
|
||||
(rx/with-latest-from ms/mouse-position-shift ms/mouse-position-mod)
|
||||
(rx/switch-map
|
||||
(fn [[point :as current]]
|
||||
(->> (snap/closest-snap-point page-id [shape] objects layout zoom focus point)
|
||||
|
||||
@@ -1,60 +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.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.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 init-mcp!
|
||||
[]
|
||||
(->> (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))}}))))))
|
||||
|
||||
(defn init-mcp-connexion
|
||||
[]
|
||||
(ptk/reify ::init-mcp-connexion
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(when (and (contains? cf/flags :mcp)
|
||||
(-> state :profile :props :mcp-status))
|
||||
(init-mcp!)))))
|
||||
@@ -197,7 +197,7 @@
|
||||
:settings-options
|
||||
:settings-feedback
|
||||
:settings-subscription
|
||||
:settings-integrations
|
||||
:settings-access-tokens
|
||||
: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,7 +32,6 @@
|
||||
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)
|
||||
|
||||
@@ -83,6 +82,7 @@
|
||||
(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,18 +92,9 @@
|
||||
(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)
|
||||
@@ -140,7 +131,7 @@
|
||||
:for (name input-name)} label
|
||||
|
||||
(when is-checkbox?
|
||||
[:span {:class (stl/css-case :global/checked checked?) :tab-index "0" :on-key-press on-key-press} (when checked? deprecated-icon/status-tick)])
|
||||
[:span {:class (stl/css-case :global/checked checked?)} (when checked? deprecated-icon/status-tick)])
|
||||
|
||||
(if is-checkbox?
|
||||
[:> :input props]
|
||||
|
||||
@@ -9,17 +9,14 @@
|
||||
(: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
|
||||
@@ -71,11 +68,8 @@
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h2 {:class (stl/css :modal-title)} title]
|
||||
[:div {:class (stl/css :modal-close-btn)}
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:aria-label (tr "labels.close")
|
||||
:on-click cancel-fn
|
||||
:icon i/close}]]]
|
||||
[:button {:class (stl/css :modal-close-btn)
|
||||
:on-click cancel-fn} deprecated-icon/close]]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
(when (and (string? message) (not= message ""))
|
||||
@@ -93,19 +87,24 @@
|
||||
[:ul {:class (stl/css :component-list)}
|
||||
(for [item items]
|
||||
[:li {:class (stl/css :modal-item-element)}
|
||||
[:> icon* {:icon-id i/component
|
||||
:class (stl/css :modal-component-icon)
|
||||
:size "s"}]
|
||||
[:span {:class (stl/css :modal-component-icon)}
|
||||
deprecated-icon/component]
|
||||
[: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)
|
||||
[:> 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]]]]]))
|
||||
[: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}]]]]]))
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
|
||||
.modal-container {
|
||||
@extend .modal-container-base;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xxl);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@@ -26,13 +27,12 @@
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
position: absolute;
|
||||
top: var(--sp-m);
|
||||
right: var(--sp-m);
|
||||
@extend .modal-close-btn-base;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@include deprecated.bodyLargeTypography;
|
||||
margin-bottom: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.modal-item-element {
|
||||
@@ -41,18 +41,32 @@
|
||||
|
||||
.modal-component-icon {
|
||||
@include deprecated.flexCenter;
|
||||
color: var(--color-foreground-secondary);
|
||||
height: deprecated.$s-16;
|
||||
width: deprecated.$s-16;
|
||||
svg {
|
||||
@extend .button-icon-small;
|
||||
stroke: var(--color);
|
||||
}
|
||||
}
|
||||
|
||||
.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,9 +7,7 @@
|
||||
(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]
|
||||
@@ -24,7 +22,6 @@
|
||||
[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]
|
||||
@@ -35,7 +32,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,.woff2,.otf"))
|
||||
",.ttf,application/font-woff,woff,.otf"))
|
||||
|
||||
(defn- use-page-title
|
||||
[team section]
|
||||
@@ -119,10 +116,10 @@
|
||||
(swap! fonts* dissoc id)
|
||||
(swap! uploading* disj id)
|
||||
(st/emit! (df/add-font font)))
|
||||
(fn [cause]
|
||||
(fn [error]
|
||||
(st/emit! (ntf/error (tr "errors.bad-font" (first (:names item)))))
|
||||
(swap! fonts* dissoc id)
|
||||
(ex/print-throwable cause))))))
|
||||
(js/console.log "error" error))))))
|
||||
|
||||
on-upload
|
||||
(mf/use-fn
|
||||
@@ -262,14 +259,11 @@
|
||||
(mf/defc installed-font-context-menu
|
||||
{::mf/props :obj
|
||||
::mf/private true}
|
||||
[{:keys [is-open on-close on-edit on-download on-delete]}]
|
||||
(let [options (mf/with-memo [on-edit on-download on-delete]
|
||||
[{:keys [is-open on-close on-edit on-delete]}]
|
||||
(let [options (mf/with-memo [on-edit 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}])]
|
||||
@@ -351,26 +345,6 @@
|
||||
(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]
|
||||
@@ -433,7 +407,6 @@
|
||||
{: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,7 +16,6 @@
|
||||
[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]
|
||||
@@ -31,13 +30,10 @@
|
||||
[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]
|
||||
@@ -78,8 +74,6 @@
|
||||
(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]}]
|
||||
@@ -305,7 +299,7 @@
|
||||
(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)))))]
|
||||
(st/emit! (modal/show :nitrate-form {})))))]
|
||||
|
||||
[:> dropdown-menu* props
|
||||
|
||||
@@ -503,23 +497,18 @@
|
||||
|
||||
(mf/defc sidebar-org-switch*
|
||||
[{:keys [team profile]}]
|
||||
(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)))))))
|
||||
(let [teams (->> (mf/deref refs/teams)
|
||||
vals
|
||||
(group-by :organization-id)
|
||||
(map (fn [[_group entries]] (first entries)))
|
||||
vec
|
||||
(d/index-by :id))
|
||||
|
||||
empty? (= (count orgs) 1)
|
||||
teams (update-vals teams
|
||||
(fn [t]
|
||||
(assoc t :name (str "ORG: " (:organization-name t)))))
|
||||
|
||||
|
||||
current-org (mf/with-memo [team]
|
||||
(assoc team :name (str "ORG: " (:organization-name team))))
|
||||
team (assoc team :name (str "ORG: " (:organization-name team)))
|
||||
|
||||
show-teams-menu*
|
||||
(mf/use-state false)
|
||||
@@ -541,53 +530,36 @@
|
||||
(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))]
|
||||
|
||||
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 :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}
|
||||
|
||||
[: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}
|
||||
[: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 :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)]]
|
||||
arrow-icon]]
|
||||
|
||||
arrow-icon]]
|
||||
;; Teams Dropdown
|
||||
|
||||
;; 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}]])))
|
||||
[:> 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}]]))
|
||||
|
||||
(mf/defc sidebar-team-switch*
|
||||
[{:keys [team profile]}]
|
||||
@@ -629,7 +601,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))
|
||||
@@ -649,7 +621,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))]
|
||||
@@ -733,8 +705,6 @@
|
||||
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)))
|
||||
|
||||
@@ -823,71 +793,70 @@
|
||||
(reset! overflow* (> scroll-height client-height))))
|
||||
|
||||
[:*
|
||||
[: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}]
|
||||
[: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}]
|
||||
|
||||
[:> 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
|
||||
@@ -1087,13 +1056,10 @@
|
||||
(dom/open-new-window "https://penpot.app/pricing")))]
|
||||
|
||||
[:*
|
||||
(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}])))
|
||||
(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,11 +40,6 @@
|
||||
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%;
|
||||
@@ -519,44 +514,3 @@
|
||||
@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,7 +6,6 @@
|
||||
[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*]]
|
||||
@@ -116,26 +115,6 @@
|
||||
: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,32 +205,3 @@
|
||||
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,7 +18,6 @@ $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);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.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*]]
|
||||
@@ -51,11 +52,10 @@
|
||||
:has-hint has-hint
|
||||
:hint-type hint-type
|
||||
:variant variant})]
|
||||
|
||||
[:div {:class [class (stl/css-case :input-wrapper true
|
||||
:variant-dense (= variant "dense")
|
||||
:variant-comfortable (= variant "comfortable")
|
||||
:has-hint has-hint)]}
|
||||
[:div {:class (dm/str 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,3 +64,4 @@
|
||||
:class hint-class
|
||||
:message hint-message
|
||||
:type hint-type}])]))
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
: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,7 +120,3 @@
|
||||
color: var(--color-foreground-secondary);
|
||||
min-inline-size: var(--sp-l);
|
||||
}
|
||||
|
||||
.tooltip-wrapper {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
(: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]
|
||||
@@ -48,23 +47,6 @@
|
||||
|
||||
[:> 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)
|
||||
@@ -97,4 +79,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]]))
|
||||
@@ -7,86 +7,42 @@
|
||||
(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.ds.foundations.assets.icon :as i :refer [icon*]]
|
||||
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.util.dom :as dom]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def ^:private schema:nitrate-form
|
||||
[:map {:title "NitrateForm"}
|
||||
[:subscription [::sm/one-of #{:monthly :yearly}]]])
|
||||
;; FIXME: rename to `form` (remove the nitrate prefix from namespace,
|
||||
;; because it is already under nitrate)
|
||||
|
||||
(mf/defc nitrate-form-modal*
|
||||
{::mf/register modal/components
|
||||
::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/register-as :nitrate-form}
|
||||
[]
|
||||
(let [on-click
|
||||
(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)))))))]
|
||||
(dom/open-new-window "/control-center/licenses/start")))]
|
||||
|
||||
[:div {:class (stl/css :modal-overlay)}
|
||||
[: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-container)}
|
||||
[:div {:class (stl/css :nitrate-form)}
|
||||
|
||||
[:div {:class (stl/css :modal-end)}
|
||||
[:div {:class (stl/css :modal-title)}
|
||||
"Unlock Nitrate Features"]
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h2 {:class (stl/css :modal-title)}
|
||||
"BUY NITRATE"]
|
||||
|
||||
[: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)}
|
||||
[:button {:class (stl/css :modal-close-btn)
|
||||
:on-click modal/hide!} deprecated-icon/close]]
|
||||
|
||||
[:& 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-content)}
|
||||
"Nitrate is so cool! You should buy it!"]
|
||||
|
||||
[: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"]]])]]]]))
|
||||
[:div {:class (stl/css :modal-footer)}
|
||||
[:div {:class (stl/css :action-buttons)}
|
||||
[:> button* {:variant "primary"
|
||||
:on-click on-click}
|
||||
"BUY NOW!"]]]]]]))
|
||||
|
||||
|
||||
|
||||
@@ -5,94 +5,48 @@
|
||||
// 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-dialog {
|
||||
.modal-container {
|
||||
@extend .modal-container-base;
|
||||
max-block-size: initial;
|
||||
min-inline-size: px2rem(648);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
@extend .modal-close-btn-base;
|
||||
.modal-header {
|
||||
margin-bottom: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include t.use-typography("title-large");
|
||||
margin-block-end: var(--sp-xxxl);
|
||||
@include deprecated.uppercaseTitleTipography;
|
||||
color: var(--modal-title-foreground-color);
|
||||
display: flex;
|
||||
gap: var(--sp-m);
|
||||
}
|
||||
|
||||
.modal-text-large {
|
||||
@include t.use-typography("body-large");
|
||||
.modal-close-btn {
|
||||
@extend .modal-close-btn-base;
|
||||
}
|
||||
|
||||
.modal-text-medium {
|
||||
@include t.use-typography("body-medium");
|
||||
.modal-content {
|
||||
margin-bottom: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.modal-text-small {
|
||||
@include t.use-typography("body-small");
|
||||
.nitrate-form {
|
||||
min-width: deprecated.$s-400;
|
||||
}
|
||||
|
||||
.modal-info {
|
||||
margin-block-start: var(--sp-s);
|
||||
width: 40%;
|
||||
.action-buttons {
|
||||
@extend .modal-action-btns;
|
||||
}
|
||||
|
||||
.modal-content,
|
||||
.modal-end {
|
||||
color: var(--color-foreground-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.cancel-button {
|
||||
@extend .modal-cancel-btn;
|
||||
}
|
||||
|
||||
.modal-success-content {
|
||||
display: flex;
|
||||
gap: $sz-40;
|
||||
}
|
||||
.accept-btn {
|
||||
@extend .modal-accept-btn;
|
||||
|
||||
.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;
|
||||
&.danger {
|
||||
@extend .modal-danger-btn;
|
||||
}
|
||||
}
|
||||
|
||||
.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]
|
||||
["/integrations" :settings-integrations]
|
||||
["/access-tokens" :settings-access-tokens]
|
||||
["/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-integrations
|
||||
[:> integrations-page*]
|
||||
:settings-access-tokens
|
||||
[:& access-tokens-page]
|
||||
|
||||
:settings-notifications
|
||||
[:& notifications-page* {:profile profile}])]]]]))
|
||||
|
||||
291
frontend/src/app/main/ui/settings/access_tokens.cljs
Normal file
291
frontend/src/app/main/ui/settings/access_tokens.cljs
Normal file
@@ -0,0 +1,291 @@
|
||||
;; 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)}])]])]))
|
||||
|
||||
202
frontend/src/app/main/ui/settings/access_tokens.scss
Normal file
202
frontend/src/app/main/ui/settings/access_tokens.scss
Normal file
@@ -0,0 +1,202 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,573 +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.integrations
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[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
|
||||
[:map
|
||||
[:name [::sm/text {:max 250}]]
|
||||
[:expiration-date [::sm/text {:max 250}]]])
|
||||
|
||||
(def form-initial-data
|
||||
{:name ""
|
||||
:expiration-date "never"})
|
||||
|
||||
(mf/defc token-created*
|
||||
{::mf/private true}
|
||||
[{:keys [title]}]
|
||||
(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}
|
||||
(tr "integrations.info.non-recuperable")]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :modal-token)}
|
||||
[:> input* {:type "text"
|
||||
:default-value (:token token-created "")
|
||||
:read-only true}]
|
||||
[:div {:class (stl/css :modal-token-button)}
|
||||
[:> icon-button* {:variant "secondary"
|
||||
:aria-label (tr "integrations.copy-token")
|
||||
:on-click on-copy-to-clipboard
|
||||
:icon i/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"))]]
|
||||
|
||||
[: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 form-initial-data
|
||||
:schema schema:form)
|
||||
|
||||
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"))]
|
||||
(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}
|
||||
info])
|
||||
|
||||
[: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 create-mcp-key-modal
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :create-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 "create-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.create-mcp-key.title.created")}]
|
||||
[:> create-token* {:title (tr "integrations.create-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")}]
|
||||
[:> 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 :create-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")]
|
||||
[:div {:class (stl/css :mcp-server-notification-line)}
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-medium
|
||||
:class (stl/css :color-primary)}
|
||||
cf/mcp-server-url]
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-medium
|
||||
:on-click on-copy-to-clipboard
|
||||
:class (stl/css :mcp-server-notification-link)}
|
||||
[:> icon* {:icon-id i/clipboard}] (tr "integrations.mcp-server.mcp-keys.copy")]]
|
||||
|
||||
[:> 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*])])
|
||||
@@ -1,221 +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;
|
||||
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/spacing.scss" as *;
|
||||
@use "ds/mixins.scss" as *;
|
||||
|
||||
.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;
|
||||
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);
|
||||
}
|
||||
|
||||
.modal-token {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-token-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 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-s);
|
||||
}
|
||||
|
||||
.mcp-server-notification-line {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--sp-m);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -43,8 +43,8 @@
|
||||
(def ^:private go-settings-subscription
|
||||
#(st/emit! (rt/nav :settings-subscription)))
|
||||
|
||||
(def ^:private go-settings-integrations
|
||||
#(st/emit! (rt/nav :settings-integrations)))
|
||||
(def ^:private go-settings-access-tokens
|
||||
#(st/emit! (rt/nav :settings-access-tokens)))
|
||||
|
||||
(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)
|
||||
integrations? (= section :settings-integrations)
|
||||
access-tokens? (= section :settings-access-tokens)
|
||||
notifications? (= section :settings-notifications)
|
||||
team-id (or (dtm/get-last-team-id)
|
||||
(:default-team-id profile))
|
||||
@@ -115,13 +115,12 @@
|
||||
:data-testid "settings-subscription"}
|
||||
[:span {:class (stl/css :element-title)} (tr "subscription.labels")]])
|
||||
|
||||
(when (or (contains? cf/flags :access-tokens)
|
||||
(contains? cf/flags :mcp))
|
||||
[:li {:class (stl/css-case :current integrations?
|
||||
(when (contains? cf/flags :access-tokens)
|
||||
[:li {:class (stl/css-case :current access-tokens?
|
||||
:settings-item true)
|
||||
:on-click go-settings-integrations
|
||||
:data-testid "settings-integrations"}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.integrations")]])
|
||||
:on-click go-settings-access-tokens
|
||||
:data-testid "settings-access-tokens"}
|
||||
[:span {:class (stl/css :element-title)} (tr "labels.access-tokens")]])
|
||||
|
||||
[:hr {:class (stl/css :sidebar-separator)}]
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
[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]
|
||||
@@ -417,13 +415,11 @@
|
||||
(fn [subscription-type current-subscription]
|
||||
(st/emit! (ev/event {::ev/name "open-subscription-modal"
|
||||
::ev/origin "settings:in-app"}))
|
||||
(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))})))))]
|
||||
(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")))
|
||||
@@ -566,7 +562,7 @@
|
||||
:recommended (= subscription-type "professional")
|
||||
:show-button-cta (= subscription-type "professional")}])
|
||||
|
||||
(when (and (not= subscription-type "enterprise") (not (contains? cf/flags :nitrate)))
|
||||
(when (not= subscription-type "enterprise")
|
||||
[:> plan-card* {:card-title (tr "subscription.settings.enterprise")
|
||||
:card-title-icon i/character-e
|
||||
:price-value "$950"
|
||||
@@ -579,104 +575,5 @@
|
||||
: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")}])
|
||||
|
||||
;; 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"]]])]]))
|
||||
|
||||
|
||||
:show-button-cta (= subscription-type "professional")}])]]]))
|
||||
|
||||
|
||||
@@ -329,22 +329,3 @@
|
||||
.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,9 +150,7 @@
|
||||
{::mf/props :obj
|
||||
::mf/private true}
|
||||
[{:keys [shapes]}]
|
||||
(let [multiple? (> (count shapes) 1)
|
||||
|
||||
do-copy #(st/emit! (dw/copy-selected))
|
||||
(let [do-copy #(st/emit! (dw/copy-selected))
|
||||
do-copy-link #(st/emit! (dw/copy-link-to-clipboard))
|
||||
|
||||
do-cut #(st/emit! (dw/copy-selected)
|
||||
@@ -180,9 +178,6 @@
|
||||
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 []
|
||||
@@ -227,11 +222,6 @@
|
||||
[:> 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")
|
||||
@@ -239,7 +229,7 @@
|
||||
|
||||
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-props")
|
||||
:shortcut (sc/get-tooltip :copy-props)
|
||||
:disabled multiple?
|
||||
:disabled (> (count shapes) 1)
|
||||
:on-click handle-copy-props}]
|
||||
[:> menu-entry* {:title (tr "workspace.shape.menu.paste-props")
|
||||
:shortcut (sc/get-tooltip :paste-props)
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
[app.main.data.workspace.media :as dwm]
|
||||
[app.main.data.workspace.path :as dwdp]
|
||||
[app.main.data.workspace.specialized-panel :as-alias dwsp]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
@@ -50,41 +49,42 @@
|
||||
(mf/deps id blocked hidden type selected edition drawing-tool text-editing?
|
||||
node-editing? grid-editing? drawing-path? create-comment? @z? @space?
|
||||
panning read-only?)
|
||||
(fn [bevent]
|
||||
(fn [event]
|
||||
;; We need to handle editor related stuff here because
|
||||
;; handling on editor dom node does not works properly.
|
||||
(let [target (dom/get-target bevent)
|
||||
(let [target (dom/get-target event)
|
||||
editor (txu/closest-text-editor-content target)]
|
||||
;; Capture mouse pointer to detect the movements even if cursor
|
||||
;; leaves the viewport or the browser itself
|
||||
;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture
|
||||
(if editor
|
||||
(.setPointerCapture editor (.-pointerId bevent))
|
||||
(.setPointerCapture target (.-pointerId bevent))))
|
||||
(.setPointerCapture editor (.-pointerId event))
|
||||
(.setPointerCapture target (.-pointerId event))))
|
||||
|
||||
(when (or (dom/class? (dom/get-target bevent) "viewport-controls")
|
||||
(dom/class? (dom/get-target bevent) "viewport-selrect")
|
||||
(dom/child? (dom/get-target bevent) (dom/query ".grid-layout-editor")))
|
||||
(when (or (dom/class? (dom/get-target event) "viewport-controls")
|
||||
(dom/class? (dom/get-target event) "viewport-selrect")
|
||||
(dom/child? (dom/get-target event) (dom/query ".grid-layout-editor")))
|
||||
|
||||
(dom/stop-propagation bevent)
|
||||
(dom/stop-propagation event)
|
||||
|
||||
(when-not @z?
|
||||
(let [event (dom/event->native-event bevent)
|
||||
ctrl? (kbd/ctrl? event)
|
||||
meta? (kbd/meta? event)
|
||||
shift? (kbd/shift? event)
|
||||
alt? (kbd/alt? event)
|
||||
mod? (kbd/mod? event)
|
||||
(let [native-event (dom/event->native-event event)
|
||||
ctrl? (kbd/ctrl? native-event)
|
||||
meta? (kbd/meta? native-event)
|
||||
shift? (kbd/shift? native-event)
|
||||
alt? (kbd/alt? native-event)
|
||||
mod? (kbd/mod? native-event)
|
||||
off-pt (dom/get-offset-position native-event)
|
||||
|
||||
left-click? (and (not panning) (dom/left-mouse? bevent))
|
||||
middle-click? (and (not panning) (dom/middle-mouse? bevent))]
|
||||
left-click? (and (not panning) (dom/left-mouse? event))
|
||||
middle-click? (and (not panning) (dom/middle-mouse? event))]
|
||||
|
||||
(cond
|
||||
(or middle-click? (and left-click? @space?))
|
||||
(do
|
||||
(dom/prevent-default bevent)
|
||||
(dom/prevent-default event)
|
||||
(if mod?
|
||||
(let [raw-pt (dom/get-client-position event)
|
||||
(let [raw-pt (dom/get-client-position native-event)
|
||||
pt (uwvv/point->viewport raw-pt)]
|
||||
(st/emit! (dw/start-zooming pt)))
|
||||
(st/emit! (dw/start-panning))))
|
||||
@@ -94,18 +94,23 @@
|
||||
(st/emit! (mse/->MouseEvent :down ctrl? shift? alt? meta?)
|
||||
::dwsp/interrupt)
|
||||
|
||||
(when (wasm.api/text-editor-is-active?)
|
||||
(wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt)))
|
||||
|
||||
(when (and (not= edition id) (or text-editing? grid-editing?))
|
||||
(st/emit! (dw/clear-edition-mode))
|
||||
;; FIXME: I think this is not completely correct because this
|
||||
;; is going to happen even when clicking or selecting text.
|
||||
;; Sync and stop WASM text editor when exiting edit mode
|
||||
(when (and text-editing?
|
||||
(features/active-feature? @st/state "render-wasm/v1")
|
||||
wasm.wasm/context-initialized?)
|
||||
(when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
|
||||
(st/emit! (dwt/v2-update-text-shape-content
|
||||
shape-id content
|
||||
:update-name? true
|
||||
:finalize? true)))
|
||||
(wasm.api/text-editor-stop)))
|
||||
#_(when (and text-editing?
|
||||
(features/active-feature? @st/state "render-wasm/v1")
|
||||
wasm.wasm/context-initialized?)
|
||||
(when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
|
||||
(st/emit! (dwt/v2-update-text-shape-content
|
||||
shape-id content
|
||||
:update-name? true
|
||||
:finalize? true)))
|
||||
(wasm.api/text-editor-stop)))
|
||||
|
||||
(when (and (not text-editing?)
|
||||
(not blocked)
|
||||
@@ -187,10 +192,14 @@
|
||||
alt? (kbd/alt? event)
|
||||
meta? (kbd/meta? event)
|
||||
hovering? (some? @hover)
|
||||
native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)
|
||||
raw-pt (dom/get-client-position event)
|
||||
pt (uwvv/point->viewport raw-pt)]
|
||||
(st/emit! (mse/->MouseEvent :click ctrl? shift? alt? meta?))
|
||||
|
||||
;; FIXME: Maybe we can transform this into a cond instead
|
||||
;; of multiple (when)s.
|
||||
(when (and hovering?
|
||||
(not @space?)
|
||||
(not edition)
|
||||
@@ -198,6 +207,8 @@
|
||||
(not drawing-tool))
|
||||
(st/emit! (dw/select-shape (:id @hover) shift?)))
|
||||
|
||||
;; FIXME: Maybe we can move into a function of the kind
|
||||
;; "text-editor-on-click"
|
||||
;; If clicking on a text shape and wasm render is enabled, forward cursor position
|
||||
(when (and hovering?
|
||||
(not @space?)
|
||||
@@ -208,9 +219,7 @@
|
||||
(when (and (= :text (:type hover-shape))
|
||||
(features/active-feature? @st/state "text-editor-wasm/v1")
|
||||
wasm.wasm/context-initialized?)
|
||||
(let [raw-pt (dom/get-client-position event)]
|
||||
;; FIXME
|
||||
(wasm.api/text-editor-set-cursor-from-point (.-x raw-pt) (.-y raw-pt))))))
|
||||
(wasm.api/text-editor-set-cursor-from-point (.-x off-pt) (.-y off-pt)))))
|
||||
|
||||
(when (and @z?
|
||||
(not @space?)
|
||||
@@ -261,6 +270,12 @@
|
||||
wasm.wasm/context-initialized?)
|
||||
(wasm.api/text-editor-start id)))
|
||||
|
||||
(and editable? (= id edition) (not read-only?)
|
||||
(= type :text)
|
||||
(features/active-feature? @st/state "text-editor-wasm/v1")
|
||||
wasm.wasm/context-initialized?)
|
||||
(wasm.api/text-editor-select-all)
|
||||
|
||||
(some? selected-shape)
|
||||
(do
|
||||
(reset! hover selected-shape)
|
||||
@@ -310,20 +325,24 @@
|
||||
;; Release pointer on mouse up
|
||||
(.releasePointerCapture target (.-pointerId event)))
|
||||
|
||||
(let [event (dom/event->native-event event)
|
||||
ctrl? (kbd/ctrl? event)
|
||||
shift? (kbd/shift? event)
|
||||
alt? (kbd/alt? event)
|
||||
meta? (kbd/meta? event)
|
||||
(let [native-event (dom/event->native-event event)
|
||||
off-pt (dom/get-offset-position native-event)
|
||||
ctrl? (kbd/ctrl? native-event)
|
||||
shift? (kbd/shift? native-event)
|
||||
alt? (kbd/alt? native-event)
|
||||
meta? (kbd/meta? native-event)
|
||||
|
||||
left-click? (= 1 (.-which event))
|
||||
middle-click? (= 2 (.-which event))]
|
||||
left-click? (= 1 (.-which native-event))
|
||||
middle-click? (= 2 (.-which native-event))]
|
||||
|
||||
(when left-click?
|
||||
(st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?)))
|
||||
(st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?))
|
||||
|
||||
(when (wasm.api/text-editor-is-active?)
|
||||
(wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt))))
|
||||
|
||||
(when middle-click?
|
||||
(dom/prevent-default event)
|
||||
(dom/prevent-default native-event)
|
||||
|
||||
;; We store this so in Firefox the middle button won't do a paste of the content
|
||||
(mf/set-ref-val! disable-paste-ref true)
|
||||
@@ -381,7 +400,9 @@
|
||||
(let [last-position (mf/use-var nil)]
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(let [raw-pt (dom/get-client-position event)
|
||||
(let [native-event (unchecked-get event "nativeEvent")
|
||||
off-pt (dom/get-offset-position native-event)
|
||||
raw-pt (dom/get-client-position event)
|
||||
pt (uwvv/point->viewport raw-pt)
|
||||
|
||||
;; We calculate the delta because Safari's MouseEvent.movementX/Y drop
|
||||
@@ -390,6 +411,12 @@
|
||||
(gpt/subtract raw-pt @last-position)
|
||||
(gpt/point 0 0))]
|
||||
|
||||
;; IMPORTANT! This function, right now it's called on EVERY pointermove. I think
|
||||
;; in the future (when we handle the UI in the render) should be better to
|
||||
;; have a "wasm.api/pointer-move" function that works as an entry point for
|
||||
;; all the pointer-move events.
|
||||
(wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt))
|
||||
|
||||
(rx/push! move-stream pt)
|
||||
(reset! last-position raw-pt)
|
||||
(st/emit! (mse/->PointerEvent :delta delta
|
||||
|
||||
@@ -261,39 +261,7 @@
|
||||
:else
|
||||
(let [child-id (obj/get child "$id")]
|
||||
(st/emit! (dwt/move-shapes-to-frame #{child-id} id nil nil)
|
||||
(ptk/data-event :layout/update {:ids [id]})))))
|
||||
|
||||
:horizontalSizing
|
||||
{:this true
|
||||
:get #(-> % u/proxy->shape :layout-item-h-sizing (d/nilv :fix) d/name)
|
||||
:set
|
||||
(fn [_ value]
|
||||
(let [value (keyword value)]
|
||||
(cond
|
||||
(not (contains? ctl/item-h-sizing-types value))
|
||||
(u/display-not-valid :horizontalPadding value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-h-sizing value})))))}
|
||||
|
||||
:verticalSizing
|
||||
{:this true
|
||||
:get #(-> % u/proxy->shape :layout-item-v-sizing (d/nilv :fix) d/name)
|
||||
:set
|
||||
(fn [_ value]
|
||||
(let [value (keyword value)]
|
||||
(cond
|
||||
(not (contains? ctl/item-v-sizing-types value))
|
||||
(u/display-not-valid :verticalSizing value)
|
||||
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-v-sizing value})))))}))
|
||||
(ptk/data-event :layout/update {:ids [id]})))))))
|
||||
|
||||
|
||||
(defn layout-child-proxy? [p]
|
||||
|
||||
@@ -598,10 +598,3 @@
|
||||
(case axis
|
||||
:y "horizontal"
|
||||
:x "vertical"))
|
||||
|
||||
(defn format-geom-rect
|
||||
[{:keys [x y width height]}]
|
||||
#js {:x x
|
||||
:y y
|
||||
:width width
|
||||
:height height})
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
[app.util.object :as obj]
|
||||
[beicon.v2.core :as rx]))
|
||||
|
||||
;; Needs to be here because moving it to `app.main.data.workspace.mcp` will
|
||||
;; cause a circular dependency
|
||||
(def mcp-plugin-id "96dfa740-005d-8020-8007-55ede24a2bae")
|
||||
|
||||
;; Stores the installed plugins information
|
||||
(defonce ^:private registry (atom {}))
|
||||
|
||||
@@ -131,6 +127,5 @@
|
||||
(defn check-permission
|
||||
[plugin-id permission]
|
||||
(or (= plugin-id "00000000-0000-0000-0000-000000000000")
|
||||
(= plugin-id mcp-plugin-id)
|
||||
(let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])]
|
||||
(contains? permissions permission))))
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.geom.shapes.text :as gst]
|
||||
[app.common.record :as crc]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.shape :as cts]
|
||||
@@ -652,7 +651,4 @@
|
||||
(u/display-not-valid :verticalAlign "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwt/update-attrs id {:vertical-align value})))))}
|
||||
|
||||
{:name "textBounds"
|
||||
:get #(-> % u/proxy->shape gst/shape->bounds format/format-geom-rect)}))
|
||||
(st/emit! (dwt/update-attrs id {:vertical-align value})))))}))
|
||||
|
||||
@@ -87,7 +87,11 @@
|
||||
(def text-editor-start text-editor/text-editor-start)
|
||||
(def text-editor-stop text-editor/text-editor-stop)
|
||||
(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
|
||||
(def text-editor-pointer-down text-editor/text-editor-pointer-down)
|
||||
(def text-editor-pointer-move text-editor/text-editor-pointer-move)
|
||||
(def text-editor-pointer-up text-editor/text-editor-pointer-up)
|
||||
(def text-editor-is-active? text-editor/text-editor-is-active?)
|
||||
(def text-editor-select-all text-editor/text-editor-select-all)
|
||||
(def text-editor-sync-content text-editor/text-editor-sync-content)
|
||||
|
||||
(def dpr
|
||||
@@ -263,22 +267,6 @@
|
||||
[attrs]
|
||||
(text-editor/apply-style-to-selection attrs use-shape set-shape-text-content))
|
||||
|
||||
(defn update-text-rect!
|
||||
[id]
|
||||
(when wasm/context-initialized?
|
||||
(mw/emit!
|
||||
{:cmd :index/update-text-rect
|
||||
:page-id (:current-page-id @st/state)
|
||||
:shape-id id
|
||||
:dimensions (get-text-dimensions id)})))
|
||||
|
||||
(defn- ensure-text-content
|
||||
"Guarantee that the shape always sends a valid text tree to WASM. When the
|
||||
content is nil (freshly created text) we fall back to
|
||||
tc/default-text-content so the renderer receives typography information."
|
||||
[content]
|
||||
(or content (tc/v2-default-text-content)))
|
||||
|
||||
(defn set-parent-id
|
||||
[id]
|
||||
(let [buffer (uuid/get-u32 id)]
|
||||
@@ -996,6 +984,22 @@
|
||||
(render-finish)
|
||||
(perf/end-measure "set-view-box::zoom")))))
|
||||
|
||||
(defn update-text-rect!
|
||||
[id]
|
||||
(when wasm/context-initialized?
|
||||
(mw/emit!
|
||||
{:cmd :index/update-text-rect
|
||||
:page-id (:current-page-id @st/state)
|
||||
:shape-id id
|
||||
:dimensions (get-text-dimensions id)})))
|
||||
|
||||
(defn- ensure-text-content
|
||||
"Guarantee that the shape always sends a valid text tree to WASM. When the
|
||||
content is nil (freshly created text) we fall back to
|
||||
tc/default-text-content so the renderer receives typography information."
|
||||
[content]
|
||||
(or content (tc/v2-default-text-content)))
|
||||
|
||||
(defn set-object
|
||||
[shape]
|
||||
(perf/begin-measure "set-object")
|
||||
|
||||
@@ -27,6 +27,21 @@
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
|
||||
|
||||
(defn text-editor-pointer-down
|
||||
[x y]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_pointer_down" x y)))
|
||||
|
||||
(defn text-editor-pointer-move
|
||||
[x y]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_pointer_move" x y)))
|
||||
|
||||
(defn text-editor-pointer-up
|
||||
[x y]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_pointer_up" x y)))
|
||||
|
||||
(defn text-editor-update-blink
|
||||
[timestamp-ms]
|
||||
(when wasm/context-initialized?
|
||||
@@ -83,9 +98,12 @@
|
||||
(h/call wasm/internal-module "_text_editor_stop")))
|
||||
|
||||
(defn text-editor-is-active?
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(not (zero? (h/call wasm/internal-module "_text_editor_is_active")))))
|
||||
([id]
|
||||
(when wasm/context-initialized?
|
||||
(not (zero? (h/call wasm/internal-module "_text_editor_is_active_with_id" id)))))
|
||||
([]
|
||||
(when wasm/context-initialized?
|
||||
(not (zero? (h/call wasm/internal-module "_text_editor_is_active"))))))
|
||||
|
||||
(defn text-editor-export-content
|
||||
[]
|
||||
|
||||
@@ -230,6 +230,12 @@
|
||||
|
||||
(def get-target-scroll (comp get-scroll-position get-target))
|
||||
|
||||
(defn click
|
||||
"Click a node"
|
||||
[^js node]
|
||||
(when (some? node)
|
||||
(.click node)))
|
||||
|
||||
(defn get-files
|
||||
"Extract the files from dom node."
|
||||
[^js node]
|
||||
@@ -470,7 +476,7 @@
|
||||
(when (some? node)
|
||||
(.focus node)))
|
||||
|
||||
(defn click
|
||||
(defn click!
|
||||
[^js node]
|
||||
(when (some? node)
|
||||
(.click node)))
|
||||
@@ -742,11 +748,7 @@
|
||||
|
||||
(defn trigger-download
|
||||
[filename blob]
|
||||
(let [uri (wapi/create-uri blob)]
|
||||
(try
|
||||
(trigger-download-uri filename (.-type ^js blob) uri)
|
||||
(finally
|
||||
(wapi/revoke-uri uri)))))
|
||||
(trigger-download-uri filename (.-type ^js blob) (wapi/create-uri blob)))
|
||||
|
||||
(defn event
|
||||
"Create an instance of DOM Event"
|
||||
|
||||
@@ -190,11 +190,6 @@
|
||||
[{:keys [status]}]
|
||||
(<= 400 status 499))
|
||||
|
||||
(defn blob?
|
||||
[^js v]
|
||||
(when (some? v)
|
||||
(instance? js/Blob v)))
|
||||
|
||||
(defn as-promise
|
||||
[observable]
|
||||
(p/create
|
||||
|
||||
@@ -464,13 +464,6 @@
|
||||
(let [o (get o type-symbol)]
|
||||
(= o t))))
|
||||
|
||||
#?(:cljs
|
||||
(def Proxy
|
||||
(app.util.object/class
|
||||
:name "Proxy"
|
||||
:extends js/Object
|
||||
:constructor (constantly nil))))
|
||||
|
||||
(defmacro reify
|
||||
"A domain specific variation of reify that creates anonymous objects
|
||||
on demand with the ability to assign protocol implementations and
|
||||
@@ -488,7 +481,7 @@
|
||||
obj-sym
|
||||
(gensym "obj-")]
|
||||
|
||||
`(let [~obj-sym (new Proxy)
|
||||
`(let [~obj-sym (cljs.core/js-obj)
|
||||
~f-sym (fn [] ~type-name)]
|
||||
(add-properties! ~obj-sym
|
||||
{:name ~'js/Symbol.toStringTag
|
||||
|
||||
@@ -76,3 +76,4 @@ export function getFills(fillStyle) {
|
||||
const [color, opacity] = getColor(fillStyle);
|
||||
return `[["^ ","~:fill-color","${color}","~:fill-opacity",${opacity}]]`;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,12 +162,15 @@ class TextEditorPlayground {
|
||||
}
|
||||
|
||||
this.#module.call("use_shape", ...textShape.id);
|
||||
// FIXME: This function doesn't exists anymore.
|
||||
/*
|
||||
const caretPosition = this.#module.call(
|
||||
"get_caret_position_at",
|
||||
e.offsetX,
|
||||
e.offsetY,
|
||||
);
|
||||
console.log("caretPosition", caretPosition);
|
||||
*/
|
||||
};
|
||||
|
||||
#onResize = (_entries) => {
|
||||
|
||||
@@ -338,6 +338,77 @@ msgstr "You're going to restore %s."
|
||||
msgid "dashboard-restore-file-confirmation.title"
|
||||
msgstr "Restore file"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:103
|
||||
msgid "dashboard.access-tokens.copied-success"
|
||||
msgstr "Copied token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:189
|
||||
msgid "dashboard.access-tokens.create"
|
||||
msgstr "Generate new token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:64
|
||||
msgid "dashboard.access-tokens.create.success"
|
||||
msgstr "Access token created successfully."
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:286
|
||||
msgid "dashboard.access-tokens.empty.add-one"
|
||||
msgstr "Press the button \"Generate new token\" to generate one."
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:285
|
||||
msgid "dashboard.access-tokens.empty.no-access-tokens"
|
||||
msgstr "You have no tokens so far."
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:135
|
||||
msgid "dashboard.access-tokens.expiration-180-days"
|
||||
msgstr "180 days"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:132
|
||||
msgid "dashboard.access-tokens.expiration-30-days"
|
||||
msgstr "30 days"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:133
|
||||
msgid "dashboard.access-tokens.expiration-60-days"
|
||||
msgstr "60 days"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:134
|
||||
msgid "dashboard.access-tokens.expiration-90-days"
|
||||
msgstr "90 days"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:131
|
||||
msgid "dashboard.access-tokens.expiration-never"
|
||||
msgstr "Never"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:268
|
||||
msgid "dashboard.access-tokens.expired-on"
|
||||
msgstr "Expired on %s"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:269
|
||||
msgid "dashboard.access-tokens.expires-on"
|
||||
msgstr "Expires on %s"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:267
|
||||
msgid "dashboard.access-tokens.no-expiration"
|
||||
msgstr "No expiration date"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:184
|
||||
msgid "dashboard.access-tokens.personal"
|
||||
msgstr "Personal access tokens"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:185
|
||||
msgid "dashboard.access-tokens.personal.description"
|
||||
msgstr ""
|
||||
"Personal access tokens function like an alternative to our login/password "
|
||||
"authentication system and can be used to allow an application to access the "
|
||||
"internal Penpot API"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:142
|
||||
msgid "dashboard.access-tokens.token-will-expire"
|
||||
msgstr "The token will expire on %s"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:143
|
||||
msgid "dashboard.access-tokens.token-will-not-expire"
|
||||
msgstr "The token has no expiration date"
|
||||
|
||||
#: src/app/main/ui/dashboard/placeholder.cljs:41
|
||||
msgid "dashboard.add-file"
|
||||
msgstr "Add file"
|
||||
@@ -360,7 +431,7 @@ msgstr "(copy)"
|
||||
|
||||
#: src/app/main/ui/dashboard/sidebar.cljs:347
|
||||
msgid "dashboard.create-new-org"
|
||||
msgstr "+ Create org"
|
||||
msgstr "Create new org"
|
||||
|
||||
#: src/app/main/ui/dashboard/sidebar.cljs:340
|
||||
msgid "dashboard.create-new-team"
|
||||
@@ -2063,209 +2134,6 @@ msgstr "Resolved value:"
|
||||
msgid "inspect.tabs.styles.variants-panel"
|
||||
msgstr "Variant Properties"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:189
|
||||
msgid "integrations.access-tokens.create"
|
||||
msgstr "Create new access token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:286
|
||||
msgid "integrations.access-tokens.empty.add-one"
|
||||
msgstr "Press the button \"Create new access token\" to generate one."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:285
|
||||
msgid "integrations.access-tokens.empty.no-access-tokens"
|
||||
msgstr "You have no tokens so far."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:184
|
||||
msgid "integrations.access-tokens.personal"
|
||||
msgstr "Personal access tokens"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:185
|
||||
msgid "integrations.access-tokens.personal.description"
|
||||
msgstr ""
|
||||
"Personal access tokens function like an alternative to our login/password "
|
||||
"authentication system and can be used to allow an application to access the "
|
||||
"internal Penpot API"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:152, src/app/main/ui/settings/integrations.cljs:158
|
||||
msgid "integrations.copy-token"
|
||||
msgstr "Copy token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:432
|
||||
msgid "integrations.create-access-token.title"
|
||||
msgstr "Create access token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:433
|
||||
msgid "integrations.create-access-token.title.created"
|
||||
msgstr "Access token created"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:290
|
||||
msgid "integrations.create-mcp-key.title"
|
||||
msgstr "Create new MCP key"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:291
|
||||
msgid "integrations.create-mcp-key.title.created"
|
||||
msgstr "MCP key created"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:257
|
||||
msgid "integrations.delete-token.accept"
|
||||
msgstr "Delete token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:256
|
||||
msgid "integrations.delete-token.message"
|
||||
msgstr "Are you sure you want to delete this token?"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:255
|
||||
msgid "integrations.delete-token.title"
|
||||
msgstr "Delete token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:135
|
||||
msgid "integrations.expiration-180-days"
|
||||
msgstr "180 days"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:132
|
||||
msgid "integrations.expiration-30-days"
|
||||
msgstr "30 days"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:133
|
||||
msgid "integrations.expiration-60-days"
|
||||
msgstr "60 days"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:134
|
||||
msgid "integrations.expiration-90-days"
|
||||
msgstr "90 days"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:131
|
||||
msgid "integrations.expiration-never"
|
||||
msgstr "Never"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:268
|
||||
msgid "integrations.expired-on"
|
||||
msgstr "Expired on %s"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:269
|
||||
msgid "integrations.expires-on"
|
||||
msgstr "Expires on %s"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:267
|
||||
msgid "integrations.no-expiration"
|
||||
msgstr "No expiration date"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:130
|
||||
msgid "integrations.expiration-date.label"
|
||||
msgstr "Expiration date"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:131
|
||||
msgid "integrations.info.non-recuperable"
|
||||
msgstr "This unique token is non-recuperable. If you lose it, you will need to create a new one."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:336
|
||||
msgid "integrations.mcp-server.title"
|
||||
msgstr "MCP Server"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:336
|
||||
msgid "integrations.mcp-server.title.beta"
|
||||
msgstr "Beta"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:347
|
||||
msgid "integrations.mcp-server.description"
|
||||
msgstr "The Penpot MCP Server enables MCP clients to interact directly with Penpot design files."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:353
|
||||
msgid "integrations.mcp-server.status"
|
||||
msgstr "Status"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:370
|
||||
msgid "integrations.mcp-server.status.disabled"
|
||||
msgstr "Disabled"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:370
|
||||
msgid "integrations.mcp-server.status.enabled"
|
||||
msgstr "Enabled"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:363
|
||||
msgid "integrations.mcp-server.status.expired.0"
|
||||
msgstr "The MCP key used to connect to the MCP server has expired. As a result, the connection cannot be established."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:368
|
||||
msgid "integrations.mcp-server.status.expired.1"
|
||||
msgstr "Please regenerate the MCP key and update your client configuration with the new key."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:415
|
||||
msgid "integrations.mcp-server.mcp-keys.copy"
|
||||
msgstr "Copy link"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:422
|
||||
msgid "integrations.mcp-server.mcp-keys.help"
|
||||
msgstr "How to configure MCP clients"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:405
|
||||
msgid "integrations.mcp-server.mcp-keys.info"
|
||||
msgstr "This is the server url you'll need to configure your MCP client in order to connect it to the Penpot MCP server."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:387
|
||||
msgid "integrations.mcp-server.mcp-keys.regenerate"
|
||||
msgstr "Regenerate MCP key"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:381
|
||||
msgid "integrations.mcp-server.mcp-keys.title"
|
||||
msgstr "MCP key"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:388
|
||||
msgid "integrations.mcp-server.mcp-keys.tootip"
|
||||
msgstr "The MCP key is needed for the MCP client set up"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:124
|
||||
msgid "integrations.name.label"
|
||||
msgstr "Name"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:126
|
||||
msgid "integrations.name.placeholder"
|
||||
msgstr "The name can help to know what's the token for"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:103
|
||||
msgid "integrations.notification.success.copied"
|
||||
msgstr "Copied token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:64
|
||||
msgid "integrations.notification.success.created"
|
||||
msgstr "Token created successfully"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:327
|
||||
msgid "integrations.notification.success.copied-link"
|
||||
msgstr "Link copied to clipboard"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:293
|
||||
msgid "integrations.notification.success.mcp-server-disabled"
|
||||
msgstr "MCP server disabled"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:299
|
||||
msgid "integrations.notification.success.mcp-server-enabled"
|
||||
msgstr "MCP server enabled"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:317
|
||||
msgid "integrations.regenerate-mcp-key.info"
|
||||
msgstr "Regenerating the key will immediately revoke the current one. Any application using it will stop working."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:317
|
||||
msgid "integrations.regenerate-mcp-key.title"
|
||||
msgstr "Regenerate MCP key"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:318
|
||||
msgid "integrations.regenerate-mcp-key.title.created"
|
||||
msgstr "MCP key regenerated"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:480
|
||||
msgid "integrations.title"
|
||||
msgstr "Integrations"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:142
|
||||
msgid "integrations.token-will-expire"
|
||||
msgstr "The token will expire on %s"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:143
|
||||
msgid "integrations.token-will-not-expire"
|
||||
msgstr "The token has no expiration date"
|
||||
|
||||
#: src/app/main/ui/dashboard/comments.cljs:96
|
||||
msgid "label.mark-all-as-read"
|
||||
msgstr "Mark all as read"
|
||||
@@ -2486,9 +2354,6 @@ msgstr "Discard"
|
||||
msgid "labels.download"
|
||||
msgstr "Download %s"
|
||||
|
||||
msgid "labels.download-simple"
|
||||
msgstr "Download"
|
||||
|
||||
#: src/app/main/ui/dashboard/file_menu.cljs:30, src/app/main/ui/dashboard/files.cljs:80, src/app/main/ui/dashboard/files.cljs:179, src/app/main/ui/dashboard/projects.cljs:229, src/app/main/ui/dashboard/projects.cljs:233, src/app/main/ui/dashboard/sidebar.cljs:820
|
||||
msgid "labels.drafts"
|
||||
msgstr "Drafts"
|
||||
@@ -2620,10 +2485,6 @@ msgstr "Info"
|
||||
msgid "labels.installed-fonts"
|
||||
msgstr "Installed fonts"
|
||||
|
||||
#: src/app/main/ui/settings/sidebar.cljs:123
|
||||
msgid "labels.integrations"
|
||||
msgstr "Integrations"
|
||||
|
||||
#: src/app/main/ui/static.cljs:405
|
||||
msgid "labels.internal-error.desc-message-first"
|
||||
msgstr "Something bad happened."
|
||||
@@ -3283,6 +3144,30 @@ msgstr "Change email"
|
||||
msgid "modals.change-email.title"
|
||||
msgstr "Change your email"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:152, src/app/main/ui/settings/access_tokens.cljs:158
|
||||
msgid "modals.create-access-token.copy-token"
|
||||
msgstr "Copy token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:130
|
||||
msgid "modals.create-access-token.expiration-date.label"
|
||||
msgstr "Expiration date"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:124
|
||||
msgid "modals.create-access-token.name.label"
|
||||
msgstr "Name"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:126
|
||||
msgid "modals.create-access-token.name.placeholder"
|
||||
msgstr "The name can help to know what's the token for"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:178
|
||||
msgid "modals.create-access-token.submit-label"
|
||||
msgstr "Create token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:111
|
||||
msgid "modals.create-access-token.title"
|
||||
msgstr "Generate access token"
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs:1127
|
||||
msgid "modals.create-webhook.submit-label"
|
||||
msgstr "Create webhook"
|
||||
@@ -3299,6 +3184,18 @@ msgstr "Payload URL"
|
||||
msgid "modals.create-webhook.url.placeholder"
|
||||
msgstr "https://example.com/postreceive"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:257
|
||||
msgid "modals.delete-acces-token.accept"
|
||||
msgstr "Delete token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:256
|
||||
msgid "modals.delete-acces-token.message"
|
||||
msgstr "Are you sure you want to delete this token?"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:255
|
||||
msgid "modals.delete-acces-token.title"
|
||||
msgstr "Delete token"
|
||||
|
||||
#: src/app/main/ui/settings/delete_account.cljs:56
|
||||
msgid "modals.delete-account.cancel"
|
||||
msgstr "Cancel and keep my account"
|
||||
@@ -5192,14 +5089,14 @@ msgstr "Shared Libraries - %s - Penpot"
|
||||
msgid "title.default"
|
||||
msgstr "Penpot - Design Freedom for Teams"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:278
|
||||
msgid "title.settings.access-tokens"
|
||||
msgstr "Profile - Access tokens"
|
||||
|
||||
#: src/app/main/ui/settings/feedback.cljs:161
|
||||
msgid "title.settings.feedback"
|
||||
msgstr "Give feedback - Penpot"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:278
|
||||
msgid "title.settings.integrations"
|
||||
msgstr "Integrations - Penpot"
|
||||
|
||||
#: src/app/main/ui/settings/notifications.cljs:45
|
||||
msgid "title.settings.notifications"
|
||||
msgstr "Notifications - Penpot"
|
||||
@@ -7636,18 +7533,6 @@ msgstr "Paste"
|
||||
msgid "workspace.shape.menu.paste-props"
|
||||
msgstr "Paste properties"
|
||||
|
||||
msgid "workspace.shape.menu.copy-as-image"
|
||||
msgstr "Copy as image"
|
||||
|
||||
msgid "workspace.clipboard.copying"
|
||||
msgstr "Copying image…"
|
||||
|
||||
msgid "workspace.clipboard.image-copied"
|
||||
msgstr "Image copied to the clipboard"
|
||||
|
||||
msgid "workspace.clipboard.image-copy-failed"
|
||||
msgstr "Error copying image"
|
||||
|
||||
#: src/app/main/ui/workspace/context_menu.cljs:443
|
||||
msgid "workspace.shape.menu.path"
|
||||
msgstr "Path"
|
||||
|
||||
@@ -347,6 +347,77 @@ msgstr "Vas a restaurar %s."
|
||||
msgid "dashboard-restore-file-confirmation.title"
|
||||
msgstr "Restaurar archivo"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:103
|
||||
msgid "dashboard.access-tokens.copied-success"
|
||||
msgstr "Token copiado"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:189
|
||||
msgid "dashboard.access-tokens.create"
|
||||
msgstr "Generar nuevo token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:64
|
||||
msgid "dashboard.access-tokens.create.success"
|
||||
msgstr "Access token creado con éxito."
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:286
|
||||
msgid "dashboard.access-tokens.empty.add-one"
|
||||
msgstr "Pulsa el botón \"Generar nuevo token\" para generar uno."
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:285
|
||||
msgid "dashboard.access-tokens.empty.no-access-tokens"
|
||||
msgstr "Todavía no tienes ningún token."
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:135
|
||||
msgid "dashboard.access-tokens.expiration-180-days"
|
||||
msgstr "180 días"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:132
|
||||
msgid "dashboard.access-tokens.expiration-30-days"
|
||||
msgstr "30 días"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:133
|
||||
msgid "dashboard.access-tokens.expiration-60-days"
|
||||
msgstr "60 días"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:134
|
||||
msgid "dashboard.access-tokens.expiration-90-days"
|
||||
msgstr "90 días"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:131
|
||||
msgid "dashboard.access-tokens.expiration-never"
|
||||
msgstr "Nunca"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:268
|
||||
msgid "dashboard.access-tokens.expired-on"
|
||||
msgstr "Expiró el %s"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:269
|
||||
msgid "dashboard.access-tokens.expires-on"
|
||||
msgstr "Expira el %s"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:267
|
||||
msgid "dashboard.access-tokens.no-expiration"
|
||||
msgstr "Sin fecha de expiración"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:184
|
||||
msgid "dashboard.access-tokens.personal"
|
||||
msgstr "Access tokens personales"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:185
|
||||
msgid "dashboard.access-tokens.personal.description"
|
||||
msgstr ""
|
||||
"Los access tokens personales funcionan como una alternativa a nuestro "
|
||||
"sistema de autenticación usuario/password y se pueden usar para permitir a "
|
||||
"otras aplicaciones acceso a la API interna de Penpot"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:142
|
||||
msgid "dashboard.access-tokens.token-will-expire"
|
||||
msgstr "El token expirará el %s"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:143
|
||||
msgid "dashboard.access-tokens.token-will-not-expire"
|
||||
msgstr "El token no tiene fecha de expiración"
|
||||
|
||||
#: src/app/main/ui/dashboard/placeholder.cljs:41
|
||||
msgid "dashboard.add-file"
|
||||
msgstr "Añadir archivo"
|
||||
@@ -369,7 +440,7 @@ msgstr "(copia)"
|
||||
|
||||
#: src/app/main/ui/dashboard/sidebar.cljs:347
|
||||
msgid "dashboard.create-new-org"
|
||||
msgstr "+ Crear org"
|
||||
msgstr "Crear nueva organización"
|
||||
|
||||
#: src/app/main/ui/dashboard/sidebar.cljs:340
|
||||
msgid "dashboard.create-new-team"
|
||||
@@ -2034,209 +2105,6 @@ msgstr "Valor resuelto:"
|
||||
msgid "inspect.tabs.styles.variants-panel"
|
||||
msgstr "Propiedades de las variantes"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:189
|
||||
msgid "integrations.access-tokens.create"
|
||||
msgstr "Crear nuevo token de acceso"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:286
|
||||
msgid "integrations.access-tokens.empty.add-one"
|
||||
msgstr "Pulsa el botón \"Crear nuevo token de accesso\" para generar uno."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:285
|
||||
msgid "integrations.access-tokens.empty.no-access-tokens"
|
||||
msgstr "Todavía no tienes ningún token."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:184
|
||||
msgid "integrations.access-tokens.personal"
|
||||
msgstr "Tokens de acceso personales"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:185
|
||||
msgid "integrations.access-tokens.personal.description"
|
||||
msgstr ""
|
||||
"Los tokens de accesso personales funcionan como una alternativa a nuestro "
|
||||
"sistema de autenticación usuario/password y se pueden usar para permitir a "
|
||||
"otras aplicaciones acceso a la API interna de Penpot"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:152, src/app/main/ui/settings/integrations.cljs:158
|
||||
msgid "integrations.copy-token"
|
||||
msgstr "Copiar token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:432
|
||||
msgid "integrations.create-access-token.title"
|
||||
msgstr "Crear token de accesso"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:433
|
||||
msgid "integrations.create-access-token.title.created"
|
||||
msgstr "Token de acceso creado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:290
|
||||
msgid "integrations.create-mcp-key.title"
|
||||
msgstr "Crear nueva clave MCP"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:291
|
||||
msgid "integrations.create-mcp-key.title.created"
|
||||
msgstr "Clave MCP creada"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:257
|
||||
msgid "integrations.delete-token.accept"
|
||||
msgstr "Borrar token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:256
|
||||
msgid "integrations.delete-token.message"
|
||||
msgstr "¿Seguro que deseas borrar este token?"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:255
|
||||
msgid "integrations.delete-token.title"
|
||||
msgstr "Borrar token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:135
|
||||
msgid "integrations.expiration-180-days"
|
||||
msgstr "180 días"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:132
|
||||
msgid "integrations.expiration-30-days"
|
||||
msgstr "30 días"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:133
|
||||
msgid "integrations.expiration-60-days"
|
||||
msgstr "60 días"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:134
|
||||
msgid "integrations.expiration-90-days"
|
||||
msgstr "90 días"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:131
|
||||
msgid "integrations.expiration-never"
|
||||
msgstr "Nunca"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:268
|
||||
msgid "integrations.expired-on"
|
||||
msgstr "Expiró el %s"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:269
|
||||
msgid "integrations.expires-on"
|
||||
msgstr "Expira el %s"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:267
|
||||
msgid "integrations.no-expiration"
|
||||
msgstr "Sin fecha de expiración"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:130
|
||||
msgid "integrations.expiration-date.label"
|
||||
msgstr "Fecha de expiración"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:131
|
||||
msgid "integrations.info.non-recuperable"
|
||||
msgstr "Esta clave única no es recuperable. Si la pierdes, tendrás que crear una nueva."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:336
|
||||
msgid "integrations.mcp-server.title"
|
||||
msgstr "Servidor MCP"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:336
|
||||
msgid "integrations.mcp-server.title.beta"
|
||||
msgstr "Beta"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:347
|
||||
msgid "integrations.mcp-server.description"
|
||||
msgstr "El servidor MCP de Penpot permite que los clientes MCP interactúen directamente con los archivos de diseño de Penpot."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:353
|
||||
msgid "integrations.mcp-server.status"
|
||||
msgstr "Estado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:370
|
||||
msgid "integrations.mcp-server.status.enabled"
|
||||
msgstr "Habilitado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:370
|
||||
msgid "integrations.mcp-server.status.disabled"
|
||||
msgstr "Deshabilitado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:363
|
||||
msgid "integrations.mcp-server.status.expired.0"
|
||||
msgstr "La clave MCP utilizada para conectarse al servidor MCP ha expirado. Como resultado, no se puede establecer la conexión."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:368
|
||||
msgid "integrations.mcp-server.status.expired.1"
|
||||
msgstr "Por favor, regenera la clave MCP y actualiza la configuración de tu cliente con la nueva clave."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:415
|
||||
msgid "integrations.mcp-server.mcp-keys.copy"
|
||||
msgstr "Copiar enlace"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:422
|
||||
msgid "integrations.mcp-server.mcp-keys.help"
|
||||
msgstr "Cómo configurar clientes MCP"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:405
|
||||
msgid "integrations.mcp-server.mcp-keys.info"
|
||||
msgstr "Esta es la URL del servidor que necesitarás configurar en tu cliente MCP para conectarlo al servidor MCP de Penpot."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:387
|
||||
msgid "integrations.mcp-server.mcp-keys.regenerate"
|
||||
msgstr "Regenerar clave MCP"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:381
|
||||
msgid "integrations.mcp-server.mcp-keys.title"
|
||||
msgstr "Clave MCP"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:388
|
||||
msgid "integrations.mcp-server.mcp-keys.tootip"
|
||||
msgstr "La clave MCP es necesaria para la configuración del cliente MCP"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:124
|
||||
msgid "integrations.name.label"
|
||||
msgstr "Nombre"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:126
|
||||
msgid "integrations.name.placeholder"
|
||||
msgstr "El nombre te pude ayudar a saber para qué se utiliza el token"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:103
|
||||
msgid "integrations.notification.success.copied"
|
||||
msgstr "Token copiado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:64
|
||||
msgid "integrations.notification.success.created"
|
||||
msgstr "Token creado con éxito"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:327
|
||||
msgid "integrations.notification.success.copied-link"
|
||||
msgstr "Enlace copiado al portapapeles"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:293
|
||||
msgid "integrations.notification.success.mcp-server-disabled"
|
||||
msgstr "Servidor MCP deshabilitado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:299
|
||||
msgid "integrations.notification.success.mcp-server-enabled"
|
||||
msgstr "Servidor MCP habilitado"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:317
|
||||
msgid "integrations.regenerate-mcp-key.info"
|
||||
msgstr "Regenerar la clave revocará inmediatamente la actual. Cualquier aplicación que la esté utilizando dejará de funcionar."
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:317
|
||||
msgid "integrations.regenerate-mcp-key.title"
|
||||
msgstr "Regenerar clave MCP"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:318
|
||||
msgid "integrations.regenerate-mcp-key.title.created"
|
||||
msgstr "Clave MCP regenerada"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:480
|
||||
msgid "integrations.title"
|
||||
msgstr "Integraciones"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:142
|
||||
msgid "integrations.token-will-expire"
|
||||
msgstr "El token expirará el %s"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:143
|
||||
msgid "integrations.token-will-not-expire"
|
||||
msgstr "El token no tiene fecha de expiración"
|
||||
|
||||
#: src/app/main/ui/dashboard/comments.cljs:96
|
||||
msgid "label.mark-all-as-read"
|
||||
msgstr "Marcar todo como leído"
|
||||
@@ -2457,9 +2325,6 @@ msgstr "Descartar"
|
||||
msgid "labels.download"
|
||||
msgstr "Descargar %s"
|
||||
|
||||
msgid "labels.download-simple"
|
||||
msgstr "Descargar"
|
||||
|
||||
#: src/app/main/ui/dashboard/file_menu.cljs:30, src/app/main/ui/dashboard/files.cljs:80, src/app/main/ui/dashboard/files.cljs:179, src/app/main/ui/dashboard/projects.cljs:229, src/app/main/ui/dashboard/projects.cljs:233, src/app/main/ui/dashboard/sidebar.cljs:820
|
||||
msgid "labels.drafts"
|
||||
msgstr "Borradores"
|
||||
@@ -2591,10 +2456,6 @@ msgstr "Información"
|
||||
msgid "labels.installed-fonts"
|
||||
msgstr "Fuentes instaladas"
|
||||
|
||||
#: src/app/main/ui/settings/sidebar.cljs:123
|
||||
msgid "labels.integrations"
|
||||
msgstr "Integraciones"
|
||||
|
||||
#: src/app/main/ui/static.cljs:405
|
||||
msgid "labels.internal-error.desc-message-first"
|
||||
msgstr "Ha ocurrido algo extraño."
|
||||
@@ -3250,6 +3111,30 @@ msgstr "Cambiar correo"
|
||||
msgid "modals.change-email.title"
|
||||
msgstr "Cambiar tu correo"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:152, src/app/main/ui/settings/access_tokens.cljs:158
|
||||
msgid "modals.create-access-token.copy-token"
|
||||
msgstr "Copiar token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:130
|
||||
msgid "modals.create-access-token.expiration-date.label"
|
||||
msgstr "Fecha de expiración"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:124
|
||||
msgid "modals.create-access-token.name.label"
|
||||
msgstr "Nombre"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:126
|
||||
msgid "modals.create-access-token.name.placeholder"
|
||||
msgstr "El nombre te pude ayudar a saber para qué se utiliza el token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:178
|
||||
msgid "modals.create-access-token.submit-label"
|
||||
msgstr "Crear token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:111
|
||||
msgid "modals.create-access-token.title"
|
||||
msgstr "Generar access token"
|
||||
|
||||
#: src/app/main/ui/dashboard/team.cljs:1127
|
||||
msgid "modals.create-webhook.submit-label"
|
||||
msgstr "Crear webhook"
|
||||
@@ -3266,6 +3151,18 @@ msgstr "Payload URL"
|
||||
msgid "modals.create-webhook.url.placeholder"
|
||||
msgstr "https://example.com/postreceive"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:257
|
||||
msgid "modals.delete-acces-token.accept"
|
||||
msgstr "Borrar token"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:256
|
||||
msgid "modals.delete-acces-token.message"
|
||||
msgstr "¿Seguro que deseas borrar este token?"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:255
|
||||
msgid "modals.delete-acces-token.title"
|
||||
msgstr "Borrar token"
|
||||
|
||||
#: src/app/main/ui/settings/delete_account.cljs:56
|
||||
msgid "modals.delete-account.cancel"
|
||||
msgstr "Cancelar y mantener mi cuenta"
|
||||
@@ -5171,14 +5068,14 @@ msgstr "Bibliotecas Compartidas - %s - Penpot"
|
||||
msgid "title.default"
|
||||
msgstr "Penpot - Diseño Libre para Equipos"
|
||||
|
||||
#: src/app/main/ui/settings/access_tokens.cljs:278
|
||||
msgid "title.settings.access-tokens"
|
||||
msgstr "Perfil - Access tokens"
|
||||
|
||||
#: src/app/main/ui/settings/feedback.cljs:161
|
||||
msgid "title.settings.feedback"
|
||||
msgstr "Danos tu opinión - Penpot"
|
||||
|
||||
#: src/app/main/ui/settings/integrations.cljs:278
|
||||
msgid "title.settings.integrations"
|
||||
msgstr "Integraciones - Penpot"
|
||||
|
||||
#: src/app/main/ui/settings/notifications.cljs:45
|
||||
msgid "title.settings.notifications"
|
||||
msgstr "Notificaciones - Penpot"
|
||||
@@ -7580,18 +7477,6 @@ msgstr "Pegar"
|
||||
msgid "workspace.shape.menu.paste-props"
|
||||
msgstr "Pegar propiedades"
|
||||
|
||||
msgid "workspace.shape.menu.copy-as-image"
|
||||
msgstr "Copiar como imagen"
|
||||
|
||||
msgid "workspace.clipboard.copying"
|
||||
msgstr "Copiando imagen…"
|
||||
|
||||
msgid "workspace.clipboard.image-copied"
|
||||
msgstr "Imagen copiada al portapapeles"
|
||||
|
||||
msgid "workspace.clipboard.image-copy-failed"
|
||||
msgstr "Error al copiar la imagen"
|
||||
|
||||
#: src/app/main/ui/workspace/context_menu.cljs:443
|
||||
msgid "workspace.shape.menu.path"
|
||||
msgstr "Ruta"
|
||||
|
||||
@@ -50,45 +50,31 @@ Follow the steps below to enable the integration.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The project requires [Node.js](https://nodejs.org/) (tested with v22.x).
|
||||
Following the installation of Node.js, the tools `corepack` and `npx`
|
||||
should be available in your terminal.
|
||||
The project requires [Node.js](https://nodejs.org/) (tested with v22.x
|
||||
with corepack).
|
||||
|
||||
On Windows, use the Git Bash terminal to ensure compatibility with the provided scripts.
|
||||
Following the installation of Node.js, the tools `pnpm` and `npx`
|
||||
should be available in your terminal. For ensure corepack installed
|
||||
and enabled correctly, just execute the `./scripts/setup`.
|
||||
|
||||
### 0. Clone the Appropriate Branch of the Repository
|
||||
It is also required to have `caddy` executeable in the path, it is
|
||||
used for start a local server for generate types documentation from
|
||||
the current branch. If you want to run it outside devenv where all
|
||||
dependencies are already provided, please download caddy from
|
||||
[here](https://caddyserver.com/download).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The branches are subject to change in the future.
|
||||
> Be sure to check the instructions for the latest information on which branch to use.
|
||||
You should probably be using penpot devenv, where all this
|
||||
dependencies are already present and correctly setup. But nothing
|
||||
prevents you execute this outside of devenv if you satisfy the
|
||||
specified dependencies.
|
||||
|
||||
Clone the Penpot repository, using the proper branch depending on the
|
||||
version of Penpot you want to use the MCP server with.
|
||||
|
||||
* For released versions of Penpot, use the `mcp-prod` branch:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/penpot/penpot.git --branch mcp-prod --depth 1
|
||||
```
|
||||
|
||||
* For the latest development version of Penpot, use the `develop` branch:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/penpot/penpot.git --branch develop --depth 1
|
||||
```
|
||||
|
||||
Then change into the `mcp` directory:
|
||||
|
||||
```shell
|
||||
cd penpot/mcp
|
||||
```
|
||||
|
||||
### 1. Build & Launch the MCP Server and the Plugin Server
|
||||
|
||||
If it's your first execution, install the required dependencies.
|
||||
(If you are using the Penpot devenv, this step is not necessary, as dependencies are already installed.)
|
||||
If it's your first execution, install the required dependencies:
|
||||
|
||||
```shell
|
||||
cd mcp/
|
||||
./scripts/setup
|
||||
```
|
||||
|
||||
@@ -100,9 +86,9 @@ pnpm run bootstrap
|
||||
|
||||
This bootstrap command will:
|
||||
|
||||
* install dependencies for all components
|
||||
* build all components
|
||||
* start all components
|
||||
* install dependencies for all components (`pnpm -r run install`)
|
||||
* build all components (`pnpm -r run build`)
|
||||
* start all components (`pnpm -r --parallel run start`)
|
||||
|
||||
### 2. Load the Plugin in Penpot and Establish the Connection
|
||||
|
||||
@@ -209,30 +195,31 @@ To add the Penpot MCP server to a Claude Code project, issue the command
|
||||
|
||||
This repository is a monorepo containing four main components:
|
||||
|
||||
1. **Common Types** (`packages/common/`):
|
||||
1. **Common Types** (`common/`):
|
||||
- Shared TypeScript definitions for request/response protocol
|
||||
- Ensures type safety across server and plugin components
|
||||
|
||||
2. **Penpot MCP Server** (`packages/server/`):
|
||||
2. **Penpot MCP Server** (`mcp-server/`):
|
||||
- Provides MCP tools to LLMs for Penpot interaction
|
||||
- Runs a WebSocket server accepting connections from the Penpot MCP plugin
|
||||
- Implements request/response correlation with unique task IDs
|
||||
- Handles task timeouts and proper error reporting
|
||||
|
||||
3. **Penpot MCP Plugin** (`packages/plugin/`):
|
||||
3. **Penpot MCP Plugin** (`penpot-plugin/`):
|
||||
- Connects to the MCP server via WebSocket
|
||||
- Executes tasks in Penpot using the Plugin API
|
||||
- Sends structured responses back to the server#
|
||||
|
||||
4. **Types Generator** (`types-generator/`):
|
||||
- Generates data on API types for the MCP server (development use)
|
||||
4. **Helper Scripts** (`python-scripts/`):
|
||||
- Python scripts that prepare data for the MCP server (development use)
|
||||
|
||||
The core components are written in TypeScript, rendering interactions with the
|
||||
Penpot Plugin API both natural and type-safe.
|
||||
|
||||
## Configuration
|
||||
|
||||
The Penpot MCP server can be configured using environment variables.
|
||||
The Penpot MCP server can be configured using environment variables. All configuration
|
||||
options use the `PENPOT_MCP_` prefix for consistency.
|
||||
|
||||
### Server Configuration
|
||||
|
||||
@@ -276,9 +263,3 @@ you may set the following environment variables to configure the two servers
|
||||
* `PENPOT_MCP_SERVER_ADDRESS=<your-address>`: This sets the hostname or IP address
|
||||
where the MCP server can be reached. The Penpot MCP Plugin uses this to construct
|
||||
the WebSocket URL as `ws://<your-address>:<port>` (default port: `4402`).
|
||||
|
||||
## Development
|
||||
|
||||
* The [contribution guidelines for Penpot](../CONTRIBUTING.md) apply
|
||||
* Auto-formatting: Use `pnpm run fmt`
|
||||
* Generating API type data: See [types-generator/README.md](types-generator/README.md)
|
||||
|
||||
11
mcp/packages/plugin/src/index.d.ts
vendored
11
mcp/packages/plugin/src/index.d.ts
vendored
@@ -1,11 +0,0 @@
|
||||
interface McpOptions {
|
||||
getToken(): string;
|
||||
getServerUrl(): string;
|
||||
setMcpStatus(status: string);
|
||||
}
|
||||
|
||||
declare global {
|
||||
const mcp: undefined | McpOptions;
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,7 +1,5 @@
|
||||
import "./style.css";
|
||||
|
||||
const KEEP_ALIVE_TIME = 30000; // 30 seconds
|
||||
|
||||
// get the current theme from the URL
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
document.body.dataset.theme = searchParams.get("theme") ?? "light";
|
||||
@@ -21,19 +19,12 @@ const statusElement = document.getElementById("connection-status");
|
||||
* @param isConnectedState - whether the connection is in a connected state (affects color)
|
||||
* @param message - optional additional message to append to the status
|
||||
*/
|
||||
function updateConnectionStatus(code: string, status: string, isConnectedState: boolean, message?: string): void {
|
||||
function updateConnectionStatus(status: string, isConnectedState: boolean, message?: string): void {
|
||||
if (statusElement) {
|
||||
const displayText = message ? `${status}: ${message}` : status;
|
||||
statusElement.textContent = displayText;
|
||||
statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)";
|
||||
}
|
||||
parent.postMessage(
|
||||
{
|
||||
type: "update-connection-status",
|
||||
status: code,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,33 +44,30 @@ function sendTaskResponse(response: any): void {
|
||||
/**
|
||||
* Establishes a WebSocket connection to the MCP server.
|
||||
*/
|
||||
function connectToMcpServer(baseUrl?: string, token?: string): void {
|
||||
function connectToMcpServer(): void {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
updateConnectionStatus("connected", "Already connected", true);
|
||||
updateConnectionStatus("Already connected", true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let wsUrl = baseUrl || PENPOT_MCP_WEBSOCKET_URL;
|
||||
if (isMultiUserMode && token) {
|
||||
wsUrl += `?userToken=${encodeURIComponent(token)}`;
|
||||
let wsUrl = PENPOT_MCP_WEBSOCKET_URL;
|
||||
if (isMultiUserMode) {
|
||||
// TODO obtain proper userToken from penpot
|
||||
const userToken = "dummyToken";
|
||||
wsUrl += `?userToken=${encodeURIComponent(userToken)}`;
|
||||
}
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
updateConnectionStatus("connecting", "Connecting...", false);
|
||||
updateConnectionStatus("Connecting...", false);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("Connected to MCP server");
|
||||
updateConnectionStatus("connected", "Connected to MCP server", true);
|
||||
updateConnectionStatus("Connected to MCP server", true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
console.log("Received from MCP server:", event.data);
|
||||
try {
|
||||
if (event.data === "keep-alive") {
|
||||
// Keep alive response, ignore it
|
||||
return;
|
||||
}
|
||||
console.log("Received from MCP server:", event.data);
|
||||
const request = JSON.parse(event.data);
|
||||
// Forward the task request to the plugin for execution
|
||||
parent.postMessage(request, "*");
|
||||
@@ -88,25 +76,22 @@ function connectToMcpServer(baseUrl?: string, token?: string): void {
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(() => ws?.send("keep-alive"), KEEP_ALIVE_TIME);
|
||||
|
||||
ws.onclose = (event: CloseEvent) => {
|
||||
console.log("Disconnected from MCP server");
|
||||
clearInterval(interval);
|
||||
const message = event.reason || undefined;
|
||||
updateConnectionStatus("disconnected", "Disconnected", false, message);
|
||||
updateConnectionStatus("Disconnected", false, message);
|
||||
ws = null;
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
// note: WebSocket error events typically don't contain detailed error messages
|
||||
updateConnectionStatus("error", "Connection error", false);
|
||||
updateConnectionStatus("Connection error", false);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to connect to MCP server:", error);
|
||||
const message = error instanceof Error ? error.message : undefined;
|
||||
updateConnectionStatus("error", "Connection failed", false, message);
|
||||
updateConnectionStatus("Connection failed", false, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,14 +101,10 @@ document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click"
|
||||
|
||||
// Listen plugin.ts messages
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.data.type === "start-server") {
|
||||
connectToMcpServer(event.data.url, event.data.token);
|
||||
} else if (event.data.source === "penpot") {
|
||||
if (event.data.source === "penpot") {
|
||||
document.body.dataset.theme = event.data.theme;
|
||||
} else if (event.data.type === "task-response") {
|
||||
// Forward task response back to MCP server
|
||||
sendTaskResponse(event.data.response);
|
||||
}
|
||||
});
|
||||
|
||||
parent.postMessage({ type: "ui-initialized" }, "*");
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler";
|
||||
import { Task, TaskHandler } from "./TaskHandler";
|
||||
|
||||
mcp?.setMcpStatus("connecting");
|
||||
|
||||
/**
|
||||
* Registry of all available task handlers.
|
||||
*/
|
||||
@@ -13,24 +11,12 @@ declare const IS_MULTI_USER_MODE: boolean;
|
||||
const isMultiUserMode = typeof IS_MULTI_USER_MODE !== "undefined" ? IS_MULTI_USER_MODE : false;
|
||||
|
||||
// Open the plugin UI (main.ts)
|
||||
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, {
|
||||
width: 158,
|
||||
height: 200,
|
||||
hidden: !!mcp,
|
||||
} as any);
|
||||
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, { width: 158, height: 200 });
|
||||
|
||||
// Handle messages
|
||||
penpot.ui.onMessage<string | { id: string; type?: string; status?: string; task: string; params: any }>((message) => {
|
||||
penpot.ui.onMessage<string | { id: string; task: string; params: any }>((message) => {
|
||||
// Handle plugin task requests
|
||||
if (mcp && typeof message === "object" && message.type === "ui-initialized") {
|
||||
penpot.ui.sendMessage({
|
||||
type: "start-server",
|
||||
url: mcp?.getServerUrl(),
|
||||
token: mcp?.getToken(),
|
||||
});
|
||||
} else if (typeof message === "object" && message.type === "update-connection-status") {
|
||||
mcp?.setMcpStatus(message.status || "unknown");
|
||||
} else if (typeof message === "object" && message.task && message.id) {
|
||||
if (typeof message === "object" && message.task && message.id) {
|
||||
handlePluginTaskRequest(message).catch((error) => {
|
||||
console.error("Error in handlePluginTaskRequest:", error);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import baseConfig from "./vite.config";
|
||||
export default mergeConfig(
|
||||
baseConfig,
|
||||
defineConfig({
|
||||
base: "./",
|
||||
plugins: [],
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
You have access to Penpot tools in order to interact with a Penpot design project directly.
|
||||
As a precondition, the user must connect the Penpot design project to the MCP server using the Penpot MCP Plugin.
|
||||
|
||||
IMPORTANT: When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design.
|
||||
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to
|
||||
non-creative defaults such as white/black if you are lacking information).
|
||||
|
||||
# Executing Code
|
||||
|
||||
One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API
|
||||
@@ -44,8 +48,6 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`
|
||||
**Other Writable Properties**:
|
||||
* `name` - Shape name
|
||||
* `fills`, `strokes` - Styling properties
|
||||
IMPORTANT: The contents of the arrays are read-only. You cannot modify individual fills/strokes; you need to replace the entire array to change them, e.g.
|
||||
`shape.fills = [{ fillColor: "#FF0000", fillOpacity: 1 }]` to set a single red fill.
|
||||
* `rotation`, `opacity`, `blocked`, `hidden`, `visible`
|
||||
|
||||
**Z-Order**:
|
||||
@@ -97,12 +99,11 @@ Boards can have layout systems that automatically control the positioning and sp
|
||||
- `dir`: "row" | "column" | "row-reverse" | "column-reverse"
|
||||
- Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding`
|
||||
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions.
|
||||
Optionally, adjust individual child margins via `child.layoutChild`.
|
||||
- Sizing: `verticalSizing` and `horizontalSizing` are NOT functional. You need to size manually for the time being.
|
||||
Optionally, adjust indivudual child margins via `child.layoutChild`.
|
||||
- When a board has flex layout,
|
||||
- child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
|
||||
appending or inserting children automatically positions them according to the layout rules.
|
||||
- CRITICAL: For dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
|
||||
- CRITICAL: For for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
|
||||
Therefore, the element that appears first in the array, appears visually at the end (bottom/right) and vice versa.
|
||||
ALWAYS BEAR IN MIND THAT THE CHILDREN ARRAY ORDER IS REVERSED FOR dir="column" OR dir="row"!
|
||||
- CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that
|
||||
@@ -209,6 +210,19 @@ Common tasks - Quick Reference (ALWAYS use penpotUtils for these):
|
||||
});
|
||||
Always validate against the root container that is supposed to contain the shapes.
|
||||
|
||||
# Visual Inspection of Designs
|
||||
|
||||
For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose!
|
||||
|
||||
# Revising Designs
|
||||
|
||||
* Before applying design changes, ask: "Would a designer consider this appropriate?"
|
||||
* When dealing with containment issues, ask: Is the parent too small OR is the child too large?
|
||||
Container sizes are usually intentional, check content first.
|
||||
* Check for reasonable font sizes and typefaces
|
||||
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
|
||||
Consider converting boards to flex layout when appropriate.
|
||||
|
||||
# Asset Libraries
|
||||
|
||||
Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files.
|
||||
@@ -268,15 +282,14 @@ The token library: `penpot.library.local.tokens` (type: `TokenCatalog`)
|
||||
* `tokens: Token[]` - All tokens in set
|
||||
* `addToken(type: TokenType, name: string, value: TokenValueString): Token` - Creates a token, adding it to the set.
|
||||
- `TokenType`: "color" | "dimension" | "spacing" | "typography" | "shadow" | "opacity" | "borderRadius" | "borderWidth" | "fontWeights" | "fontSizes" | "fontFamilies" | "letterSpacing" | "textDecoration" | "textCase"
|
||||
- `value`: depends on the type of token (inspect `Token` and related types)
|
||||
- Examples:
|
||||
const token = set.addToken("color", "color.primary", "#0066FF"); // direct value
|
||||
const token2 = set.addToken("color", "color.accent", "{color.primary}"); // reference to another token
|
||||
|
||||
`Token`: union type encompassing various token types, with common properties:
|
||||
* `name: string` - Token name (typically structured, e.g. "color.base.white")
|
||||
* `value` - Raw value (direct value or reference to another token like "{color.primary}")
|
||||
* `resolvedValue` - Computed final value (follows references) - currently NOT working, do not use!
|
||||
`Token`:
|
||||
* `name: string` - Token name (may include group path like "color.base.white")
|
||||
* `value: string | TokenValueString` - Raw value (may be direct value or reference to another token like "{color.primary}")
|
||||
* `resolvedValue` - Computed final value (follows references)
|
||||
* `type: TokenType`
|
||||
|
||||
Discovering tokens:
|
||||
@@ -316,24 +329,5 @@ Removing tokens:
|
||||
Simply set the respective property directly - token binding is automatically removed, e.g.
|
||||
shape.fills = [{ fillColor: "#000000", fillOpacity: 1 }]; // Removes fill token
|
||||
|
||||
# Visual Inspection of Designs
|
||||
|
||||
For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose!
|
||||
|
||||
# Creating and Translating Designs
|
||||
|
||||
* When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design.
|
||||
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to
|
||||
non-creative defaults such as white/black if you are lacking information).
|
||||
|
||||
# Revising Designs
|
||||
|
||||
* Before applying design changes, ask: "Would a designer consider this appropriate?"
|
||||
* When dealing with containment issues, ask: Is the parent too small OR is the child too large?
|
||||
Container sizes are usually intentional, check content first.
|
||||
* Check for reasonable font sizes and typefaces
|
||||
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
|
||||
Consider converting boards to flex layout when appropriate.
|
||||
|
||||
--
|
||||
You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.
|
||||
|
||||
@@ -72,10 +72,6 @@ export class PluginBridge {
|
||||
ws.on("message", (data: Buffer) => {
|
||||
this.logger.debug("Received WebSocket message: %s", data.toString());
|
||||
try {
|
||||
if (data.toString() === "keep-alive") {
|
||||
ws.send("keep-alive");
|
||||
return;
|
||||
}
|
||||
const response: PluginTaskResponse<any> = JSON.parse(data.toString());
|
||||
this.handlePluginTaskResponse(response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -53,10 +53,9 @@ export class ExecuteCodeTool extends Tool<ExecuteCodeArgs> {
|
||||
"could come in handy later should be stored in `storage` instead of just a fleeting variable; " +
|
||||
"you can also store functions and thus build up a library).\n" +
|
||||
"Think of the code being executed as the body of a function: " +
|
||||
"The tool call returns whatever you return in the applicable `return` statement, if any. " +
|
||||
"You can return arbitrary JS objects; no need to apply JSON.stringify.\n" +
|
||||
"The tool call returns whatever you return in the applicable `return` statement, if any.\n" +
|
||||
"If an exception occurs, the exception's message will be returned to you.\n" +
|
||||
"Any output that you generate via the `console` object will be returned to you separately; so you may use it " +
|
||||
"Any output that you generate via the `console` object will be returned to you separately; so you may use it" +
|
||||
"to track what your code is doing, but you should *only* do so only if there is an ACTUAL NEED for this! " +
|
||||
"VERY IMPORTANT: Don't use logging prematurely! NEVER log the data you are returning, as you will otherwise receive it twice!\n" +
|
||||
"VERY IMPORTANT: In general, try a simple approach first, and only if it fails, try more complex code that involves " +
|
||||
|
||||
10
plugins/libs/plugin-types/index.d.ts
vendored
10
plugins/libs/plugin-types/index.d.ts
vendored
@@ -23,7 +23,7 @@ export interface Penpot extends Omit<
|
||||
open: (
|
||||
name: string,
|
||||
url: string,
|
||||
options?: { width: number; height: number; hidden: boolean },
|
||||
options?: { width: number; height: number },
|
||||
) => void;
|
||||
|
||||
size: {
|
||||
@@ -3723,7 +3723,7 @@ export interface ShapeBase extends PluginData {
|
||||
/**
|
||||
* Layout properties for cells in a grid layout.
|
||||
*/
|
||||
readonly layoutCell?: LayoutCellProperties;
|
||||
readonly layoutCell?: LayoutChildProperties;
|
||||
|
||||
/**
|
||||
* Changes the index inside the parent of the current shape.
|
||||
@@ -4127,12 +4127,6 @@ export interface Text extends ShapeBase {
|
||||
*/
|
||||
verticalAlign: 'top' | 'center' | 'bottom' | null;
|
||||
|
||||
/**
|
||||
* Return the bounding box for the text as a (x, y, width, height) rectangle
|
||||
* This is the box that covers the text even if it overflows its selection rectangle.
|
||||
*/
|
||||
readonly textBounds: { x: number; y: number; width: number; height: number };
|
||||
|
||||
/**
|
||||
* Gets a text range within the text shape.
|
||||
* @param start - The start index of the text range.
|
||||
|
||||
@@ -24,10 +24,6 @@ export function createModal(
|
||||
inlineStart: window.innerWidth - width - 290,
|
||||
};
|
||||
|
||||
if (options?.hidden) {
|
||||
modal.style.setProperty('display', 'none');
|
||||
}
|
||||
|
||||
modal.style.setProperty(
|
||||
'--modal-block-start',
|
||||
`${initialPosition.blockStart}px`,
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('createPlugin', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(createSandbox).toHaveBeenCalledWith(mockPluginManager, undefined);
|
||||
expect(createSandbox).toHaveBeenCalledWith(mockPluginManager);
|
||||
expect(mockSandbox.evaluate).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
plugin: mockPluginManager,
|
||||
|
||||
@@ -7,7 +7,6 @@ export async function createPlugin(
|
||||
context: Context,
|
||||
manifest: Manifest,
|
||||
onCloseCallback: () => void,
|
||||
apiExtensions?: object,
|
||||
) {
|
||||
const evaluateSandbox = async () => {
|
||||
try {
|
||||
@@ -31,7 +30,7 @@ export async function createPlugin(
|
||||
},
|
||||
);
|
||||
|
||||
const sandbox = createSandbox(plugin, apiExtensions);
|
||||
const sandbox = createSandbox(plugin);
|
||||
|
||||
evaluateSandbox();
|
||||
|
||||
|
||||
@@ -2,10 +2,8 @@ import type { Penpot } from '@penpot/plugin-types';
|
||||
import type { createPluginManager } from './plugin-manager';
|
||||
import { createApi } from './api';
|
||||
import { ses } from './ses.js';
|
||||
|
||||
export function createSandbox(
|
||||
plugin: Awaited<ReturnType<typeof createPluginManager>>,
|
||||
apiExtensions?: object,
|
||||
) {
|
||||
ses.hardenIntrinsics();
|
||||
|
||||
@@ -53,7 +51,7 @@ export function createSandbox(
|
||||
});
|
||||
};
|
||||
|
||||
let publicPluginApi = {
|
||||
const publicPluginApi = {
|
||||
penpot: proxyApi,
|
||||
fetch: ses.harden(safeFetch),
|
||||
setTimeout: ses.harden(
|
||||
@@ -125,10 +123,6 @@ export function createSandbox(
|
||||
structuredClone: ses.harden(window.structuredClone),
|
||||
};
|
||||
|
||||
if (apiExtensions) {
|
||||
publicPluginApi = Object.assign(publicPluginApi, apiExtensions);
|
||||
}
|
||||
|
||||
const compartment = ses.createCompartment(publicPluginApi);
|
||||
|
||||
return {
|
||||
|
||||
@@ -78,7 +78,6 @@ describe('plugin-loader', () => {
|
||||
mockContext,
|
||||
manifest,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
expect(mockPluginApi.plugin.close).not.toHaveBeenCalled();
|
||||
expect(getPlugins()).toHaveLength(1);
|
||||
@@ -131,7 +130,6 @@ describe('plugin-loader', () => {
|
||||
mockContext,
|
||||
manifest,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -146,7 +144,6 @@ describe('plugin-loader', () => {
|
||||
mockContext,
|
||||
manifest,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,10 +19,7 @@ export const getPlugins = () => plugins;
|
||||
|
||||
const closeAllPlugins = () => {
|
||||
plugins.forEach((pluginApi) => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
if (!(pluginApi.manifest as any)?.allowBackground) {
|
||||
pluginApi.plugin.close();
|
||||
}
|
||||
pluginApi.plugin.close();
|
||||
});
|
||||
|
||||
plugins = [];
|
||||
@@ -41,7 +38,6 @@ window.addEventListener('message', (event) => {
|
||||
export const loadPlugin = async function (
|
||||
manifest: Manifest,
|
||||
closeCallback?: () => void,
|
||||
apiExtensions?: object,
|
||||
) {
|
||||
try {
|
||||
const context = contextBuilder && contextBuilder(manifest.pluginId);
|
||||
@@ -62,7 +58,6 @@ export const loadPlugin = async function (
|
||||
closeCallback();
|
||||
}
|
||||
},
|
||||
apiExtensions,
|
||||
);
|
||||
|
||||
plugins.push(plugin);
|
||||
@@ -75,9 +70,8 @@ export const loadPlugin = async function (
|
||||
export const ɵloadPlugin = async function (
|
||||
manifest: Manifest,
|
||||
closeCallback?: () => void,
|
||||
apiExtensions?: object,
|
||||
) {
|
||||
loadPlugin(manifest, closeCallback, apiExtensions);
|
||||
loadPlugin(manifest, closeCallback);
|
||||
};
|
||||
|
||||
export const ɵloadPluginByUrl = async function (manifestUrl: string) {
|
||||
|
||||
@@ -3,5 +3,4 @@ import { z } from 'zod';
|
||||
export const openUISchema = z.object({
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive(),
|
||||
hidden: z.boolean().optional(),
|
||||
});
|
||||
|
||||
2
render-wasm/pnpm-workspace.yaml
Normal file
2
render-wasm/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
@@ -45,7 +45,11 @@ fn render_cursor(
|
||||
paint.set_color(editor_state.theme.cursor_color);
|
||||
paint.set_anti_alias(true);
|
||||
|
||||
let shape_matrix = shape.get_matrix();
|
||||
canvas.save();
|
||||
canvas.concat(&shape_matrix);
|
||||
canvas.draw_rect(rect, &paint);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
fn render_selection(
|
||||
@@ -65,9 +69,14 @@ fn render_selection(
|
||||
paint.set_blend_mode(BlendMode::Multiply);
|
||||
paint.set_color(editor_state.theme.selection_color);
|
||||
paint.set_anti_alias(true);
|
||||
|
||||
let shape_matrix = shape.get_matrix();
|
||||
canvas.save();
|
||||
canvas.concat(&shape_matrix);
|
||||
for rect in rects {
|
||||
canvas.draw_rect(rect, &paint);
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
fn vertical_align_offset(
|
||||
@@ -99,12 +108,10 @@ fn calculate_cursor_rect(
|
||||
return None;
|
||||
}
|
||||
|
||||
let selrect = shape.selrect();
|
||||
|
||||
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
|
||||
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
|
||||
if idx == cursor.paragraph {
|
||||
let char_pos = cursor.char_offset;
|
||||
let char_pos = cursor.offset;
|
||||
// For cursor, we get a zero-width range at the position
|
||||
// We need to handle edge cases:
|
||||
// - At start of paragraph: use position 0
|
||||
@@ -157,8 +164,8 @@ fn calculate_cursor_rect(
|
||||
};
|
||||
|
||||
return Some(Rect::from_xywh(
|
||||
selrect.x() + cursor_x,
|
||||
selrect.y() + y_offset,
|
||||
cursor_x,
|
||||
y_offset,
|
||||
editor_state.theme.cursor_width,
|
||||
cursor_height,
|
||||
));
|
||||
@@ -182,7 +189,6 @@ fn calculate_selection_rects(
|
||||
let paragraphs = text_content.paragraphs();
|
||||
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
||||
|
||||
let selrect = shape.selrect();
|
||||
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
|
||||
|
||||
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
|
||||
@@ -203,13 +209,13 @@ fn calculate_selection_rects(
|
||||
.sum();
|
||||
|
||||
let range_start = if para_idx == start.paragraph {
|
||||
start.char_offset
|
||||
start.offset
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let range_end = if para_idx == end.paragraph {
|
||||
end.char_offset
|
||||
end.offset
|
||||
} else {
|
||||
para_char_count
|
||||
};
|
||||
@@ -225,8 +231,8 @@ fn calculate_selection_rects(
|
||||
for text_box in text_boxes {
|
||||
let r = text_box.rect;
|
||||
rects.push(Rect::from_xywh(
|
||||
selrect.x() + r.left(),
|
||||
selrect.y() + y_offset + r.top(),
|
||||
r.left(),
|
||||
y_offset + r.top(),
|
||||
r.width(),
|
||||
r.height(),
|
||||
));
|
||||
|
||||
@@ -258,6 +258,18 @@ pub fn all_with_ancestors(
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
pub fn get_relative_point(
|
||||
point: &Point,
|
||||
view_matrix: &Matrix,
|
||||
shape_matrix: &Matrix,
|
||||
) -> Option<Point> {
|
||||
let inv_view_matrix = view_matrix.invert()?;
|
||||
let inv_shape_matrix = shape_matrix.invert()?;
|
||||
let transform_matrix: Matrix = Matrix::concat(&inv_shape_matrix, &inv_view_matrix);
|
||||
let shape_relative_point = transform_matrix.map_point(*point);
|
||||
Some(shape_relative_point)
|
||||
}
|
||||
|
||||
pub fn new(id: Uuid) -> Self {
|
||||
Self {
|
||||
id,
|
||||
|
||||
@@ -14,6 +14,7 @@ use skia_safe::{
|
||||
textlayout::ParagraphBuilder,
|
||||
textlayout::ParagraphStyle,
|
||||
textlayout::PositionWithAffinity,
|
||||
textlayout::Affinity,
|
||||
Contains,
|
||||
};
|
||||
|
||||
@@ -112,29 +113,55 @@ impl TextContentSize {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct TextPositionWithAffinity {
|
||||
pub position_with_affinity: PositionWithAffinity,
|
||||
pub paragraph: i32,
|
||||
#[allow(dead_code)]
|
||||
pub span: i32,
|
||||
pub offset: i32,
|
||||
pub position_with_affinity: PositionWithAffinity,
|
||||
pub paragraph: usize,
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
impl PartialEq for TextPositionWithAffinity {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.paragraph == other.paragraph
|
||||
&& self.offset == other.offset
|
||||
}
|
||||
}
|
||||
|
||||
impl TextPositionWithAffinity {
|
||||
pub fn new(
|
||||
position_with_affinity: PositionWithAffinity,
|
||||
paragraph: i32,
|
||||
span: i32,
|
||||
offset: i32,
|
||||
paragraph: usize,
|
||||
offset: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
position_with_affinity,
|
||||
paragraph,
|
||||
span,
|
||||
offset,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
position_with_affinity: PositionWithAffinity {
|
||||
position: 0,
|
||||
affinity: Affinity::Downstream,
|
||||
},
|
||||
paragraph: 0,
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_without_affinity(paragraph: usize, offset: usize) -> Self {
|
||||
Self {
|
||||
position_with_affinity: PositionWithAffinity {
|
||||
position: offset as i32,
|
||||
affinity: Affinity::Downstream,
|
||||
},
|
||||
paragraph,
|
||||
offset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -421,14 +448,19 @@ impl TextContent {
|
||||
self.bounds = Rect::from_ltrb(p1.x, p1.y, p2.x, p2.y);
|
||||
}
|
||||
|
||||
pub fn get_caret_position_at(&self, point: &Point) -> Option<TextPositionWithAffinity> {
|
||||
pub fn get_caret_position_from_shape_coords(
|
||||
&self,
|
||||
point: &Point,
|
||||
) -> Option<TextPositionWithAffinity> {
|
||||
let mut offset_y = 0.0;
|
||||
let layout_paragraphs = self.layout.paragraphs.iter().flatten();
|
||||
|
||||
let mut paragraph_index: i32 = -1;
|
||||
let mut span_index: i32 = -1;
|
||||
let mut paragraph_index: usize = 0;
|
||||
// IMPORTANT! I'm keeping this because I think it should be better to have the span index
|
||||
// cached the same way we keep the paragraph index.
|
||||
#[allow(dead_code)]
|
||||
let mut _span_index: usize = 0;
|
||||
for layout_paragraph in layout_paragraphs {
|
||||
paragraph_index += 1;
|
||||
let start_y = offset_y;
|
||||
let end_y = offset_y + layout_paragraph.height();
|
||||
|
||||
@@ -449,16 +481,15 @@ impl TextContent {
|
||||
// Computed position keeps the current position in terms
|
||||
// of number of characters of text. This is used to know
|
||||
// in which span we are.
|
||||
let mut computed_position = 0;
|
||||
let mut span_offset = 0;
|
||||
let mut computed_position: usize = 0;
|
||||
let mut span_offset: usize = 0;
|
||||
|
||||
// If paragraph has no spans, default to span 0, offset 0
|
||||
if paragraph.children().is_empty() {
|
||||
span_index = 0;
|
||||
_span_index = 0;
|
||||
span_offset = 0;
|
||||
} else {
|
||||
for span in paragraph.children() {
|
||||
span_index += 1;
|
||||
let length = span.text.chars().count();
|
||||
let start_position = computed_position;
|
||||
let end_position = computed_position + length;
|
||||
@@ -475,22 +506,23 @@ impl TextContent {
|
||||
&& end_position >= current_position
|
||||
{
|
||||
span_offset =
|
||||
position_with_affinity.position - start_position as i32;
|
||||
position_with_affinity.position as usize - start_position;
|
||||
break;
|
||||
}
|
||||
computed_position += length;
|
||||
_span_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Some(TextPositionWithAffinity::new(
|
||||
position_with_affinity,
|
||||
paragraph_index,
|
||||
span_index,
|
||||
span_offset,
|
||||
position_with_affinity.position,
|
||||
));
|
||||
}
|
||||
}
|
||||
offset_y += layout_paragraph.height();
|
||||
paragraph_index += 1;
|
||||
}
|
||||
|
||||
// Handle completely empty text shapes: if there are no paragraphs or all paragraphs
|
||||
@@ -507,7 +539,6 @@ impl TextContent {
|
||||
return Some(TextPositionWithAffinity::new(
|
||||
default_position,
|
||||
0, // paragraph 0
|
||||
0, // span 0
|
||||
0, // offset 0
|
||||
));
|
||||
}
|
||||
@@ -515,6 +546,16 @@ impl TextContent {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_caret_position_from_screen_coords(
|
||||
&self,
|
||||
point: &Point,
|
||||
view_matrix: &Matrix,
|
||||
shape_matrix: &Matrix,
|
||||
) -> Option<TextPositionWithAffinity> {
|
||||
let shape_rel_point = Shape::get_relative_point(point, view_matrix, shape_matrix)?;
|
||||
self.get_caret_position_from_shape_coords(&shape_rel_point)
|
||||
}
|
||||
|
||||
/// Builds the ParagraphBuilders necessary to render
|
||||
/// this text.
|
||||
pub fn paragraph_builder_group_from_text(
|
||||
|
||||
@@ -1,37 +1,16 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::shapes::TextPositionWithAffinity;
|
||||
use crate::shapes::{TextContent, TextPositionWithAffinity};
|
||||
use crate::uuid::Uuid;
|
||||
use skia_safe::Color;
|
||||
|
||||
/// Cursor position within text content.
|
||||
/// Uses character offsets for precise positioning.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
|
||||
pub struct TextCursor {
|
||||
pub paragraph: usize,
|
||||
pub char_offset: usize,
|
||||
}
|
||||
|
||||
impl TextCursor {
|
||||
pub fn new(paragraph: usize, char_offset: usize) -> Self {
|
||||
Self {
|
||||
paragraph,
|
||||
char_offset,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn zero() -> Self {
|
||||
Self {
|
||||
paragraph: 0,
|
||||
char_offset: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
use skia_safe::{
|
||||
textlayout::{Affinity, PositionWithAffinity},
|
||||
Color,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct TextSelection {
|
||||
pub anchor: TextCursor,
|
||||
pub focus: TextCursor,
|
||||
pub anchor: TextPositionWithAffinity,
|
||||
pub focus: TextPositionWithAffinity,
|
||||
}
|
||||
|
||||
impl TextSelection {
|
||||
@@ -39,10 +18,10 @@ impl TextSelection {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn from_cursor(cursor: TextCursor) -> Self {
|
||||
pub fn from_position_with_affinity(position: TextPositionWithAffinity) -> Self {
|
||||
Self {
|
||||
anchor: cursor,
|
||||
focus: cursor,
|
||||
anchor: position,
|
||||
focus: position,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +33,12 @@ impl TextSelection {
|
||||
!self.is_collapsed()
|
||||
}
|
||||
|
||||
pub fn set_caret(&mut self, cursor: TextCursor) {
|
||||
pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) {
|
||||
self.anchor = cursor;
|
||||
self.focus = cursor;
|
||||
}
|
||||
|
||||
pub fn extend_to(&mut self, cursor: TextCursor) {
|
||||
pub fn extend_to(&mut self, cursor: TextPositionWithAffinity) {
|
||||
self.focus = cursor;
|
||||
}
|
||||
|
||||
@@ -71,24 +50,24 @@ impl TextSelection {
|
||||
self.focus = self.anchor;
|
||||
}
|
||||
|
||||
pub fn start(&self) -> TextCursor {
|
||||
pub fn start(&self) -> TextPositionWithAffinity {
|
||||
if self.anchor.paragraph < self.focus.paragraph {
|
||||
self.anchor
|
||||
} else if self.anchor.paragraph > self.focus.paragraph {
|
||||
self.focus
|
||||
} else if self.anchor.char_offset <= self.focus.char_offset {
|
||||
} else if self.anchor.offset <= self.focus.offset {
|
||||
self.anchor
|
||||
} else {
|
||||
self.focus
|
||||
}
|
||||
}
|
||||
|
||||
pub fn end(&self) -> TextCursor {
|
||||
pub fn end(&self) -> TextPositionWithAffinity {
|
||||
if self.anchor.paragraph > self.focus.paragraph {
|
||||
self.anchor
|
||||
} else if self.anchor.paragraph < self.focus.paragraph {
|
||||
self.focus
|
||||
} else if self.anchor.char_offset >= self.focus.char_offset {
|
||||
} else if self.anchor.offset >= self.focus.offset {
|
||||
self.anchor
|
||||
} else {
|
||||
self.focus
|
||||
@@ -99,7 +78,7 @@ impl TextSelection {
|
||||
/// Events that the text editor can emit for frontend synchronization
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum EditorEvent {
|
||||
pub enum TextEditorEvent {
|
||||
None = 0,
|
||||
ContentChanged = 1,
|
||||
SelectionChanged = 2,
|
||||
@@ -122,10 +101,13 @@ pub struct TextEditorState {
|
||||
pub theme: TextEditorTheme,
|
||||
pub selection: TextSelection,
|
||||
pub is_active: bool,
|
||||
// This property indicates that we've started
|
||||
// selecting something with the pointer.
|
||||
pub is_pointer_selection_active: bool,
|
||||
pub active_shape_id: Option<Uuid>,
|
||||
pub cursor_visible: bool,
|
||||
pub last_blink_time: f64,
|
||||
pending_events: Vec<EditorEvent>,
|
||||
pending_events: Vec<TextEditorEvent>,
|
||||
}
|
||||
|
||||
impl TextEditorState {
|
||||
@@ -138,6 +120,7 @@ impl TextEditorState {
|
||||
},
|
||||
selection: TextSelection::new(),
|
||||
is_active: false,
|
||||
is_pointer_selection_active: false,
|
||||
active_shape_id: None,
|
||||
cursor_visible: true,
|
||||
last_blink_time: 0.0,
|
||||
@@ -151,6 +134,7 @@ impl TextEditorState {
|
||||
self.cursor_visible = true;
|
||||
self.last_blink_time = 0.0;
|
||||
self.selection = TextSelection::new();
|
||||
self.is_pointer_selection_active = false;
|
||||
self.pending_events.clear();
|
||||
}
|
||||
|
||||
@@ -158,21 +142,77 @@ impl TextEditorState {
|
||||
self.is_active = false;
|
||||
self.active_shape_id = None;
|
||||
self.cursor_visible = false;
|
||||
self.is_pointer_selection_active = false;
|
||||
self.pending_events.clear();
|
||||
self.reset_blink();
|
||||
}
|
||||
|
||||
pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) {
|
||||
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
|
||||
self.selection.set_caret(cursor);
|
||||
self.reset_blink();
|
||||
self.push_event(EditorEvent::SelectionChanged);
|
||||
pub fn start_pointer_selection(&mut self) -> bool {
|
||||
if self.is_pointer_selection_active {
|
||||
return false;
|
||||
}
|
||||
self.is_pointer_selection_active = true;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn extend_selection_from_position(&mut self, position: TextPositionWithAffinity) {
|
||||
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
|
||||
self.selection.extend_to(cursor);
|
||||
pub fn stop_pointer_selection(&mut self) -> bool {
|
||||
if !self.is_pointer_selection_active {
|
||||
return false;
|
||||
}
|
||||
self.is_pointer_selection_active = false;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn select_all(&mut self, content: &TextContent) -> bool {
|
||||
self.is_pointer_selection_active = false;
|
||||
self.set_caret_from_position(TextPositionWithAffinity::new(
|
||||
PositionWithAffinity {
|
||||
position: 0,
|
||||
affinity: Affinity::Downstream,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
));
|
||||
let num_paragraphs = (content.paragraphs().len() - 1) as i32;
|
||||
let Some(last_paragraph) = content.paragraphs().last() else {
|
||||
return false;
|
||||
};
|
||||
let num_spans = (last_paragraph.children().len() - 1) as i32;
|
||||
let Some(last_text_span) = last_paragraph.children().last() else {
|
||||
return false;
|
||||
};
|
||||
let mut offset = 0;
|
||||
for span in last_paragraph.children() {
|
||||
offset += span.text.len();
|
||||
}
|
||||
self.extend_selection_from_position(TextPositionWithAffinity::new(
|
||||
PositionWithAffinity {
|
||||
position: offset as i32,
|
||||
affinity: Affinity::Upstream,
|
||||
},
|
||||
num_paragraphs,
|
||||
num_spans,
|
||||
last_text_span.text.len() as i32,
|
||||
offset as i32,
|
||||
));
|
||||
self.reset_blink();
|
||||
self.push_event(EditorEvent::SelectionChanged);
|
||||
self.push_event(crate::state::EditorEvent::SelectionChanged);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) {
|
||||
self.selection.set_caret(*position);
|
||||
self.reset_blink();
|
||||
self.push_event(TextEditorEvent::SelectionChanged);
|
||||
}
|
||||
|
||||
pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) {
|
||||
self.selection.extend_to(*position);
|
||||
self.reset_blink();
|
||||
self.push_event(TextEditorEvent::SelectionChanged);
|
||||
}
|
||||
|
||||
pub fn update_blink(&mut self, timestamp_ms: f64) {
|
||||
@@ -198,41 +238,17 @@ impl TextEditorState {
|
||||
self.last_blink_time = 0.0;
|
||||
}
|
||||
|
||||
pub fn push_event(&mut self, event: EditorEvent) {
|
||||
pub fn push_event(&mut self, event: TextEditorEvent) {
|
||||
if self.pending_events.last() != Some(&event) {
|
||||
self.pending_events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll_event(&mut self) -> EditorEvent {
|
||||
self.pending_events.pop().unwrap_or(EditorEvent::None)
|
||||
pub fn poll_event(&mut self) -> TextEditorEvent {
|
||||
self.pending_events.pop().unwrap_or(TextEditorEvent::None)
|
||||
}
|
||||
|
||||
pub fn has_pending_events(&self) -> bool {
|
||||
!self.pending_events.is_empty()
|
||||
}
|
||||
|
||||
pub fn set_caret_position_from(
|
||||
&mut self,
|
||||
text_position_with_affinity: TextPositionWithAffinity,
|
||||
) {
|
||||
self.set_caret_from_position(text_position_with_affinity);
|
||||
}
|
||||
}
|
||||
|
||||
/// TODO: Remove legacy code
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
pub struct TextNodePosition {
|
||||
pub paragraph: i32,
|
||||
pub span: i32,
|
||||
}
|
||||
|
||||
impl TextNodePosition {
|
||||
pub fn new(paragraph: i32, span: i32) -> Self {
|
||||
Self { paragraph, span }
|
||||
}
|
||||
|
||||
pub fn is_invalid(&self) -> bool {
|
||||
self.paragraph < 0 || self.span < 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
use macros::ToJs;
|
||||
|
||||
use super::{fills::RawFillData, fonts::RawFontStyle};
|
||||
use crate::math::{Matrix, Point};
|
||||
|
||||
use crate::mem::{self, SerializableResult};
|
||||
use crate::shapes::{
|
||||
self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type,
|
||||
};
|
||||
use crate::utils::{uuid_from_u32, uuid_from_u32_quartet};
|
||||
use crate::{
|
||||
with_current_shape, with_current_shape_mut, with_state, with_state_mut,
|
||||
with_state_mut_current_shape, STATE,
|
||||
};
|
||||
use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_mut, STATE};
|
||||
|
||||
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
|
||||
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
|
||||
@@ -388,32 +385,6 @@ pub extern "C" fn update_shape_text_layout_for(a: u32, b: u32, c: u32, d: u32) {
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_caret_position_at(x: f32, y: f32) -> i32 {
|
||||
with_state_mut_current_shape!(state, |shape: &Shape| {
|
||||
if let Type::Text(text_content) = &shape.shape_type {
|
||||
let mut matrix = Matrix::new_identity();
|
||||
let shape_matrix = shape.get_concatenated_matrix(&state.shapes);
|
||||
let view_matrix = state.render_state.viewbox.get_matrix();
|
||||
if let Some(inv_view_matrix) = view_matrix.invert() {
|
||||
matrix.post_concat(&inv_view_matrix);
|
||||
matrix.post_concat(&shape_matrix);
|
||||
|
||||
let mapped_point = matrix.map_point(Point::new(x, y));
|
||||
|
||||
if let Some(position_with_affinity) =
|
||||
text_content.get_caret_position_at(&mapped_point)
|
||||
{
|
||||
return position_with_affinity.position_with_affinity.position;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
panic!("Trying to get caret position of a shape that it's not a text shape");
|
||||
}
|
||||
});
|
||||
-1
|
||||
}
|
||||
|
||||
const RAW_POSITION_DATA_SIZE: usize = size_of::<shapes::PositionData>();
|
||||
|
||||
impl From<[u8; RAW_POSITION_DATA_SIZE]> for shapes::PositionData {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
use macros::ToJs;
|
||||
|
||||
use crate::math::{Matrix, Point, Rect};
|
||||
use crate::mem;
|
||||
use crate::shapes::{Paragraph, Shape, TextContent, Type, VerticalAlign};
|
||||
use crate::state::{TextCursor, TextSelection};
|
||||
use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
|
||||
use crate::state::{TextSelection};
|
||||
use crate::utils::uuid_from_u32_quartet;
|
||||
use crate::utils::uuid_to_u32_quartet;
|
||||
use crate::{with_state, with_state_mut, STATE};
|
||||
use macros::ToJs;
|
||||
|
||||
#[derive(PartialEq, ToJs)]
|
||||
#[repr(u8)]
|
||||
@@ -54,6 +53,17 @@ pub extern "C" fn text_editor_is_active() -> bool {
|
||||
with_state!(state, { state.text_editor_state.is_active })
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn text_editor_is_active_with_id(a: u32, b: u32, c: u32, d: u32) -> bool {
|
||||
with_state!(state, {
|
||||
let shape_id = uuid_from_u32_quartet(a, b, c, d);
|
||||
let Some(active_shape_id) = state.text_editor_state.active_shape_id else {
|
||||
return false;
|
||||
};
|
||||
state.text_editor_state.is_active && active_shape_id == shape_id
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) {
|
||||
with_state!(state, {
|
||||
@@ -70,45 +80,25 @@ pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) {
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn text_editor_select_all() {
|
||||
pub extern "C" fn text_editor_select_all() -> bool {
|
||||
with_state_mut!(state, {
|
||||
if !state.text_editor_state.is_active {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(shape_id) = state.text_editor_state.active_shape_id else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(shape) = state.shapes.get(&shape_id) else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
|
||||
let Type::Text(text_content) = &shape.shape_type else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
|
||||
let paragraphs = text_content.paragraphs();
|
||||
if paragraphs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let last_para_idx = paragraphs.len() - 1;
|
||||
let last_para = ¶graphs[last_para_idx];
|
||||
let total_chars: usize = last_para
|
||||
.children()
|
||||
.iter()
|
||||
.map(|span| span.text.chars().count())
|
||||
.sum();
|
||||
|
||||
use crate::state::TextCursor;
|
||||
state.text_editor_state.selection.anchor = TextCursor::new(0, 0);
|
||||
state.text_editor_state.selection.focus = TextCursor::new(last_para_idx, total_chars);
|
||||
state.text_editor_state.reset_blink();
|
||||
state
|
||||
.text_editor_state
|
||||
.push_event(crate::state::EditorEvent::SelectionChanged);
|
||||
});
|
||||
state.text_editor_state.select_all(text_content)
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
@@ -121,146 +111,127 @@ pub extern "C" fn text_editor_poll_event() -> u8 {
|
||||
// ============================================================================
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
|
||||
pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
|
||||
with_state_mut!(state, {
|
||||
if !state.text_editor_state.is_active {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(shape_id) = state.text_editor_state.active_shape_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (shape_matrix, view_matrix, selrect, vertical_align) = {
|
||||
let Some(shape) = state.shapes.get(&shape_id) else {
|
||||
return;
|
||||
};
|
||||
(
|
||||
shape.get_concatenated_matrix(&state.shapes),
|
||||
state.render_state.viewbox.get_matrix(),
|
||||
shape.selrect(),
|
||||
shape.vertical_align(),
|
||||
)
|
||||
};
|
||||
|
||||
let Some(inv_view_matrix) = view_matrix.invert() else {
|
||||
let Some(shape) = state.shapes.get(&shape_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(inv_shape_matrix) = shape_matrix.invert() else {
|
||||
let Type::Text(text_content) = &shape.shape_type else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut matrix = Matrix::new_identity();
|
||||
matrix.post_concat(&inv_view_matrix);
|
||||
matrix.post_concat(&inv_shape_matrix);
|
||||
|
||||
let mapped_point = matrix.map_point(Point::new(x, y));
|
||||
|
||||
let Some(shape) = state.shapes.get_mut(&shape_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Type::Text(text_content) = &mut shape.shape_type else {
|
||||
return;
|
||||
};
|
||||
|
||||
if text_content.layout.paragraphs.is_empty() && !text_content.paragraphs().is_empty() {
|
||||
let bounds = text_content.bounds;
|
||||
text_content.update_layout(bounds);
|
||||
}
|
||||
|
||||
// Calculate vertical alignment offset (same as in render/text_editor.rs)
|
||||
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
||||
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
|
||||
let vertical_offset = match vertical_align {
|
||||
crate::shapes::VerticalAlign::Center => (selrect.height() - total_height) / 2.0,
|
||||
crate::shapes::VerticalAlign::Bottom => selrect.height() - total_height,
|
||||
_ => 0.0,
|
||||
};
|
||||
|
||||
// Adjust point: subtract selrect offset and vertical alignment
|
||||
// The text layout expects coordinates where (0, 0) is the top-left of the text content
|
||||
let adjusted_point = Point::new(
|
||||
mapped_point.x - selrect.x(),
|
||||
mapped_point.y - selrect.y() - vertical_offset,
|
||||
);
|
||||
|
||||
if let Some(position) = text_content.get_caret_position_at(&adjusted_point) {
|
||||
let point = Point::new(x, y);
|
||||
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
|
||||
let shape_matrix = shape.get_matrix();
|
||||
state.text_editor_state.start_pointer_selection();
|
||||
if let Some(position) =
|
||||
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
|
||||
{
|
||||
state.text_editor_state.set_caret_from_position(position);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn text_editor_extend_selection_to_point(x: f32, y: f32) {
|
||||
pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
|
||||
with_state_mut!(state, {
|
||||
if !state.text_editor_state.is_active {
|
||||
return;
|
||||
}
|
||||
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
|
||||
let point = Point::new(x, y);
|
||||
let Some(shape_id) = state.text_editor_state.active_shape_id else {
|
||||
return;
|
||||
};
|
||||
let Some(shape) = state.shapes.get(&shape_id) else {
|
||||
return;
|
||||
};
|
||||
let shape_matrix = shape.get_matrix();
|
||||
let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !state.text_editor_state.is_pointer_selection_active {
|
||||
return;
|
||||
}
|
||||
let Type::Text(text_content) = &shape.shape_type else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(position) =
|
||||
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
|
||||
{
|
||||
state
|
||||
.text_editor_state
|
||||
.extend_selection_from_position(position);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
|
||||
with_state_mut!(state, {
|
||||
if !state.text_editor_state.is_active {
|
||||
return;
|
||||
}
|
||||
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
|
||||
let point = Point::new(x, y);
|
||||
let Some(shape_id) = state.text_editor_state.active_shape_id else {
|
||||
return;
|
||||
};
|
||||
let Some(shape) = state.shapes.get(&shape_id) else {
|
||||
return;
|
||||
};
|
||||
let shape_matrix = shape.get_matrix();
|
||||
let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !state.text_editor_state.is_pointer_selection_active {
|
||||
return;
|
||||
}
|
||||
let Type::Text(text_content) = &shape.shape_type else {
|
||||
return;
|
||||
};
|
||||
if let Some(position) =
|
||||
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
|
||||
{
|
||||
state
|
||||
.text_editor_state
|
||||
.extend_selection_from_position(&position);
|
||||
}
|
||||
state.text_editor_state.stop_pointer_selection();
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
|
||||
with_state_mut!(state, {
|
||||
if !state.text_editor_state.is_active {
|
||||
return;
|
||||
}
|
||||
|
||||
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
|
||||
let point = Point::new(x, y);
|
||||
let Some(shape_id) = state.text_editor_state.active_shape_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (shape_matrix, view_matrix, selrect, vertical_align) = {
|
||||
let Some(shape) = state.shapes.get(&shape_id) else {
|
||||
return;
|
||||
};
|
||||
(
|
||||
shape.get_concatenated_matrix(&state.shapes),
|
||||
state.render_state.viewbox.get_matrix(),
|
||||
shape.selrect(),
|
||||
shape.vertical_align(),
|
||||
)
|
||||
};
|
||||
|
||||
let Some(inv_view_matrix) = view_matrix.invert() else {
|
||||
let Some(shape) = state.shapes.get(&shape_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(inv_shape_matrix) = shape_matrix.invert() else {
|
||||
let shape_matrix = shape.get_matrix();
|
||||
let Type::Text(text_content) = &shape.shape_type else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut matrix = Matrix::new_identity();
|
||||
matrix.post_concat(&inv_view_matrix);
|
||||
matrix.post_concat(&inv_shape_matrix);
|
||||
|
||||
let mapped_point = matrix.map_point(Point::new(x, y));
|
||||
|
||||
let Some(shape) = state.shapes.get_mut(&shape_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Type::Text(text_content) = &mut shape.shape_type else {
|
||||
return;
|
||||
};
|
||||
|
||||
if text_content.layout.paragraphs.is_empty() && !text_content.paragraphs().is_empty() {
|
||||
let bounds = text_content.bounds;
|
||||
text_content.update_layout(bounds);
|
||||
}
|
||||
|
||||
// Calculate vertical alignment offset (same as in render/text_editor.rs)
|
||||
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
||||
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
|
||||
let vertical_offset = match vertical_align {
|
||||
crate::shapes::VerticalAlign::Center => (selrect.height() - total_height) / 2.0,
|
||||
crate::shapes::VerticalAlign::Bottom => selrect.height() - total_height,
|
||||
_ => 0.0,
|
||||
};
|
||||
|
||||
// Adjust point: subtract selrect offset and vertical alignment
|
||||
let adjusted_point = Point::new(
|
||||
mapped_point.x - selrect.x(),
|
||||
mapped_point.y - selrect.y() - vertical_offset,
|
||||
);
|
||||
|
||||
if let Some(position) = text_content.get_caret_position_at(&adjusted_point) {
|
||||
state
|
||||
.text_editor_state
|
||||
.extend_selection_from_position(position);
|
||||
if let Some(position) =
|
||||
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
|
||||
{
|
||||
state.text_editor_state.set_caret_from_position(&position);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -305,7 +276,7 @@ pub extern "C" fn text_editor_insert_text() {
|
||||
let cursor = state.text_editor_state.selection.focus;
|
||||
|
||||
if let Some(new_offset) = insert_text_at_cursor(text_content, &cursor, &text) {
|
||||
let new_cursor = TextCursor::new(cursor.paragraph, new_offset);
|
||||
let new_cursor = TextPositionWithAffinity::new_without_affinity(cursor.paragraph, new_offset);
|
||||
state.text_editor_state.selection.set_caret(new_cursor);
|
||||
}
|
||||
|
||||
@@ -315,10 +286,10 @@ pub extern "C" fn text_editor_insert_text() {
|
||||
state.text_editor_state.reset_blink();
|
||||
state
|
||||
.text_editor_state
|
||||
.push_event(crate::state::EditorEvent::ContentChanged);
|
||||
.push_event(crate::state::TextEditorEvent::ContentChanged);
|
||||
state
|
||||
.text_editor_state
|
||||
.push_event(crate::state::EditorEvent::NeedsLayout);
|
||||
.push_event(crate::state::TextEditorEvent::NeedsLayout);
|
||||
|
||||
state.render_state.mark_touched(shape_id);
|
||||
});
|
||||
@@ -365,10 +336,10 @@ pub extern "C" fn text_editor_delete_backward() {
|
||||
state.text_editor_state.reset_blink();
|
||||
state
|
||||
.text_editor_state
|
||||
.push_event(crate::state::EditorEvent::ContentChanged);
|
||||
.push_event(crate::state::TextEditorEvent::ContentChanged);
|
||||
state
|
||||
.text_editor_state
|
||||
.push_event(crate::state::EditorEvent::NeedsLayout);
|
||||
.push_event(crate::state::TextEditorEvent::NeedsLayout);
|
||||
|
||||
state.render_state.mark_touched(shape_id);
|
||||
});
|
||||
@@ -413,10 +384,10 @@ pub extern "C" fn text_editor_delete_forward() {
|
||||
state.text_editor_state.reset_blink();
|
||||
state
|
||||
.text_editor_state
|
||||
.push_event(crate::state::EditorEvent::ContentChanged);
|
||||
.push_event(crate::state::TextEditorEvent::ContentChanged);
|
||||
state
|
||||
.text_editor_state
|
||||
.push_event(crate::state::EditorEvent::NeedsLayout);
|
||||
.push_event(crate::state::TextEditorEvent::NeedsLayout);
|
||||
|
||||
state.render_state.mark_touched(shape_id);
|
||||
});
|
||||
@@ -452,7 +423,7 @@ pub extern "C" fn text_editor_insert_paragraph() {
|
||||
let cursor = state.text_editor_state.selection.focus;
|
||||
|
||||
if split_paragraph_at_cursor(text_content, &cursor) {
|
||||
let new_cursor = TextCursor::new(cursor.paragraph + 1, 0);
|
||||
let new_cursor = TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0);
|
||||
state.text_editor_state.selection.set_caret(new_cursor);
|
||||
}
|
||||
|
||||
@@ -462,10 +433,10 @@ pub extern "C" fn text_editor_insert_paragraph() {
|
||||
state.text_editor_state.reset_blink();
|
||||
state
|
||||
.text_editor_state
|
||||
.push_event(crate::state::EditorEvent::ContentChanged);
|
||||
.push_event(crate::state::TextEditorEvent::ContentChanged);
|
||||
state
|
||||
.text_editor_state
|
||||
.push_event(crate::state::EditorEvent::NeedsLayout);
|
||||
.push_event(crate::state::TextEditorEvent::NeedsLayout);
|
||||
|
||||
state.render_state.mark_touched(shape_id);
|
||||
});
|
||||
@@ -523,7 +494,7 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel
|
||||
state.text_editor_state.reset_blink();
|
||||
state
|
||||
.text_editor_state
|
||||
.push_event(crate::state::EditorEvent::SelectionChanged);
|
||||
.push_event(crate::state::TextEditorEvent::SelectionChanged);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -740,12 +711,12 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 {
|
||||
.map(|span| span.text.chars().count())
|
||||
.sum();
|
||||
let range_start = if para_idx == start.paragraph {
|
||||
start.char_offset
|
||||
start.offset
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let range_end = if para_idx == end.paragraph {
|
||||
end.char_offset
|
||||
end.offset
|
||||
} else {
|
||||
para_char_count
|
||||
};
|
||||
@@ -793,9 +764,9 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
|
||||
let sel = &state.text_editor_state.selection;
|
||||
unsafe {
|
||||
*buffer_ptr = sel.anchor.paragraph as u32;
|
||||
*buffer_ptr.add(1) = sel.anchor.char_offset as u32;
|
||||
*buffer_ptr.add(1) = sel.anchor.offset as u32;
|
||||
*buffer_ptr.add(2) = sel.focus.paragraph as u32;
|
||||
*buffer_ptr.add(3) = sel.focus.char_offset as u32;
|
||||
*buffer_ptr.add(3) = sel.focus.offset as u32;
|
||||
}
|
||||
1
|
||||
})
|
||||
@@ -805,7 +776,7 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
|
||||
// HELPERS: Cursor & Selection
|
||||
// ============================================================================
|
||||
|
||||
fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shape) -> Option<Rect> {
|
||||
fn get_cursor_rect(text_content: &TextContent, cursor: &TextPositionWithAffinity, shape: &Shape) -> Option<Rect> {
|
||||
let paragraphs = text_content.paragraphs();
|
||||
if cursor.paragraph >= paragraphs.len() {
|
||||
return None;
|
||||
@@ -823,7 +794,7 @@ fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shap
|
||||
let mut y_offset = valign_offset;
|
||||
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
|
||||
if idx == cursor.paragraph {
|
||||
let char_pos = cursor.char_offset;
|
||||
let char_pos = cursor.offset;
|
||||
|
||||
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
@@ -898,13 +869,13 @@ fn get_selection_rects(
|
||||
.map(|span| span.text.chars().count())
|
||||
.sum();
|
||||
let range_start = if para_idx == start.paragraph {
|
||||
start.char_offset
|
||||
start.offset
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let range_end = if para_idx == end.paragraph {
|
||||
end.char_offset
|
||||
end.offset
|
||||
} else {
|
||||
para_char_count
|
||||
};
|
||||
@@ -943,40 +914,40 @@ fn paragraph_char_count(para: &Paragraph) -> usize {
|
||||
}
|
||||
|
||||
/// Clamp a cursor position to valid bounds within the text content.
|
||||
fn clamp_cursor(cursor: TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
|
||||
fn clamp_cursor(position: TextPositionWithAffinity, paragraphs: &[Paragraph]) -> TextPositionWithAffinity {
|
||||
if paragraphs.is_empty() {
|
||||
return TextCursor::new(0, 0);
|
||||
return TextPositionWithAffinity::new_without_affinity(0, 0);
|
||||
}
|
||||
|
||||
let para_idx = cursor.paragraph.min(paragraphs.len() - 1);
|
||||
let para_idx = position.paragraph.min(paragraphs.len() - 1);
|
||||
let para_len = paragraph_char_count(¶graphs[para_idx]);
|
||||
let char_offset = cursor.char_offset.min(para_len);
|
||||
let char_offset = position.offset.min(para_len);
|
||||
|
||||
TextCursor::new(para_idx, char_offset)
|
||||
TextPositionWithAffinity::new_without_affinity(para_idx, char_offset)
|
||||
}
|
||||
|
||||
/// Move cursor left by one character.
|
||||
fn move_cursor_backward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
|
||||
if cursor.char_offset > 0 {
|
||||
TextCursor::new(cursor.paragraph, cursor.char_offset - 1)
|
||||
fn move_cursor_backward(cursor: &TextPositionWithAffinity, paragraphs: &[Paragraph]) -> TextPositionWithAffinity {
|
||||
if cursor.offset > 0 {
|
||||
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset - 1)
|
||||
} else if cursor.paragraph > 0 {
|
||||
let prev_para = cursor.paragraph - 1;
|
||||
let char_count = paragraph_char_count(¶graphs[prev_para]);
|
||||
TextCursor::new(prev_para, char_count)
|
||||
TextPositionWithAffinity::new_without_affinity(prev_para, char_count)
|
||||
} else {
|
||||
*cursor
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor right by one character.
|
||||
fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
|
||||
fn move_cursor_forward(cursor: &TextPositionWithAffinity, paragraphs: &[Paragraph]) -> TextPositionWithAffinity {
|
||||
let para = ¶graphs[cursor.paragraph];
|
||||
let char_count = paragraph_char_count(para);
|
||||
|
||||
if cursor.char_offset < char_count {
|
||||
TextCursor::new(cursor.paragraph, cursor.char_offset + 1)
|
||||
if cursor.offset < char_count {
|
||||
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset + 1)
|
||||
} else if cursor.paragraph < paragraphs.len() - 1 {
|
||||
TextCursor::new(cursor.paragraph + 1, 0)
|
||||
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0)
|
||||
} else {
|
||||
*cursor
|
||||
}
|
||||
@@ -984,52 +955,52 @@ fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCur
|
||||
|
||||
/// Move cursor up by one line.
|
||||
fn move_cursor_up(
|
||||
cursor: &TextCursor,
|
||||
cursor: &TextPositionWithAffinity,
|
||||
paragraphs: &[Paragraph],
|
||||
_text_content: &TextContent,
|
||||
_shape: &Shape,
|
||||
) -> TextCursor {
|
||||
) -> TextPositionWithAffinity {
|
||||
// TODO: Implement proper line-based navigation using line metrics
|
||||
if cursor.paragraph > 0 {
|
||||
let prev_para = cursor.paragraph - 1;
|
||||
let char_count = paragraph_char_count(¶graphs[prev_para]);
|
||||
let new_offset = cursor.char_offset.min(char_count);
|
||||
TextCursor::new(prev_para, new_offset)
|
||||
let new_offset = cursor.offset.min(char_count);
|
||||
TextPositionWithAffinity::new_without_affinity(prev_para, new_offset)
|
||||
} else {
|
||||
TextCursor::new(cursor.paragraph, 0)
|
||||
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor down by one line.
|
||||
fn move_cursor_down(
|
||||
cursor: &TextCursor,
|
||||
cursor: &TextPositionWithAffinity,
|
||||
paragraphs: &[Paragraph],
|
||||
_text_content: &TextContent,
|
||||
_shape: &Shape,
|
||||
) -> TextCursor {
|
||||
) -> TextPositionWithAffinity {
|
||||
// TODO: Implement proper line-based navigation using line metrics
|
||||
if cursor.paragraph < paragraphs.len() - 1 {
|
||||
let next_para = cursor.paragraph + 1;
|
||||
let char_count = paragraph_char_count(¶graphs[next_para]);
|
||||
let new_offset = cursor.char_offset.min(char_count);
|
||||
TextCursor::new(next_para, new_offset)
|
||||
let new_offset = cursor.offset.min(char_count);
|
||||
TextPositionWithAffinity::new_without_affinity(next_para, new_offset)
|
||||
} else {
|
||||
let char_count = paragraph_char_count(¶graphs[cursor.paragraph]);
|
||||
TextCursor::new(cursor.paragraph, char_count)
|
||||
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor to start of current line.
|
||||
fn move_cursor_line_start(cursor: &TextCursor, _paragraphs: &[Paragraph]) -> TextCursor {
|
||||
fn move_cursor_line_start(cursor: &TextPositionWithAffinity, _paragraphs: &[Paragraph]) -> TextPositionWithAffinity {
|
||||
// TODO: Implement proper line-start using line metrics
|
||||
TextCursor::new(cursor.paragraph, 0)
|
||||
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
|
||||
}
|
||||
|
||||
/// Move cursor to end of current line.
|
||||
fn move_cursor_line_end(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
|
||||
fn move_cursor_line_end(cursor: &TextPositionWithAffinity, paragraphs: &[Paragraph]) -> TextPositionWithAffinity {
|
||||
// TODO: Implement proper line-end using line metrics
|
||||
let char_count = paragraph_char_count(¶graphs[cursor.paragraph]);
|
||||
TextCursor::new(cursor.paragraph, char_count)
|
||||
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1057,7 +1028,7 @@ fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, u
|
||||
/// Insert text at a cursor position. Returns the new character offset after insertion.
|
||||
fn insert_text_at_cursor(
|
||||
text_content: &mut TextContent,
|
||||
cursor: &TextCursor,
|
||||
cursor: &TextPositionWithAffinity,
|
||||
text: &str,
|
||||
) -> Option<usize> {
|
||||
let paragraphs = text_content.paragraphs_mut();
|
||||
@@ -1077,7 +1048,7 @@ fn insert_text_at_cursor(
|
||||
return Some(text.chars().count());
|
||||
}
|
||||
|
||||
let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.char_offset)?;
|
||||
let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.offset)?;
|
||||
|
||||
let children = para.children_mut();
|
||||
let span = &mut children[span_idx];
|
||||
@@ -1092,7 +1063,7 @@ fn insert_text_at_cursor(
|
||||
new_text.insert_str(byte_offset, text);
|
||||
span.set_text(new_text);
|
||||
|
||||
Some(cursor.char_offset + text.chars().count())
|
||||
Some(cursor.offset + text.chars().count())
|
||||
}
|
||||
|
||||
/// Delete a range of text specified by a selection.
|
||||
@@ -1108,18 +1079,18 @@ fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelect
|
||||
if start.paragraph == end.paragraph {
|
||||
delete_range_in_paragraph(
|
||||
&mut paragraphs[start.paragraph],
|
||||
start.char_offset,
|
||||
end.char_offset,
|
||||
start.offset,
|
||||
end.offset,
|
||||
);
|
||||
} else {
|
||||
let start_para_len = paragraph_char_count(¶graphs[start.paragraph]);
|
||||
delete_range_in_paragraph(
|
||||
&mut paragraphs[start.paragraph],
|
||||
start.char_offset,
|
||||
start.offset,
|
||||
start_para_len,
|
||||
);
|
||||
|
||||
delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.char_offset);
|
||||
delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset);
|
||||
|
||||
if end.paragraph < paragraphs.len() {
|
||||
let end_para_children: Vec<_> =
|
||||
@@ -1218,13 +1189,13 @@ fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offs
|
||||
}
|
||||
|
||||
/// Delete the character before the cursor. Returns the new cursor position.
|
||||
fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Option<TextCursor> {
|
||||
if cursor.char_offset > 0 {
|
||||
fn delete_char_before(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) -> Option<TextPositionWithAffinity> {
|
||||
if cursor.offset > 0 {
|
||||
let paragraphs = text_content.paragraphs_mut();
|
||||
let para = &mut paragraphs[cursor.paragraph];
|
||||
let delete_pos = cursor.char_offset - 1;
|
||||
delete_range_in_paragraph(para, delete_pos, cursor.char_offset);
|
||||
Some(TextCursor::new(cursor.paragraph, delete_pos))
|
||||
let delete_pos = cursor.offset - 1;
|
||||
delete_range_in_paragraph(para, delete_pos, cursor.offset);
|
||||
Some(TextPositionWithAffinity::new_without_affinity(cursor.paragraph, delete_pos))
|
||||
} else if cursor.paragraph > 0 {
|
||||
let prev_para_idx = cursor.paragraph - 1;
|
||||
let paragraphs = text_content.paragraphs_mut();
|
||||
@@ -1240,14 +1211,14 @@ fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Op
|
||||
|
||||
paragraphs.remove(cursor.paragraph);
|
||||
|
||||
Some(TextCursor::new(prev_para_idx, prev_para_len))
|
||||
Some(TextPositionWithAffinity::new_without_affinity(prev_para_idx, prev_para_len))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the character after the cursor.
|
||||
fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
|
||||
fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) {
|
||||
let paragraphs = text_content.paragraphs_mut();
|
||||
if cursor.paragraph >= paragraphs.len() {
|
||||
return;
|
||||
@@ -1255,9 +1226,9 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
|
||||
|
||||
let para_len = paragraph_char_count(¶graphs[cursor.paragraph]);
|
||||
|
||||
if cursor.char_offset < para_len {
|
||||
if cursor.offset < para_len {
|
||||
let para = &mut paragraphs[cursor.paragraph];
|
||||
delete_range_in_paragraph(para, cursor.char_offset, cursor.char_offset + 1);
|
||||
delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1);
|
||||
} else if cursor.paragraph < paragraphs.len() - 1 {
|
||||
let next_para_idx = cursor.paragraph + 1;
|
||||
let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect();
|
||||
@@ -1270,7 +1241,7 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
|
||||
}
|
||||
|
||||
/// Split a paragraph at the cursor position. Returns true if split was successful.
|
||||
fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor) -> bool {
|
||||
fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) -> bool {
|
||||
let paragraphs = text_content.paragraphs_mut();
|
||||
if cursor.paragraph >= paragraphs.len() {
|
||||
return false;
|
||||
@@ -1278,7 +1249,7 @@ fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor
|
||||
|
||||
let para = ¶graphs[cursor.paragraph];
|
||||
|
||||
let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.char_offset) else {
|
||||
let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user