mirror of
https://github.com/penpot/penpot.git
synced 2026-03-06 00:06:07 -05:00
Compare commits
58 Commits
niwinz-dev
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5102ae2a58 | ||
|
|
de5276d638 | ||
|
|
4da9aa844b | ||
|
|
1ce295f5e5 | ||
|
|
c9d9e493e7 | ||
|
|
eb5b3a3fe5 | ||
|
|
9de591d9d7 | ||
|
|
cc2c104e16 | ||
|
|
0b8ac2508e | ||
|
|
c35f70edc5 | ||
|
|
c18375c66e | ||
|
|
23e77b5f03 | ||
|
|
7067cc2286 | ||
|
|
0644bd817e | ||
|
|
b587e2e8ec | ||
|
|
d61e57099e | ||
|
|
97d3e31593 | ||
|
|
740e790585 | ||
|
|
8882f18db4 | ||
|
|
a2f8fca6ea | ||
|
|
ed23c55550 | ||
|
|
5b5c868a87 | ||
|
|
35c829a981 | ||
|
|
b5874b365b | ||
|
|
1a3ac6bdf8 | ||
|
|
de5d4f4292 | ||
|
|
2bd7c10e09 | ||
|
|
7066afa01a | ||
|
|
495371c079 | ||
|
|
75b1c0c1b1 | ||
|
|
5ea4b03108 | ||
|
|
0fef5b7e5d | ||
|
|
8a1fdd9dd1 | ||
|
|
a080a9e646 | ||
|
|
a728d5a5f2 | ||
|
|
6072234230 | ||
|
|
41f2877801 | ||
|
|
e2576d049a | ||
|
|
4db9c373e6 | ||
|
|
09a9407867 | ||
|
|
7be03e2ea6 | ||
|
|
9345902a62 | ||
|
|
a4190df073 | ||
|
|
47dae090ed | ||
|
|
05165ce014 | ||
|
|
96677713fc | ||
|
|
0974bca2c0 | ||
|
|
927455926f | ||
|
|
a12b59d101 | ||
|
|
4e577d37b8 | ||
|
|
40fb4edc4a | ||
|
|
e305ad1fa8 | ||
|
|
f43de05d3d | ||
|
|
d019972bca | ||
|
|
7fceb92673 | ||
|
|
426053ac17 | ||
|
|
a5da7ceb2f | ||
|
|
a7e3e78e0c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -69,7 +69,7 @@
|
|||||||
/frontend/test-results/
|
/frontend/test-results/
|
||||||
/other/
|
/other/
|
||||||
/scripts/
|
/scripts/
|
||||||
/nexus/
|
/telemetry/
|
||||||
/tmp/
|
/tmp/
|
||||||
/vendor/**/target
|
/vendor/**/target
|
||||||
/vendor/svgclean/bundle*.js
|
/vendor/svgclean/bundle*.js
|
||||||
|
|||||||
@@ -16,10 +16,12 @@
|
|||||||
- Option to download custom fonts (by @dfelinto) [Github #8320](https://github.com/penpot/penpot/issues/8320)
|
- 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)
|
- Add copy as image to clipboard option to workspace context menu (by @dfelinto) [Github #8313](https://github.com/penpot/penpot/pull/8313)
|
||||||
- Import Tokens from linked library [Github #8391](https://github.com/penpot/penpot/pull/8391)
|
- Import Tokens from linked library [Github #8391](https://github.com/penpot/penpot/pull/8391)
|
||||||
|
- Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8474)
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
- Fix Alt/Option to draw shapes from center point (by @offreal) [Github #8361](https://github.com/penpot/penpot/pull/8361)
|
- Fix Alt/Option to draw shapes from center point (by @offreal) [Github #8361](https://github.com/penpot/penpot/pull/8361)
|
||||||
|
- Add token name on broken token pill on sidebar [Taiga #13527](https://tree.taiga.io/project/penpot/issue/13527)
|
||||||
|
|
||||||
|
|
||||||
## 2.14.0 (Unreleased)
|
## 2.14.0 (Unreleased)
|
||||||
@@ -55,6 +57,7 @@
|
|||||||
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
|
- 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)
|
- Fix unable to finish the create account form using keyboard [Taiga #11333](https://tree.taiga.io/project/penpot/issue/11333)
|
||||||
- Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306)
|
- Fix 45 rotated board titles rendered incorrectly [Taiga #13306](https://tree.taiga.io/project/penpot/issue/13306)
|
||||||
|
- Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513)
|
||||||
|
|
||||||
## 2.13.3
|
## 2.13.3
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
|
export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
|
||||||
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
|
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
|
||||||
export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key
|
|
||||||
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
export PENPOT_SECRET_KEY=super-secret-devenv-key
|
||||||
|
|
||||||
# DEPRECATED: only used for subscriptions
|
# DEPRECATED: only used for subscriptions
|
||||||
|
|||||||
@@ -103,7 +103,6 @@
|
|||||||
|
|
||||||
[:exporter-shared-key {:optional true} :string]
|
[:exporter-shared-key {:optional true} :string]
|
||||||
[:nitrate-shared-key {:optional true} :string]
|
[:nitrate-shared-key {:optional true} :string]
|
||||||
[:nexus-shared-key {:optional true} :string]
|
|
||||||
[:management-api-key {:optional true} :string]
|
[:management-api-key {:optional true} :string]
|
||||||
|
|
||||||
[:telemetry-uri {:optional true} :string]
|
[:telemetry-uri {:optional true} :string]
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
;; an external storage and data cleared.
|
;; an external storage and data cleared.
|
||||||
|
|
||||||
(def ^:private schema:event
|
(def ^:private schema:event
|
||||||
[:map {:title "AuditEvent"}
|
[:map {:title "event"}
|
||||||
[::type ::sm/text]
|
[::type ::sm/text]
|
||||||
[::name ::sm/text]
|
[::name ::sm/text]
|
||||||
[::profile-id ::sm/uuid]
|
[::profile-id ::sm/uuid]
|
||||||
|
|||||||
@@ -10,11 +10,14 @@
|
|||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.transit :as t]
|
[app.common.transit :as t]
|
||||||
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.http.client :as http]
|
[app.http.client :as http]
|
||||||
[app.setup :as-alias setup]
|
[app.setup :as-alias setup]
|
||||||
|
[app.tokens :as tokens]
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
|
[lambdaisland.uri :as u]
|
||||||
[promesa.exec :as px]))
|
[promesa.exec :as px]))
|
||||||
|
|
||||||
;; This is a task responsible to send the accumulated events to
|
;; This is a task responsible to send the accumulated events to
|
||||||
@@ -49,18 +52,19 @@
|
|||||||
|
|
||||||
(defn- send!
|
(defn- send!
|
||||||
[{:keys [::uri] :as cfg} events]
|
[{:keys [::uri] :as cfg} events]
|
||||||
(let [skey (-> cfg ::setup/shared-keys :nexus)
|
(let [token (tokens/generate cfg
|
||||||
|
{:iss "authentication"
|
||||||
|
:uid uuid/zero})
|
||||||
body (t/encode {:events events})
|
body (t/encode {:events events})
|
||||||
headers {"content-type" "application/transit+json"
|
headers {"content-type" "application/transit+json"
|
||||||
"origin" (str (cf/get :public-uri))
|
"origin" (str (cf/get :public-uri))
|
||||||
"x-shared-key" (str "nexus " skey)}
|
"cookie" (u/map->query-string {:auth-token token})}
|
||||||
params {:uri uri
|
params {:uri uri
|
||||||
:timeout 12000
|
:timeout 12000
|
||||||
:method :post
|
:method :post
|
||||||
:headers headers
|
:headers headers
|
||||||
:body body}
|
:body body}
|
||||||
resp (http/req! cfg params)]
|
resp (http/req! cfg params)]
|
||||||
|
|
||||||
(if (= (:status resp) 204)
|
(if (= (:status resp) 204)
|
||||||
true
|
true
|
||||||
(do
|
(do
|
||||||
@@ -105,7 +109,7 @@
|
|||||||
(def ^:private schema:handler-params
|
(def ^:private schema:handler-params
|
||||||
[:map
|
[:map
|
||||||
::db/pool
|
::db/pool
|
||||||
::setup/shared-keys
|
::setup/props
|
||||||
::http/client])
|
::http/client])
|
||||||
|
|
||||||
(defmethod ig/assert-key ::handler
|
(defmethod ig/assert-key ::handler
|
||||||
|
|||||||
@@ -466,7 +466,6 @@
|
|||||||
|
|
||||||
::setup/shared-keys
|
::setup/shared-keys
|
||||||
{::setup/props (ig/ref ::setup/props)
|
{::setup/props (ig/ref ::setup/props)
|
||||||
:nexus (cf/get :nexus-shared-key)
|
|
||||||
:nitrate (cf/get :nitrate-shared-key)
|
:nitrate (cf/get :nitrate-shared-key)
|
||||||
:exporter (cf/get :exporter-shared-key)}
|
:exporter (cf/get :exporter-shared-key)}
|
||||||
|
|
||||||
@@ -474,9 +473,9 @@
|
|||||||
{}
|
{}
|
||||||
|
|
||||||
:app.loggers.audit.archive-task/handler
|
:app.loggers.audit.archive-task/handler
|
||||||
{::setup/shared-keys (ig/ref ::setup/shared-keys)
|
{::setup/props (ig/ref ::setup/props)
|
||||||
::http.client/client (ig/ref ::http.client/client)
|
::db/pool (ig/ref ::db/pool)
|
||||||
::db/pool (ig/ref ::db/pool)}
|
::http.client/client (ig/ref ::http.client/client)}
|
||||||
|
|
||||||
:app.loggers.audit.gc-task/handler
|
:app.loggers.audit.gc-task/handler
|
||||||
{::db/pool (ig/ref ::db/pool)}
|
{::db/pool (ig/ref ::db/pool)}
|
||||||
|
|||||||
@@ -58,4 +58,3 @@
|
|||||||
(when (nil? (:data file))
|
(when (nil? (:data file))
|
||||||
(migrate-file conn file)))
|
(migrate-file conn file)))
|
||||||
(db/exec-one! conn ["drop table page cascade;"])))
|
(db/exec-one! conn ["drop table page cascade;"])))
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
||||||
[app.common.types.team :refer [schema:team]]
|
[app.common.types.team :refer [schema:team]]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.msgbus :as mbus]
|
[app.msgbus :as mbus]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
@@ -45,6 +46,19 @@
|
|||||||
AND t.is_default IS FALSE
|
AND t.is_default IS FALSE
|
||||||
AND t.deleted_at IS NULL;")
|
AND t.deleted_at IS NULL;")
|
||||||
|
|
||||||
|
;; ---- API: get-penpot-version
|
||||||
|
|
||||||
|
(def ^:private schema:get-penpot-version-result
|
||||||
|
[:map [:version ::sm/text]])
|
||||||
|
|
||||||
|
(sv/defmethod ::get-penpot-version
|
||||||
|
"Get the current Penpot version"
|
||||||
|
{::doc/added "2.14"
|
||||||
|
::sm/params [:map]
|
||||||
|
::sm/result schema:get-penpot-version-result}
|
||||||
|
[_cfg _params]
|
||||||
|
{:version cf/version})
|
||||||
|
|
||||||
(def ^:private schema:get-teams-result
|
(def ^:private schema:get-teams-result
|
||||||
[:vector schema:team])
|
[:vector schema:team])
|
||||||
|
|
||||||
|
|||||||
@@ -82,10 +82,8 @@
|
|||||||
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
||||||
(db/xact-lock! conn 0)
|
(db/xact-lock! conn 0)
|
||||||
(when-not key
|
(when-not key
|
||||||
(l/wrn :hint (str "using autogenerated secret-key, it will change "
|
(l/warn :hint (str "using autogenerated secret-key, it will change on each restart and will invalidate "
|
||||||
"on each restart and will invalidate "
|
"all sessions on each restart, it is highly recommended setting up the "
|
||||||
"all sessions on each restart, it is highly "
|
|
||||||
"recommended setting up the "
|
|
||||||
"PENPOT_SECRET_KEY environment variable")))
|
"PENPOT_SECRET_KEY environment variable")))
|
||||||
(let [secret (or key (generate-random-key))]
|
(let [secret (or key (generate-random-key))]
|
||||||
(-> (get-all-props conn)
|
(-> (get-all-props conn)
|
||||||
@@ -93,26 +91,36 @@
|
|||||||
(assoc :tokens-key (keys/derive secret :salt "tokens"))
|
(assoc :tokens-key (keys/derive secret :salt "tokens"))
|
||||||
(update :instance-id handle-instance-id conn (db/read-only? pool)))))))
|
(update :instance-id handle-instance-id conn (db/read-only? pool)))))))
|
||||||
|
|
||||||
|
(sm/register! ::props [:map-of :keyword ::sm/any])
|
||||||
|
|
||||||
|
|
||||||
(defmethod ig/init-key ::shared-keys
|
(defmethod ig/init-key ::shared-keys
|
||||||
[_ {:keys [::props] :as cfg}]
|
[_ {:keys [::props] :as cfg}]
|
||||||
(let [secret (get props :secret-key)]
|
(let [secret (get props :secret-key)]
|
||||||
(reduce (fn [keys id]
|
(d/without-nils
|
||||||
(let [key (or (get cfg id)
|
{:exporter
|
||||||
(-> (keys/derive secret :salt (name id))
|
(let [key (or (get cfg :exporter)
|
||||||
|
(-> (keys/derive secret :salt "exporter")
|
||||||
(bc/bytes->b64-str true)))]
|
(bc/bytes->b64-str true)))]
|
||||||
(if (or (str/empty? key)
|
(if (or (str/empty? key)
|
||||||
(str/blank? key))
|
(str/blank? key))
|
||||||
(do
|
(do
|
||||||
(l/wrn :id (name id) :hint "key is disabled because empty string found")
|
(l/wrn :hint "exporter key is disabled because empty string found")
|
||||||
keys)
|
nil)
|
||||||
(do
|
(do
|
||||||
(l/inf :id (name id) :hint "key initialized" :key (d/obfuscate-string key))
|
(l/inf :hint "exporter key initialized" :key (d/obfuscate-string key))
|
||||||
(assoc keys id key)))))
|
key)))
|
||||||
{}
|
|
||||||
[:exporter
|
|
||||||
:nitrate
|
:nitrate
|
||||||
:nexus])))
|
(let [key (or (get cfg :nitrate)
|
||||||
|
(-> (keys/derive secret :salt "nitrate")
|
||||||
(sm/register! ::props [:map-of :keyword ::sm/any])
|
(bc/bytes->b64-str true)))]
|
||||||
(sm/register! ::shared-keys [:map-of :keyword ::sm/text])
|
(if (or (str/empty? key)
|
||||||
|
(str/blank? key))
|
||||||
|
(do
|
||||||
|
(l/wrn :hint "nitrate key is disabled because empty string found")
|
||||||
|
nil)
|
||||||
|
(do
|
||||||
|
(l/inf :hint "nitrate key initialized" :key (d/obfuscate-string key))
|
||||||
|
key)))})))
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
;; Copyright (c) KALEIDOS INC
|
;; Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
(ns app.common.schema
|
(ns app.common.schema
|
||||||
(:refer-clojure :exclude [deref merge parse-uuid parse-long parse-double parse-boolean type keys select-keys])
|
(:refer-clojure :exclude [deref merge parse-uuid parse-long parse-double parse-boolean type keys])
|
||||||
#?(:cljs (:require-macros [app.common.schema :refer [ignoring]]))
|
#?(:cljs (:require-macros [app.common.schema :refer [ignoring]]))
|
||||||
(:require
|
(:require
|
||||||
#?(:clj [malli.dev.pretty :as mdp])
|
#?(:clj [malli.dev.pretty :as mdp])
|
||||||
@@ -93,11 +93,6 @@
|
|||||||
[& items]
|
[& items]
|
||||||
(apply mu/merge (map schema items)))
|
(apply mu/merge (map schema items)))
|
||||||
|
|
||||||
(defn select-keys
|
|
||||||
[s keys & {:as opts}]
|
|
||||||
(let [s (schema s)]
|
|
||||||
(mu/select-keys s keys opts)))
|
|
||||||
|
|
||||||
(defn assoc-key
|
(defn assoc-key
|
||||||
"Add a key & value to a schema of type [:map]. If the first level node of the schema
|
"Add a key & value to a schema of type [:map]. If the first level node of the schema
|
||||||
is not a map, will do a depth search to find the first map node and add the key there."
|
is not a map, will do a depth search to find the first map node and add the key there."
|
||||||
@@ -143,10 +138,10 @@
|
|||||||
(mu/optional-keys schema keys default-options)))
|
(mu/optional-keys schema keys default-options)))
|
||||||
|
|
||||||
(defn required-keys
|
(defn required-keys
|
||||||
([s]
|
([schema]
|
||||||
(mu/required-keys (schema s) nil default-options))
|
(mu/required-keys schema nil default-options))
|
||||||
([s keys]
|
([schema keys]
|
||||||
(mu/required-keys (schema s) keys default-options)))
|
(mu/required-keys schema keys default-options)))
|
||||||
|
|
||||||
(defn transformer
|
(defn transformer
|
||||||
[& transformers]
|
[& transformers]
|
||||||
@@ -651,7 +646,7 @@
|
|||||||
{:title "set"
|
{:title "set"
|
||||||
:description "Set of Strings"
|
:description "Set of Strings"
|
||||||
:error/message "should be a set of strings"
|
:error/message "should be a set of strings"
|
||||||
:gen/gen (sg/mcat (fn [_] (sg/generator kind)) sg/int)
|
:gen/gen (-> kind sg/generator sg/set)
|
||||||
:decode/string decode
|
:decode/string decode
|
||||||
:decode/json decode
|
:decode/json decode
|
||||||
:encode/string encode-string
|
:encode/string encode-string
|
||||||
@@ -868,7 +863,7 @@
|
|||||||
(defn parse-boolean
|
(defn parse-boolean
|
||||||
[v]
|
[v]
|
||||||
(if (string? v)
|
(if (string? v)
|
||||||
(case v
|
(case (str/lower v)
|
||||||
("true" "t" "1") true
|
("true" "t" "1") true
|
||||||
("false" "f" "0") false
|
("false" "f" "0") false
|
||||||
v)
|
v)
|
||||||
|
|||||||
@@ -219,9 +219,6 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG
|
|||||||
|
|
||||||
<h3 id="design-tokens-sizing">Sizing</h3>
|
<h3 id="design-tokens-sizing">Sizing</h3>
|
||||||
<p>Sizing tokens can define various size-related design properties, namely the height and width of design elements.The sizing token supports numeric values, which include negative values.</p>
|
<p>Sizing tokens can define various size-related design properties, namely the height and width of design elements.The sizing token supports numeric values, which include negative values.</p>
|
||||||
<figure>
|
|
||||||
<img src="/img/design-tokens/11-tokens-spacing.webp" alt="Tokens spacing" />
|
|
||||||
</figure>
|
|
||||||
<h4>Applying Sizing Tokens</h4>
|
<h4>Applying Sizing Tokens</h4>
|
||||||
<p>To apply the sizing token to an element, select the element and choose the token from the list:</p>
|
<p>To apply the sizing token to an element, select the element and choose the token from the list:</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -245,6 +242,9 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG
|
|||||||
|
|
||||||
<h3 id="design-tokens-spacing">Spacing</h3>
|
<h3 id="design-tokens-spacing">Spacing</h3>
|
||||||
<p>The spacing token defines the distance between design elements and supports numeric values, which include negative values. Spacing tokens must be applied to Flex Layout boards. </p>
|
<p>The spacing token defines the distance between design elements and supports numeric values, which include negative values. Spacing tokens must be applied to Flex Layout boards. </p>
|
||||||
|
<figure>
|
||||||
|
<img src="/img/design-tokens/11-tokens-spacing.webp" alt="Tokens spacing" />
|
||||||
|
</figure>
|
||||||
<p class="advice">If you apply the token to a board before flex-layout is applied to it, you may have to remove and re-apply the token for it to take effect.</p>
|
<p class="advice">If you apply the token to a board before flex-layout is applied to it, you may have to remove and re-apply the token for it to take effect.</p>
|
||||||
<h4>Applying Spacing Tokens</h4>
|
<h4>Applying Spacing Tokens</h4>
|
||||||
<p>To apply the spacing token to an element, select the element and choose the token from the list:</p>
|
<p>To apply the spacing token to an element, select the element and choose the token from the list:</p>
|
||||||
|
|||||||
@@ -404,6 +404,8 @@ export class WorkspacePage extends BaseWebSocketPage {
|
|||||||
return content !== "";
|
return content !== "";
|
||||||
}, { timeout: 1000 });
|
}, { timeout: 1000 });
|
||||||
|
|
||||||
|
await this.page.waitForTimeout(3000);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -417,7 +419,8 @@ export class WorkspacePage extends BaseWebSocketPage {
|
|||||||
await this.viewport.click({ button: "right" });
|
await this.viewport.click({ button: "right" });
|
||||||
return this.page.getByText("Paste", { exact: true }).click();
|
return this.page.getByText("Paste", { exact: true }).click();
|
||||||
}
|
}
|
||||||
return this.page.keyboard.press("ControlOrMeta+V");
|
await this.page.keyboard.press("ControlOrMeta+V");
|
||||||
|
await this.page.waitForTimeout(3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async panOnViewportAt(x, y, width, height) {
|
async panOnViewportAt(x, y, width, height) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ test.beforeEach(async ({ page, context }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(async ({ context }) => {
|
test.afterEach(async ({ context }) => {
|
||||||
context.clearPermissions();
|
await context.clearPermissions();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Create a new text shape", async ({ page }) => {
|
test("Create a new text shape", async ({ page }) => {
|
||||||
@@ -27,7 +27,7 @@ test("Create a new text shape", async ({ page }) => {
|
|||||||
await workspace.waitForSelectedShapeName(initialText);
|
await workspace.waitForSelectedShapeName(initialText);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Create a new text shape from pasting text", async ({ page, context }) => {
|
test("Create a new text shape from pasting text", async ({ page }) => {
|
||||||
const textToPaste = "Lorem ipsum";
|
const textToPaste = "Lorem ipsum";
|
||||||
const workspace = new WasmWorkspacePage(page, {
|
const workspace = new WasmWorkspacePage(page, {
|
||||||
textEditor: true,
|
textEditor: true,
|
||||||
@@ -49,7 +49,6 @@ test("Create a new text shape from pasting text", async ({ page, context }) => {
|
|||||||
|
|
||||||
test("Create a new text shape from pasting text using context menu", async ({
|
test("Create a new text shape from pasting text using context menu", async ({
|
||||||
page,
|
page,
|
||||||
context,
|
|
||||||
}) => {
|
}) => {
|
||||||
const textToPaste = "Lorem ipsum";
|
const textToPaste = "Lorem ipsum";
|
||||||
const workspace = new WasmWorkspacePage(page, {
|
const workspace = new WasmWorkspacePage(page, {
|
||||||
@@ -122,7 +121,6 @@ test.skip("Update an already created text shape by inserting text in between", a
|
|||||||
|
|
||||||
test("Update a new text shape appending text by pasting text", async ({
|
test("Update a new text shape appending text by pasting text", async ({
|
||||||
page,
|
page,
|
||||||
context,
|
|
||||||
}) => {
|
}) => {
|
||||||
const textToPaste = " dolor sit amet";
|
const textToPaste = " dolor sit amet";
|
||||||
const workspace = new WasmWorkspacePage(page, {
|
const workspace = new WasmWorkspacePage(page, {
|
||||||
@@ -144,7 +142,6 @@ test("Update a new text shape appending text by pasting text", async ({
|
|||||||
|
|
||||||
test.skip("Update a new text shape prepending text by pasting text", async ({
|
test.skip("Update a new text shape prepending text by pasting text", async ({
|
||||||
page,
|
page,
|
||||||
context,
|
|
||||||
}) => {
|
}) => {
|
||||||
const textToPaste = "Dolor sit amet ";
|
const textToPaste = "Dolor sit amet ";
|
||||||
const workspace = new WasmWorkspacePage(page, {
|
const workspace = new WasmWorkspacePage(page, {
|
||||||
|
|||||||
@@ -910,7 +910,7 @@ test.describe("Tokens: Detach token", () => {
|
|||||||
await expect(page.getByText("Don't remap")).toBeVisible();
|
await expect(page.getByText("Don't remap")).toBeVisible();
|
||||||
await page.getByText("Don't remap").click();
|
await page.getByText("Don't remap").click();
|
||||||
const brokenPill = borderRadiusSection.getByRole("button", {
|
const brokenPill = borderRadiusSection.getByRole("button", {
|
||||||
name: "This token is not in any",
|
name: "is not in any active set",
|
||||||
});
|
});
|
||||||
await expect(brokenPill).toBeVisible();
|
await expect(brokenPill).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ test.beforeEach(async ({ page, context }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(async ({ context }) => {
|
test.afterEach(async ({ context }) => {
|
||||||
context.clearPermissions();
|
await context.clearPermissions();
|
||||||
});
|
});
|
||||||
|
|
||||||
const setupVariantsFile = async (workspacePage) => {
|
const setupVariantsFile = async (workspacePage) => {
|
||||||
@@ -176,7 +176,7 @@ test("User duplicates a variant container", async ({ page }) => {
|
|||||||
await validateVariant(variant_duplicate);
|
await validateVariant(variant_duplicate);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("User copy paste a variant container", async ({ page, context }) => {
|
test("User copy paste a variant container", async ({ page }) => {
|
||||||
const workspacePage = new WasmWorkspacePage(page);
|
const workspacePage = new WasmWorkspacePage(page);
|
||||||
// Access to the read/write clipboard necesary for this functionality
|
// Access to the read/write clipboard necesary for this functionality
|
||||||
await setupVariantsFileWithVariant(workspacePage);
|
await setupVariantsFileWithVariant(workspacePage);
|
||||||
@@ -383,24 +383,26 @@ test("User cut paste a component with path inside a variant", async ({
|
|||||||
|
|
||||||
const variant = await findVariant(workspacePage, 0);
|
const variant = await findVariant(workspacePage, 0);
|
||||||
|
|
||||||
//Create a component
|
// Create a component
|
||||||
await workspacePage.ellipseShapeButton.click();
|
await workspacePage.ellipseShapeButton.click();
|
||||||
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
|
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
|
||||||
await workspacePage.clickLeafLayer("Ellipse");
|
await workspacePage.clickLeafLayer("Ellipse");
|
||||||
await workspacePage.page.keyboard.press("ControlOrMeta+k");
|
await workspacePage.page.keyboard.press("ControlOrMeta+k");
|
||||||
|
await workspacePage.page.waitForTimeout(3000);
|
||||||
|
|
||||||
//Rename the component
|
// Rename the component
|
||||||
await workspacePage.layers.getByText("Ellipse").dblclick();
|
await workspacePage.layers.getByText("Ellipse").dblclick();
|
||||||
await workspacePage.page
|
await workspacePage.page
|
||||||
.getByTestId("layer-item")
|
.getByTestId("layer-item")
|
||||||
.getByRole("textbox")
|
.getByRole("textbox")
|
||||||
.pressSequentially("button / hover");
|
.pressSequentially("button / hover");
|
||||||
await workspacePage.page.keyboard.press("Enter");
|
await workspacePage.page.keyboard.press("Enter");
|
||||||
|
await workspacePage.page.waitForTimeout(3000);
|
||||||
|
|
||||||
//Cut the component
|
// Cut the component
|
||||||
await workspacePage.cut("keyboard");
|
await workspacePage.cut("keyboard");
|
||||||
|
|
||||||
//Paste the component inside the variant
|
// Paste the component inside the variant
|
||||||
await variant.container.click();
|
await variant.container.click();
|
||||||
await workspacePage.paste("keyboard");
|
await workspacePage.paste("keyboard");
|
||||||
|
|
||||||
@@ -427,6 +429,7 @@ test("User drag and drop a component with path inside a variant", async ({
|
|||||||
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
|
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
|
||||||
await workspacePage.clickLeafLayer("Ellipse");
|
await workspacePage.clickLeafLayer("Ellipse");
|
||||||
await workspacePage.page.keyboard.press("ControlOrMeta+k");
|
await workspacePage.page.keyboard.press("ControlOrMeta+k");
|
||||||
|
await workspacePage.page.waitForTimeout(3000);
|
||||||
|
|
||||||
//Rename the component
|
//Rename the component
|
||||||
await workspacePage.layers.getByText("Ellipse").dblclick();
|
await workspacePage.layers.getByText("Ellipse").dblclick();
|
||||||
|
|||||||
BIN
frontend/resources/images/features/2.14-api.gif
Normal file
BIN
frontend/resources/images/features/2.14-api.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 864 KiB |
BIN
frontend/resources/images/features/2.14-icons.gif
Normal file
BIN
frontend/resources/images/features/2.14-icons.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
frontend/resources/images/features/2.14-remap.jpg
Normal file
BIN
frontend/resources/images/features/2.14-remap.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 241 KiB |
BIN
frontend/resources/images/features/2.14-slide-0.jpg
Normal file
BIN
frontend/resources/images/features/2.14-slide-0.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
frontend/resources/images/features/2.14-tokens-fold.gif
Normal file
BIN
frontend/resources/images/features/2.14-tokens-fold.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
@@ -1,10 +1,12 @@
|
|||||||
(ns app.main.data.nitrate
|
(ns app.main.data.nitrate
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
|
[app.common.uri :as u]
|
||||||
|
[app.config :as cf]
|
||||||
[app.main.data.modal :as modal]
|
[app.main.data.modal :as modal]
|
||||||
[app.main.repo :as rp]
|
[app.main.repo :as rp]
|
||||||
[app.main.router :as rt]
|
[app.main.router :as rt]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
[app.util.dom :as dom]
|
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[potok.v2.core :as ptk]))
|
[potok.v2.core :as ptk]))
|
||||||
|
|
||||||
@@ -15,14 +17,26 @@
|
|||||||
(watch [_ _ _]
|
(watch [_ _ _]
|
||||||
(->> (rp/cmd! ::get-nitrate-connectivity {})
|
(->> (rp/cmd! ::get-nitrate-connectivity {})
|
||||||
(rx/map (fn [connectivity]
|
(rx/map (fn [connectivity]
|
||||||
|
(prn "connectivity" connectivity)
|
||||||
(modal/show popup-type (or connectivity {}))))))))
|
(modal/show popup-type (or connectivity {}))))))))
|
||||||
|
|
||||||
(defn go-to-nitrate-cc
|
(defn go-to-nitrate-cc
|
||||||
[]
|
[]
|
||||||
(st/emit! (dom/open-new-window "/control-center/")))
|
(st/emit! (rt/nav-raw :href "/control-center/")))
|
||||||
|
|
||||||
(defn go-to-nitrate-billing
|
(defn go-to-nitrate-billing
|
||||||
[]
|
[]
|
||||||
(st/emit! (rt/nav-raw :href "/control-center/licenses/billing")))
|
(st/emit! (rt/nav-raw :href "/control-center/licenses/billing")))
|
||||||
|
|
||||||
|
(defn go-to-buy-nitrate-license
|
||||||
|
([subscription]
|
||||||
|
(go-to-buy-nitrate-license subscription nil))
|
||||||
|
([subscription callback]
|
||||||
|
(let [params (cond-> {:subscription subscription}
|
||||||
|
callback (assoc :callback callback))
|
||||||
|
href (dm/str "/control-center/licenses/start?" (u/map->query-string params))]
|
||||||
|
(st/emit! (rt/nav-raw :href href)))))
|
||||||
|
|
||||||
|
(def go-to-subscription-url (u/join cf/public-uri "#/settings/subscriptions"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -620,7 +620,14 @@
|
|||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
;; We do not allow to apply tokens while text editor is open.
|
;; We do not allow to apply tokens while text editor is open.
|
||||||
(when (empty? (get state :workspace-editor-state))
|
;; The classic text editor sets :workspace-editor-state; the WASM text editor
|
||||||
|
;; does not, so we also check :workspace-local :edition for text shapes.
|
||||||
|
(let [edition (get-in state [:workspace-local :edition])
|
||||||
|
objects (dsh/lookup-page-objects state)
|
||||||
|
text-editing? (and (some? edition)
|
||||||
|
(= :text (:type (get objects edition))))]
|
||||||
|
(if (and (empty? (get state :workspace-editor-state))
|
||||||
|
(not text-editing?))
|
||||||
(let [attributes-to-remove
|
(let [attributes-to-remove
|
||||||
;; Remove atomic typography tokens when applying composite and vice-verca
|
;; Remove atomic typography tokens when applying composite and vice-verca
|
||||||
(cond
|
(cond
|
||||||
@@ -674,7 +681,12 @@
|
|||||||
(if (rx/observable? res)
|
(if (rx/observable? res)
|
||||||
res
|
res
|
||||||
(rx/of res))))
|
(rx/of res))))
|
||||||
(rx/of (dwu/commit-undo-transaction undo-id)))))))))))))
|
(rx/of (dwu/commit-undo-transaction undo-id)))))))))
|
||||||
|
|
||||||
|
(rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition")
|
||||||
|
:type :toast
|
||||||
|
:level :warning
|
||||||
|
:timeout 3000})))))))
|
||||||
|
|
||||||
(defn apply-spacing-token-separated
|
(defn apply-spacing-token-separated
|
||||||
"Handles edge-case for spacing token when applying token via toggle button.
|
"Handles edge-case for spacing token when applying token via toggle button.
|
||||||
|
|||||||
@@ -548,7 +548,7 @@
|
|||||||
modif-tree
|
modif-tree
|
||||||
(dwm/build-modif-tree ids objects get-modifier)]
|
(dwm/build-modif-tree ids objects get-modifier)]
|
||||||
|
|
||||||
(rx/of (dwm/apply-wasm-modifiers modif-tree)))
|
(rx/of (dwm/apply-wasm-modifiers modif-tree :ignore-touched (:ignore-touched options))))
|
||||||
|
|
||||||
(let [page-id (or (:page-id options)
|
(let [page-id (or (:page-id options)
|
||||||
(:current-page-id state))
|
(:current-page-id state))
|
||||||
|
|||||||
@@ -86,6 +86,24 @@
|
|||||||
:else
|
:else
|
||||||
(enabled-by-flags? state feature))))
|
(enabled-by-flags? state feature))))
|
||||||
|
|
||||||
|
(defn active-features?
|
||||||
|
"Given a state and a set of features, check if the features are all enabled."
|
||||||
|
([state a]
|
||||||
|
(js/console.warn "Please, use active-feature? instead")
|
||||||
|
(active-feature? state a))
|
||||||
|
([state a b]
|
||||||
|
(and ^boolean (active-feature? state a)
|
||||||
|
^boolean (active-feature? state b)))
|
||||||
|
([state a b c]
|
||||||
|
(and ^boolean (active-feature? state a)
|
||||||
|
^boolean (active-feature? state b)
|
||||||
|
^boolean (active-feature? state c)))
|
||||||
|
([state a b c & others]
|
||||||
|
(and ^boolean (active-feature? state a)
|
||||||
|
^boolean (active-feature? state b)
|
||||||
|
^boolean (active-feature? state c)
|
||||||
|
^boolean (every? #(active-feature? state %) others))))
|
||||||
|
|
||||||
(def ^:private features-ref
|
(def ^:private features-ref
|
||||||
(l/derived (l/key :features) st/state))
|
(l/derived (l/key :features) st/state))
|
||||||
|
|
||||||
|
|||||||
@@ -183,9 +183,6 @@
|
|||||||
[id]
|
[id]
|
||||||
(l/derived #(contains? % id) selected-shapes))
|
(l/derived #(contains? % id) selected-shapes))
|
||||||
|
|
||||||
(def highlighted-shapes
|
|
||||||
(l/derived :highlighted workspace-local))
|
|
||||||
|
|
||||||
(def export-in-progress?
|
(def export-in-progress?
|
||||||
(l/derived :export-in-progress? export))
|
(l/derived :export-in-progress? export))
|
||||||
|
|
||||||
|
|||||||
@@ -1009,7 +1009,8 @@
|
|||||||
|
|
||||||
(mf/defc profile-section*
|
(mf/defc profile-section*
|
||||||
[{:keys [profile team]}]
|
[{:keys [profile team]}]
|
||||||
(let [show-profile-menu* (mf/use-state false)
|
(let [teams (mf/deref refs/teams)
|
||||||
|
show-profile-menu* (mf/use-state false)
|
||||||
show-profile-menu? (deref show-profile-menu*)
|
show-profile-menu? (deref show-profile-menu*)
|
||||||
sub-menu* (mf/use-state false)
|
sub-menu* (mf/use-state false)
|
||||||
sub-menu (deref sub-menu*)
|
sub-menu (deref sub-menu*)
|
||||||
@@ -1086,8 +1087,7 @@
|
|||||||
|
|
||||||
[:*
|
[:*
|
||||||
(if (contains? cf/flags :nitrate)
|
(if (contains? cf/flags :nitrate)
|
||||||
(when-not (dm/get-in profile [:props :nitrate-license :valid])
|
[:> nitrate-sidebar* {:profile profile :teams teams}]
|
||||||
[:> nitrate-sidebar* {:profile profile}])
|
|
||||||
(when (contains? cf/flags :subscriptions)
|
(when (contains? cf/flags :subscriptions)
|
||||||
(if (show-subscription-dashboard-banner? profile)
|
(if (show-subscription-dashboard-banner? profile)
|
||||||
[:> dashboard-cta* {:profile profile}]
|
[:> dashboard-cta* {:profile profile}]
|
||||||
|
|||||||
@@ -534,7 +534,7 @@
|
|||||||
@include t.use-typography("body-medium");
|
@include t.use-typography("body-medium");
|
||||||
color: var(--color-foreground-primary);
|
color: var(--color-foreground-primary);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: var(--sp-xs) var(--sp-l);
|
margin: var(--sp-xs) 0 var(--sp-xs) var(--sp-l);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-s);
|
gap: var(--sp-s);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
(ns app.main.ui.dashboard.subscription
|
(ns app.main.ui.dashboard.subscription
|
||||||
(:require-macros [app.main.style :as stl])
|
(:require-macros [app.main.style :as stl])
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.main.data.event :as ev]
|
[app.main.data.event :as ev]
|
||||||
@@ -117,24 +118,52 @@
|
|||||||
:is-highlighted false}]))))
|
:is-highlighted false}]))))
|
||||||
|
|
||||||
(mf/defc nitrate-sidebar*
|
(mf/defc nitrate-sidebar*
|
||||||
[]
|
{::mf/props :obj}
|
||||||
(let [handle-click
|
[{:keys [profile teams]}]
|
||||||
|
(let [nitrate-license (dm/get-in profile [:props :nitrate-license])
|
||||||
|
nitrate? (and (contains? cf/flags :nitrate)
|
||||||
|
(:valid nitrate-license))
|
||||||
|
|
||||||
|
orgs (mf/with-memo [teams]
|
||||||
|
(let [orgs (->> teams
|
||||||
|
vals
|
||||||
|
(group-by :organization-id)
|
||||||
|
(map (fn [[_group entries]] (first entries)))
|
||||||
|
vec
|
||||||
|
(d/index-by :id))]
|
||||||
|
orgs))
|
||||||
|
|
||||||
|
no-orgs-created? (= (count orgs) 1)
|
||||||
|
|
||||||
|
handle-click
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(fn []
|
(fn []
|
||||||
(st/emit! (dnt/show-nitrate-popup :nitrate-form))))]
|
(st/emit! (dnt/show-nitrate-popup :nitrate-form))))]
|
||||||
|
|
||||||
;; TODO add translations for this texts when we have the definitive ones
|
;; TODO add translations for this texts when we have the definitive ones
|
||||||
|
(if (and nitrate? no-orgs-created?)
|
||||||
|
;; Banner for users with active nitrate license but no organizations created
|
||||||
[:div {:class (stl/css :nitrate-banner :highlighted)}
|
[:div {:class (stl/css :nitrate-banner :highlighted)}
|
||||||
|
|
||||||
[:div {:class (stl/css :nitrate-content)}
|
[:div {:class (stl/css :nitrate-content)}
|
||||||
[:span {:class (stl/css :nitrate-title)} "Unlock Nitrate features"]]
|
[:span {:class (stl/css :nitrate-title)} "Create your first org"]]
|
||||||
[:div {:class (stl/css :nitrate-content)}
|
[:div {:class (stl/css :nitrate-content)}
|
||||||
|
|
||||||
[:span {:class (stl/css :nitrate-info)} "Some further information and explanation."]
|
[:span {:class (stl/css :nitrate-info)} "Some further information and explanation."]
|
||||||
[:> button* {:variant "primary"
|
[:> button* {:variant "primary"
|
||||||
:type "button"
|
:type "button"
|
||||||
:class (stl/css :cta-bottom-button :nitrate-bottom-button)
|
:class (stl/css :nitrate-bottom-button)
|
||||||
:on-click handle-click} "UPGRADE TO NITRATE"]]]))
|
:on-click dnt/go-to-nitrate-cc} "CREATE ORGANIZATION"]]]
|
||||||
|
|
||||||
|
;; Banner for users without nitrate license
|
||||||
|
(when (not nitrate?)
|
||||||
|
[: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 :nitrate-bottom-button)
|
||||||
|
:on-click handle-click} "UPGRADE TO NITRATE"]]]))))
|
||||||
|
|
||||||
(mf/defc team*
|
(mf/defc team*
|
||||||
[{:keys [is-owner team]}]
|
[{:keys [is-owner team]}]
|
||||||
|
|||||||
@@ -224,6 +224,7 @@
|
|||||||
.nitrate-info {
|
.nitrate-info {
|
||||||
@include t.use-typography("body-medium");
|
@include t.use-typography("body-medium");
|
||||||
color: var(--color-foreground-secondary);
|
color: var(--color-foreground-secondary);
|
||||||
|
margin-block: var(--sp-s) var(--sp-xxl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nitrate-content {
|
.nitrate-content {
|
||||||
|
|||||||
@@ -658,7 +658,7 @@
|
|||||||
(when (and token-applied (not= :multiple token-applied))
|
(when (and token-applied (not= :multiple token-applied))
|
||||||
(let [token (get-option-by-name dropdown-options token-applied)
|
(let [token (get-option-by-name dropdown-options token-applied)
|
||||||
id (get token :id)
|
id (get token :id)
|
||||||
label (get token :name)
|
label (or (get token :name) applied-token)
|
||||||
token-value (or (get token :resolved-value)
|
token-value (or (get token :resolved-value)
|
||||||
(or (mf/ref-val last-value*)
|
(or (mf/ref-val last-value*)
|
||||||
(fmt/format-number value)))
|
(fmt/format-number value)))
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
(let [set-active? (some? id)
|
(let [set-active? (some? id)
|
||||||
content (if set-active?
|
content (if set-active?
|
||||||
label
|
label
|
||||||
(tr "ds.inputs.token-field.no-active-token-option"))
|
(tr "ds.inputs.token-field.no-active-token-option" label))
|
||||||
default-id (mf/use-id)
|
default-id (mf/use-id)
|
||||||
id (d/nilv id default-id)
|
id (d/nilv id default-id)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
[app.common.types.component :as ctk]
|
[app.common.types.component :as ctk]
|
||||||
[app.main.data.viewer :as dv]
|
[app.main.data.viewer :as dv]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner]]
|
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner*]]
|
||||||
[app.util.dom :as dom]
|
[app.util.dom :as dom]
|
||||||
[app.util.keyboard :as kbd]
|
[app.util.keyboard :as kbd]
|
||||||
[okulary.core :as l]
|
[okulary.core :as l]
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
(mf/defc layer-item
|
(mf/defc layer-item
|
||||||
[{:keys [item selected objects depth component-child? hide-toggle?] :as props}]
|
[{:keys [item selected objects depth component-child? hide-toggle?] :as props}]
|
||||||
(let [id (:id item)
|
(let [id (:id item)
|
||||||
hidden? (:hidden item)
|
|
||||||
selected? (contains? selected id)
|
selected? (contains? selected id)
|
||||||
item-ref (mf/use-ref nil)
|
item-ref (mf/use-ref nil)
|
||||||
depth (+ depth 1)
|
depth (+ depth 1)
|
||||||
@@ -68,18 +67,17 @@
|
|||||||
(when (and (= (count selected) 1) selected?)
|
(when (and (= (count selected) 1) selected?)
|
||||||
(dom/scroll-into-view-if-needed! (mf/ref-val item-ref) true))))
|
(dom/scroll-into-view-if-needed! (mf/ref-val item-ref) true))))
|
||||||
|
|
||||||
[:& layer-item-inner
|
[:> layer-item-inner*
|
||||||
{:ref item-ref
|
{:ref item-ref
|
||||||
:item item
|
:item item
|
||||||
:depth depth
|
:depth depth
|
||||||
:read-only? true
|
:is-read-only true
|
||||||
:highlighted? false
|
:is-highlighted false
|
||||||
:selected? selected?
|
:is-selected selected?
|
||||||
:component-tree? component-tree?
|
:is-component-tree component-tree?
|
||||||
:hidden? hidden?
|
:is-filtered false
|
||||||
:filtered? false
|
:is-expanded expanded?
|
||||||
:expanded? expanded?
|
:hide-toggle hide-toggle?
|
||||||
:hide-toggle? hide-toggle?
|
|
||||||
:on-select-shape select-shape
|
:on-select-shape select-shape
|
||||||
:on-toggle-collapse toggle-collapse}
|
:on-toggle-collapse toggle-collapse}
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.main.data.modal :as modal]
|
[app.main.data.modal :as modal]
|
||||||
|
[app.main.data.nitrate :as dnt]
|
||||||
[app.main.ui.components.forms :as fm]
|
[app.main.ui.components.forms :as fm]
|
||||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
[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.icon :as i :refer [icon*]]
|
||||||
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
|
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
|
||||||
[app.util.dom :as dom]
|
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
(def ^:private schema:nitrate-form
|
(def ^:private schema:nitrate-form
|
||||||
@@ -35,8 +35,7 @@
|
|||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps form)
|
(mf/deps form)
|
||||||
(fn []
|
(fn []
|
||||||
(let [params (:clean-data @form)]
|
(dnt/go-to-buy-nitrate-license (-> @form :clean-data :subscription name))))]
|
||||||
(dom/open-new-window (str "/control-center/licenses/start?subscription=" (name (:subscription params)))))))]
|
|
||||||
|
|
||||||
[:div {:class (stl/css :modal-overlay)}
|
[:div {:class (stl/css :modal-overlay)}
|
||||||
[:div {:class (stl/css :modal-dialog :subscription-success)}
|
[:div {:class (stl/css :modal-dialog :subscription-success)}
|
||||||
@@ -79,7 +78,7 @@
|
|||||||
|
|
||||||
|
|
||||||
[:p {:class (stl/css :modal-text-medium)}
|
[:p {:class (stl/css :modal-text-medium)}
|
||||||
[:a {:class (stl/css :link)}
|
[:a {:class (stl/css :link) :href dnt/go-to-subscription-url}
|
||||||
"See my current plan"]]]
|
"See my current plan"]]]
|
||||||
|
|
||||||
[:div {:class (stl/css :contact)}
|
[:div {:class (stl/css :contact)}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
[app.main.ui.releases.v2-11]
|
[app.main.ui.releases.v2-11]
|
||||||
[app.main.ui.releases.v2-12]
|
[app.main.ui.releases.v2-12]
|
||||||
[app.main.ui.releases.v2-13]
|
[app.main.ui.releases.v2-13]
|
||||||
|
[app.main.ui.releases.v2-14]
|
||||||
[app.main.ui.releases.v2-2]
|
[app.main.ui.releases.v2-2]
|
||||||
[app.main.ui.releases.v2-3]
|
[app.main.ui.releases.v2-3]
|
||||||
[app.main.ui.releases.v2-4]
|
[app.main.ui.releases.v2-4]
|
||||||
@@ -104,4 +105,4 @@
|
|||||||
|
|
||||||
(defmethod rc/render-release-notes "0.0"
|
(defmethod rc/render-release-notes "0.0"
|
||||||
[params]
|
[params]
|
||||||
(rc/render-release-notes (assoc params :version "2.13")))
|
(rc/render-release-notes (assoc params :version "2.14")))
|
||||||
|
|||||||
178
frontend/src/app/main/ui/releases/v2_14.cljs
Normal file
178
frontend/src/app/main/ui/releases/v2_14.cljs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
;; 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.releases.v2-14
|
||||||
|
(:require-macros [app.main.style :as stl])
|
||||||
|
(:require
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
|
[app.main.ui.releases.common :as c]
|
||||||
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
|
(defmethod c/render-release-notes "2.14"
|
||||||
|
[{:keys [slide klass next finish navigate version]}]
|
||||||
|
(mf/html
|
||||||
|
(case slide
|
||||||
|
:start
|
||||||
|
[:div {:class (stl/css-case :modal-overlay true)}
|
||||||
|
[:div.animated {:class klass}
|
||||||
|
[:div {:class (stl/css :modal-container)}
|
||||||
|
[:img {:src "images/features/2.14-slide-0.jpg"
|
||||||
|
:class (stl/css :start-image)
|
||||||
|
:border "0"
|
||||||
|
:alt "Penpot 2.14 is here!"}]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :modal-content)}
|
||||||
|
[:div {:class (stl/css :modal-header)}
|
||||||
|
[:h1 {:class (stl/css :modal-title)}
|
||||||
|
"What’s new in Penpot?"]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :version-tag)}
|
||||||
|
(dm/str "Version " version)]]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :features-block)}
|
||||||
|
[:span {:class (stl/css :feature-title)}
|
||||||
|
"Design tokens, but friendlier (and a bit faster, too)"]
|
||||||
|
|
||||||
|
[:p {:class (stl/css :feature-content)}
|
||||||
|
"This release keeps pushing Penpot’s design system foundations forward, with a big focus on design tokens. We’re making long token names easier to navigate, opening up tokens in the plugins API, and tackling one of the trickiest moments in token workflows: renaming (without breaking everything)."]
|
||||||
|
|
||||||
|
[:p {:class (stl/css :feature-content)}
|
||||||
|
"On top of that, you’ll find a handful of quality-of-life improvements and some performance work in the sidebar to keep things feeling smooth as your files grow. Let’s dive in."]
|
||||||
|
|
||||||
|
[:p {:class (stl/css :feature-content)}
|
||||||
|
"Let’s dive in!"]]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :navigation)}
|
||||||
|
[:button {:class (stl/css :next-btn)
|
||||||
|
:on-click next} "Continue"]]]]]]
|
||||||
|
|
||||||
|
0
|
||||||
|
[:div {:class (stl/css-case :modal-overlay true)}
|
||||||
|
[:div.animated {:class klass}
|
||||||
|
[:div {:class (stl/css :modal-container)}
|
||||||
|
[:img {:src "images/features/2.14-tokens-fold.gif"
|
||||||
|
:class (stl/css :start-image)
|
||||||
|
:border "0"
|
||||||
|
:alt "Token groups: Navigating long names, finally"}]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :modal-content)}
|
||||||
|
[:div {:class (stl/css :modal-header)}
|
||||||
|
[:h1 {:class (stl/css :modal-title)}
|
||||||
|
"Token groups: Navigating long names, finally"]]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :feature)}
|
||||||
|
[:p {:class (stl/css :feature-content)}
|
||||||
|
"Token names are rarely short and sweet. Most of the time they carry a lot of meaning (type, state, property, variant… and more), which is great for consistency, but not so great for browsing. In 2.14 we’re introducing token groups, a new way to navigate dotted token paths as nested, collapsible sections."]
|
||||||
|
|
||||||
|
[:p {:class (stl/css :feature-content)}
|
||||||
|
"Token segments before the final name are displayed as groups, and only the last segment stays as a pill (so you keep the familiar token “chip” where it matters). If you unfold a path, it stays open while you move around the app (it resets only when the page reloads). And when you create a new token, Penpot automatically unfolds the path needed to reveal it (even if it overrides a previously opened one)."]
|
||||||
|
|
||||||
|
[:p {:class (stl/css :feature-content)}
|
||||||
|
"One extra detail: if you edit the path and change group segments, the token is moved to its new group (creating it if needed), and empty groups are automatically cleaned up."]]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :navigation)}
|
||||||
|
[:& c/navigation-bullets
|
||||||
|
{:slide slide
|
||||||
|
:navigate navigate
|
||||||
|
:total 4}]
|
||||||
|
|
||||||
|
[:button {:on-click next
|
||||||
|
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||||
|
|
||||||
|
1
|
||||||
|
[:div {:class (stl/css-case :modal-overlay true)}
|
||||||
|
[:div.animated {:class klass}
|
||||||
|
[:div {:class (stl/css :modal-container)}
|
||||||
|
[:img {:src "images/features/2.14-api.gif"
|
||||||
|
:class (stl/css :start-image)
|
||||||
|
:border "0"
|
||||||
|
:alt "Design tokens in the plugins API: Automation unlocked"}]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :modal-content)}
|
||||||
|
[:div {:class (stl/css :modal-header)}
|
||||||
|
[:h1 {:class (stl/css :modal-title)}
|
||||||
|
"Design tokens in the plugins API: Automation unlocked"]]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :feature)}
|
||||||
|
[:p {:class (stl/css :feature-content)}
|
||||||
|
"Design tokens are now available in the Penpot plugins API. That means plugins (and external tools built around Penpot, like AI clients or Penpot MCP) can finally work with tokens programmatically and automate token workflows that used to be purely manual."]
|
||||||
|
|
||||||
|
[:p {:class (stl/css :feature-content)}
|
||||||
|
"If you’ve been waiting to generate tokens, sync them, or manipulate them from your own tools, this is the missing piece. And yes, this one has been requested a lot."]]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :navigation)}
|
||||||
|
[:& c/navigation-bullets
|
||||||
|
{:slide slide
|
||||||
|
:navigate navigate
|
||||||
|
:total 4}]
|
||||||
|
|
||||||
|
[:button {:on-click next
|
||||||
|
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||||
|
|
||||||
|
2
|
||||||
|
[:div {:class (stl/css-case :modal-overlay true)}
|
||||||
|
[:div.animated {:class klass}
|
||||||
|
[:div {:class (stl/css :modal-container)}
|
||||||
|
[:img {:src "images/features/2.14-remap.jpg"
|
||||||
|
:class (stl/css :start-image)
|
||||||
|
:border "0"
|
||||||
|
:alt "Rename tokens without breaking everything"}]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :modal-content)}
|
||||||
|
[:div {:class (stl/css :modal-header)}
|
||||||
|
[:h1 {:class (stl/css :modal-title)}
|
||||||
|
"Rename tokens without breaking everything"]]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :feature)}
|
||||||
|
[:p {:class (stl/css :feature-content)}
|
||||||
|
"Renaming tokens sounds simple until you remember the references. One change can ripple through aliases, applied tokens, tooltips, math operations… and suddenly you’re left with a broken chain. In 2.14, renaming a token can optionally remap its references, keeping connections intact and updating the design with the new token name."]
|
||||||
|
|
||||||
|
[:p {:class (stl/css :feature-content)}
|
||||||
|
"Remapping is always optional, because sometimes you don’t want to keep the current connections. When enabled, it affects all tokens in the file and also takes libraries into account, so main components can propagate changes to child components, and applied tokens update on the elements using them."]]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :navigation)}
|
||||||
|
[:& c/navigation-bullets
|
||||||
|
{:slide slide
|
||||||
|
:navigate navigate
|
||||||
|
:total 4}]
|
||||||
|
|
||||||
|
[:button {:on-click next
|
||||||
|
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||||
|
|
||||||
|
3
|
||||||
|
[:div {:class (stl/css-case :modal-overlay true)}
|
||||||
|
[:div.animated {:class klass}
|
||||||
|
[:div {:class (stl/css :modal-container)}
|
||||||
|
[:img {:src "images/features/2.14-icons.gif"
|
||||||
|
:class (stl/css :start-image)
|
||||||
|
:border "0"
|
||||||
|
:alt "Quality-of-life improvements"}]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :modal-content)}
|
||||||
|
[:div {:class (stl/css :modal-header)}
|
||||||
|
[:h1 {:class (stl/css :modal-title)}
|
||||||
|
"Quality-of-life improvements"]]
|
||||||
|
[:div {:class (stl/css :feature)}
|
||||||
|
|
||||||
|
[:p {:class (stl/css :feature-content)}
|
||||||
|
"Lock and hide controls in the layer panel are getting a usability boost. The lock and visibility icons stay fixed in a right-aligned column regardless of indentation, and scrolling won’t make them awkward to click (even in deeply nested files)."]
|
||||||
|
|
||||||
|
[:p {:class (stl/css :feature-content)}
|
||||||
|
"We’re also improving sidebar performance, with a focus on keeping interactions fluent. The goal is to lazy-load the shape list on-demand and avoid UI stalls when clicking or hovering around the sidebar."]
|
||||||
|
|
||||||
|
[:p {:class (stl/css :feature-content)}
|
||||||
|
"And one more: you can now use Shift/Alt arrow key stepping in color picker inputs (a community contribution by @eureka928. ❤️)"]]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :navigation)}
|
||||||
|
|
||||||
|
[:& c/navigation-bullets
|
||||||
|
{:slide slide
|
||||||
|
:navigate navigate
|
||||||
|
:total 4}]
|
||||||
|
|
||||||
|
[:button {:on-click finish
|
||||||
|
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||||
|
|
||||||
102
frontend/src/app/main/ui/releases/v2_14.scss
Normal file
102
frontend/src/app/main/ui/releases/v2_14.scss
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
@extend .modal-overlay-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: deprecated.$s-324 1fr;
|
||||||
|
height: deprecated.$s-500;
|
||||||
|
width: deprecated.$s-888;
|
||||||
|
border-radius: deprecated.$br-8;
|
||||||
|
background-color: var(--modal-background-color);
|
||||||
|
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-image {
|
||||||
|
width: deprecated.$s-324;
|
||||||
|
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
padding: deprecated.$s-40;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||||
|
gap: deprecated.$s-24;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--button-primary-background-color-rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: grid;
|
||||||
|
gap: deprecated.$s-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-tag {
|
||||||
|
@include deprecated.flexCenter;
|
||||||
|
@include deprecated.headlineSmallTypography;
|
||||||
|
height: deprecated.$s-32;
|
||||||
|
width: deprecated.$s-96;
|
||||||
|
background-color: var(--communication-tag-background-color);
|
||||||
|
color: var(--communication-tag-foreground-color);
|
||||||
|
border-radius: deprecated.$br-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
@include deprecated.headlineLargeTypography;
|
||||||
|
color: var(--modal-title-foreground-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: deprecated.$s-16;
|
||||||
|
width: deprecated.$s-440;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: deprecated.$s-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-title {
|
||||||
|
@include deprecated.bodyLargeTypography;
|
||||||
|
color: var(--modal-title-foreground-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-content {
|
||||||
|
@include deprecated.bodyMediumTypography;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--modal-text-foreground-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
@include deprecated.bodyMediumTypography;
|
||||||
|
color: var(--modal-text-foreground-color);
|
||||||
|
list-style: disc;
|
||||||
|
display: grid;
|
||||||
|
gap: deprecated.$s-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas: "bullets button";
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-btn {
|
||||||
|
@extend .button-primary;
|
||||||
|
width: deprecated.$s-100;
|
||||||
|
justify-self: flex-end;
|
||||||
|
grid-area: button;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
|
[app.common.uri :as u]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.main.data.auth :as da]
|
[app.main.data.auth :as da]
|
||||||
[app.main.data.event :as ev]
|
[app.main.data.event :as ev]
|
||||||
@@ -355,6 +356,37 @@
|
|||||||
:value (tr "labels.close")
|
:value (tr "labels.close")
|
||||||
:on-click handle-close-dialog}]]]]]]))
|
:on-click handle-close-dialog}]]]]]]))
|
||||||
|
|
||||||
|
(mf/defc nitrate-success-dialog
|
||||||
|
{::mf/register modal/components
|
||||||
|
::mf/register-as :nitrate-success}
|
||||||
|
[]
|
||||||
|
;; TODO add translations for this texts when we have the definitive ones
|
||||||
|
(let [profile (mf/deref refs/profile)]
|
||||||
|
|
||||||
|
[: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)}
|
||||||
|
[:> raw-svg* {:id (if (= "light" (:theme profile)) "logo-subscription-light" "logo-subscription")}]]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :modal-end)}
|
||||||
|
[:div {:class (stl/css :modal-title)}
|
||||||
|
"You are Business Nitrate!"]
|
||||||
|
[:p {:class (stl/css :modal-text-large)}
|
||||||
|
(tr "subscription.settings.success.dialog.description")]
|
||||||
|
[:p {:class (stl/css :modal-text-large)}
|
||||||
|
(tr "subscription.settings.sucess.dialog.footer")]
|
||||||
|
|
||||||
|
[:div {:class (stl/css :success-action-buttons)}
|
||||||
|
[:input
|
||||||
|
{:class (stl/css :primary-button)
|
||||||
|
:type "button"
|
||||||
|
:value "CREATE ORGANIZATION"
|
||||||
|
:on-click dnt/go-to-nitrate-cc}]]]]]]))
|
||||||
|
|
||||||
(mf/defc subscription-page*
|
(mf/defc subscription-page*
|
||||||
[{:keys [profile]}]
|
[{:keys [profile]}]
|
||||||
(let [route (mf/deref refs/route)
|
(let [route (mf/deref refs/route)
|
||||||
@@ -374,7 +406,8 @@
|
|||||||
|
|
||||||
show-subscription-success-modal?
|
show-subscription-success-modal?
|
||||||
(or (= params-subscription "subscribed-to-penpot-unlimited")
|
(or (= params-subscription "subscribed-to-penpot-unlimited")
|
||||||
(= params-subscription "subscribed-to-penpot-enterprise"))
|
(= params-subscription "subscribed-to-penpot-enterprise")
|
||||||
|
(= params-subscription "subscribed-to-penpot-nitrate"))
|
||||||
|
|
||||||
success-modal-is-trial?
|
success-modal-is-trial?
|
||||||
(-> route :params :query :trial)
|
(-> route :params :query :trial)
|
||||||
@@ -458,6 +491,8 @@
|
|||||||
|
|
||||||
^boolean show-subscription-success-modal?
|
^boolean show-subscription-success-modal?
|
||||||
(st/emit!
|
(st/emit!
|
||||||
|
(if (= params-subscription "subscribed-to-penpot-nitrate")
|
||||||
|
(modal/show :nitrate-success {})
|
||||||
(modal/show :subscription-success
|
(modal/show :subscription-success
|
||||||
{:subscription-name (if (= params-subscription "subscribed-to-penpot-unlimited")
|
{:subscription-name (if (= params-subscription "subscribed-to-penpot-unlimited")
|
||||||
(if (= success-modal-is-trial? "true")
|
(if (= success-modal-is-trial? "true")
|
||||||
@@ -465,7 +500,7 @@
|
|||||||
(tr "subscription.settings.unlimited"))
|
(tr "subscription.settings.unlimited"))
|
||||||
(if (= success-modal-is-trial? "true")
|
(if (= success-modal-is-trial? "true")
|
||||||
(tr "subscription.settings.enterprise-trial")
|
(tr "subscription.settings.enterprise-trial")
|
||||||
(tr "subscription.settings.enterprise")))})
|
(tr "subscription.settings.enterprise")))}))
|
||||||
(rt/nav :settings-subscription {} {::rt/replace true})))))
|
(rt/nav :settings-subscription {} {::rt/replace true})))))
|
||||||
|
|
||||||
[:section {:class (stl/css :dashboard-section)}
|
[:section {:class (stl/css :dashboard-section)}
|
||||||
@@ -641,8 +676,13 @@
|
|||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps form)
|
(mf/deps form)
|
||||||
(fn []
|
(fn []
|
||||||
(let [params (:clean-data @form)]
|
(let [subscription (-> @form :clean-data :subscription name)
|
||||||
(dom/open-new-window (str "/control-center/licenses/start?subscription=" (name (:subscription params)))))))]
|
return-url (dm/str
|
||||||
|
(rt/get-current-href)
|
||||||
|
"?"
|
||||||
|
(u/map->query-string
|
||||||
|
{:subscription "subscribed-to-penpot-nitrate"}))]
|
||||||
|
(dnt/go-to-buy-nitrate-license subscription return-url))))]
|
||||||
|
|
||||||
[:div {:class (stl/css :modal-overlay)}
|
[:div {:class (stl/css :modal-overlay)}
|
||||||
[:div {:class (stl/css :modal-dialog)}
|
[:div {:class (stl/css :modal-dialog)}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.files.helpers :as cfh]
|
[app.common.files.helpers :as cfh]
|
||||||
|
[app.common.math :as mth]
|
||||||
[app.common.types.component :as ctk]
|
[app.common.types.component :as ctk]
|
||||||
[app.common.types.components-list :as ctkl]
|
[app.common.types.components-list :as ctkl]
|
||||||
[app.common.types.container :as ctn]
|
[app.common.types.container :as ctn]
|
||||||
@@ -37,6 +38,8 @@
|
|||||||
(defonce ^:private sidebar-hover-queue (atom {:enter #{} :leave #{}}))
|
(defonce ^:private sidebar-hover-queue (atom {:enter #{} :leave #{}}))
|
||||||
(defonce ^:private sidebar-hover-pending? (atom false))
|
(defonce ^:private sidebar-hover-pending? (atom false))
|
||||||
|
|
||||||
|
(def ^:const default-chunk-size 50)
|
||||||
|
|
||||||
(defn- schedule-sidebar-hover-flush []
|
(defn- schedule-sidebar-hover-flush []
|
||||||
(when (compare-and-set! sidebar-hover-pending? false true)
|
(when (compare-and-set! sidebar-hover-pending? false true)
|
||||||
(ts/raf
|
(ts/raf
|
||||||
@@ -48,15 +51,15 @@
|
|||||||
(when (seq enter)
|
(when (seq enter)
|
||||||
(apply st/emit! (map dw/highlight-shape enter))))))))
|
(apply st/emit! (map dw/highlight-shape enter))))))))
|
||||||
|
|
||||||
(mf/defc layer-item-inner
|
(mf/defc layer-item-inner*
|
||||||
{::mf/wrap-props false}
|
[{:keys [item depth parent-size name-ref children ref style rename-id
|
||||||
[{:keys [item depth parent-size name-ref children ref style
|
|
||||||
;; Flags
|
;; Flags
|
||||||
read-only? highlighted? selected? component-tree?
|
is-read-only is-highlighted is-selected is-component-tree
|
||||||
filtered? expanded? dnd-over? dnd-over-top? dnd-over-bot? hide-toggle?
|
is-filtered is-expanded dnd-over dnd-over-top dnd-over-bot hide-toggle
|
||||||
;; Callbacks
|
;; Callbacks
|
||||||
on-select-shape on-context-menu on-pointer-enter on-pointer-leave on-zoom-to-selected
|
on-select-shape on-context-menu on-pointer-enter on-pointer-leave on-zoom-to-selected
|
||||||
on-toggle-collapse on-enable-drag on-disable-drag on-toggle-visibility on-toggle-blocking]}]
|
on-toggle-collapse on-enable-drag on-disable-drag on-toggle-visibility on-toggle-blocking
|
||||||
|
on-tab-press]}]
|
||||||
|
|
||||||
(let [id (:id item)
|
(let [id (:id item)
|
||||||
name (:name item)
|
name (:name item)
|
||||||
@@ -64,7 +67,7 @@
|
|||||||
hidden? (:hidden item)
|
hidden? (:hidden item)
|
||||||
has-shapes? (-> item :shapes seq boolean)
|
has-shapes? (-> item :shapes seq boolean)
|
||||||
touched? (-> item :touched seq boolean)
|
touched? (-> item :touched seq boolean)
|
||||||
parent-board? (and (cfh/frame-shape? item)
|
root-board? (and (cfh/frame-shape? item)
|
||||||
(= uuid/zero (:parent-id item)))
|
(= uuid/zero (:parent-id item)))
|
||||||
absolute? (ctl/item-absolute? item)
|
absolute? (ctl/item-absolute? item)
|
||||||
is-variant? (ctk/is-variant? item)
|
is-variant? (ctk/is-variant? item)
|
||||||
@@ -73,9 +76,11 @@
|
|||||||
variant-name (when is-variant? (:variant-name item))
|
variant-name (when is-variant? (:variant-name item))
|
||||||
variant-error (when is-variant? (:variant-error item))
|
variant-error (when is-variant? (:variant-error item))
|
||||||
|
|
||||||
data (deref refs/workspace-data)
|
component-id (get item :component-id)
|
||||||
component (ctkl/get-component data (:component-id item))
|
data (mf/deref refs/workspace-data)
|
||||||
variant-properties (:variant-properties component)
|
variant-properties (-> (ctkl/get-component data component-id)
|
||||||
|
(get :variant-properties))
|
||||||
|
|
||||||
icon-shape (usi/get-shape-icon item)]
|
icon-shape (usi/get-shape-icon item)]
|
||||||
|
|
||||||
[:*
|
[:*
|
||||||
@@ -85,30 +90,30 @@
|
|||||||
:on-context-menu on-context-menu
|
:on-context-menu on-context-menu
|
||||||
:data-testid "layer-row"
|
:data-testid "layer-row"
|
||||||
:role "checkbox"
|
:role "checkbox"
|
||||||
:aria-checked selected?
|
:aria-checked is-selected
|
||||||
:class (stl/css-case
|
:class (stl/css-case
|
||||||
:layer-row true
|
:layer-row true
|
||||||
:highlight highlighted?
|
:highlight is-highlighted
|
||||||
:component (ctk/instance-head? item)
|
:component (ctk/instance-head? item)
|
||||||
:masked (:masked-group item)
|
:masked (:masked-group item)
|
||||||
:selected selected?
|
:selected is-selected
|
||||||
:type-frame (cfh/frame-shape? item)
|
:type-frame (cfh/frame-shape? item)
|
||||||
:type-bool (cfh/bool-shape? item)
|
:type-bool (cfh/bool-shape? item)
|
||||||
:type-comp (or component-tree? is-variant-container?)
|
:type-comp (or is-component-tree is-variant-container?)
|
||||||
:hidden hidden?
|
:hidden hidden?
|
||||||
:dnd-over dnd-over?
|
:dnd-over dnd-over
|
||||||
:dnd-over-top dnd-over-top?
|
:dnd-over-top dnd-over-top
|
||||||
:dnd-over-bot dnd-over-bot?
|
:dnd-over-bot dnd-over-bot
|
||||||
:root-board parent-board?)
|
:root-board root-board?)
|
||||||
:style style}
|
:style style}
|
||||||
[:span {:class (stl/css-case
|
[:span {:class (stl/css-case
|
||||||
:tab-indentation true
|
:tab-indentation true
|
||||||
:filtered filtered?)
|
:filtered is-filtered)
|
||||||
:style {"--depth" depth}}]
|
:style {"--depth" depth}}]
|
||||||
[:div {:class (stl/css-case
|
[:div {:class (stl/css-case
|
||||||
:element-list-body true
|
:element-list-body true
|
||||||
:filtered filtered?
|
:filtered is-filtered
|
||||||
:selected selected?
|
:selected is-selected
|
||||||
:icon-layer (= (:type item) :icon))
|
:icon-layer (= (:type item) :icon))
|
||||||
:style {"--depth" depth}
|
:style {"--depth" depth}
|
||||||
:on-pointer-enter on-pointer-enter
|
:on-pointer-enter on-pointer-enter
|
||||||
@@ -117,12 +122,12 @@
|
|||||||
|
|
||||||
(if (< 0 (count (:shapes item)))
|
(if (< 0 (count (:shapes item)))
|
||||||
[:div {:class (stl/css :button-content)}
|
[:div {:class (stl/css :button-content)}
|
||||||
(when (and (not hide-toggle?) (not filtered?))
|
(when (and (not hide-toggle) (not is-filtered))
|
||||||
[:button {:class (stl/css-case
|
[:button {:class (stl/css-case
|
||||||
:toggle-content true
|
:toggle-content true
|
||||||
:inverse expanded?)
|
:inverse is-expanded)
|
||||||
:data-testid "toggle-content"
|
:data-testid "toggle-content"
|
||||||
:aria-expanded expanded?
|
:aria-expanded is-expanded
|
||||||
:on-click on-toggle-collapse}
|
:on-click on-toggle-collapse}
|
||||||
deprecated-icon/arrow])
|
deprecated-icon/arrow])
|
||||||
|
|
||||||
@@ -133,7 +138,7 @@
|
|||||||
[:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]]
|
[:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]]
|
||||||
|
|
||||||
[:div {:class (stl/css :button-content)}
|
[:div {:class (stl/css :button-content)}
|
||||||
(when (not ^boolean filtered?)
|
(when (not ^boolean is-filtered)
|
||||||
[:span {:class (stl/css :toggle-content)}])
|
[:span {:class (stl/css :toggle-content)}])
|
||||||
[:div {:class (stl/css :icon-shape)
|
[:div {:class (stl/css :icon-shape)
|
||||||
:on-double-click on-zoom-to-selected}
|
:on-double-click on-zoom-to-selected}
|
||||||
@@ -142,25 +147,27 @@
|
|||||||
[:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]])
|
[:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]])
|
||||||
|
|
||||||
[:> layer-name* {:ref name-ref
|
[:> layer-name* {:ref name-ref
|
||||||
|
:rename-id rename-id
|
||||||
:shape-id id
|
:shape-id id
|
||||||
:shape-name name
|
:shape-name name
|
||||||
:is-shape-touched touched?
|
:is-shape-touched touched?
|
||||||
:disabled-double-click read-only?
|
:disabled-double-click is-read-only
|
||||||
:on-start-edit on-disable-drag
|
:on-start-edit on-disable-drag
|
||||||
:on-stop-edit on-enable-drag
|
:on-stop-edit on-enable-drag
|
||||||
:depth depth
|
:depth depth
|
||||||
:is-blocked blocked?
|
:is-blocked blocked?
|
||||||
:parent-size parent-size
|
:parent-size parent-size
|
||||||
:is-selected selected?
|
:is-selected is-selected
|
||||||
:type-comp (or component-tree? is-variant-container?)
|
:type-comp (or is-component-tree is-variant-container?)
|
||||||
:type-frame (cfh/frame-shape? item)
|
:type-frame (cfh/frame-shape? item)
|
||||||
:variant-id variant-id
|
:variant-id variant-id
|
||||||
:variant-name variant-name
|
:variant-name variant-name
|
||||||
:variant-properties variant-properties
|
:variant-properties variant-properties
|
||||||
:variant-error variant-error
|
:variant-error variant-error
|
||||||
:component-id (:id component)
|
:component-id component-id
|
||||||
:is-hidden hidden?}]]
|
:is-hidden hidden?
|
||||||
(when (not read-only?)
|
:on-tab-press on-tab-press}]]
|
||||||
|
(when (not ^boolean is-read-only)
|
||||||
[:div {:class (stl/css-case
|
[:div {:class (stl/css-case
|
||||||
:element-actions true
|
:element-actions true
|
||||||
:is-parent has-shapes?
|
:is-parent has-shapes?
|
||||||
@@ -185,41 +192,86 @@
|
|||||||
|
|
||||||
children]))
|
children]))
|
||||||
|
|
||||||
;; Memoized for performance
|
(mf/defc layer-item*
|
||||||
(mf/defc layer-item
|
{::mf/wrap [mf/memo]}
|
||||||
{::mf/props :obj
|
[{:keys [index item selected objects rename-id
|
||||||
::mf/wrap [mf/memo]}
|
is-sortable is-filtered depth is-component-child
|
||||||
[{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted style render-children?]
|
highlighted style render-children parent-size]
|
||||||
:or {render-children? true}}]
|
:or {render-children true}}]
|
||||||
(let [id (:id item)
|
(let [id (get item :id)
|
||||||
blocked? (:blocked item)
|
blocked? (get item :blocked)
|
||||||
hidden? (:hidden item)
|
hidden? (get item :hidden)
|
||||||
|
|
||||||
|
shapes (get item :shapes)
|
||||||
|
shapes (mf/with-memo [shapes objects]
|
||||||
|
(loop [counter 0
|
||||||
|
shapes (seq shapes)
|
||||||
|
result (list)]
|
||||||
|
|
||||||
|
(if-let [id (first shapes)]
|
||||||
|
(if-let [obj (get objects id)]
|
||||||
|
(do
|
||||||
|
;; NOTE: this is a bit hacky, but reduces substantially
|
||||||
|
;; the allocation; If we use enumeration, we allocate
|
||||||
|
;; new sequence and add one iteration on each render,
|
||||||
|
;; independently if objects are changed or not. If we
|
||||||
|
;; store counter on metadata, we still need to create a
|
||||||
|
;; new allocation for each shape; with this method we
|
||||||
|
;; bypass this by mutating a private property on the
|
||||||
|
;; object removing extra allocation and extra iteration
|
||||||
|
;; on every request.
|
||||||
|
(unchecked-set obj "__$__counter" counter)
|
||||||
|
(recur (inc counter)
|
||||||
|
(rest shapes)
|
||||||
|
(conj result obj)))
|
||||||
|
(recur (inc counter)
|
||||||
|
(rest shapes)
|
||||||
|
result))
|
||||||
|
|
||||||
|
(-> result vec not-empty))))
|
||||||
|
|
||||||
drag-disabled* (mf/use-state false)
|
drag-disabled* (mf/use-state false)
|
||||||
drag-disabled? (deref drag-disabled*)
|
drag-disabled? (deref drag-disabled*)
|
||||||
|
|
||||||
scroll-to-middle? (mf/use-var true)
|
scroll-middle-ref (mf/use-ref true)
|
||||||
expanded-iref (mf/with-memo [id]
|
expanded-iref (mf/with-memo [id]
|
||||||
(-> (l/in [:expanded id])
|
(l/derived #(dm/get-in % [:expanded id]) refs/workspace-local))
|
||||||
(l/derived refs/workspace-local)))
|
is-expanded (mf/deref expanded-iref)
|
||||||
expanded? (mf/deref expanded-iref)
|
|
||||||
|
|
||||||
selected? (contains? selected id)
|
is-selected (contains? selected id)
|
||||||
highlighted? (contains? highlighted id)
|
is-highlighted (contains? highlighted id)
|
||||||
|
|
||||||
container? (or (cfh/frame-shape? item)
|
container? (or (cfh/frame-shape? item)
|
||||||
(cfh/group-shape? item))
|
(cfh/group-shape? item))
|
||||||
|
|
||||||
read-only? (mf/use-ctx ctx/workspace-read-only?)
|
is-read-only (mf/use-ctx ctx/workspace-read-only?)
|
||||||
parent-board? (and (cfh/frame-shape? item)
|
root-board? (and (cfh/frame-shape? item)
|
||||||
(= uuid/zero (:parent-id item)))
|
(= uuid/zero (:parent-id item)))
|
||||||
|
|
||||||
|
name-node-ref (mf/use-ref)
|
||||||
|
|
||||||
|
depth (+ depth 1)
|
||||||
|
|
||||||
|
is-component-tree (or ^boolean is-component-child
|
||||||
|
^boolean (ctk/instance-root? item)
|
||||||
|
^boolean (ctk/instance-head? item))
|
||||||
|
|
||||||
|
enable-drag (mf/use-fn #(reset! drag-disabled* false))
|
||||||
|
disable-drag (mf/use-fn #(reset! drag-disabled* true))
|
||||||
|
|
||||||
|
;; Lazy loading of child elements via IntersectionObserver
|
||||||
|
children-count* (mf/use-state 0)
|
||||||
|
children-count (deref children-count*)
|
||||||
|
|
||||||
|
lazy-ref (mf/use-ref nil)
|
||||||
|
observer-ref (mf/use-ref nil)
|
||||||
|
|
||||||
toggle-collapse
|
toggle-collapse
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps expanded?)
|
(mf/deps is-expanded)
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(dom/stop-propagation event)
|
(dom/stop-propagation event)
|
||||||
(if (and expanded? (kbd/shift? event))
|
(if (and is-expanded (kbd/shift? event))
|
||||||
(st/emit! (dwc/collapse-all))
|
(st/emit! (dwc/collapse-all))
|
||||||
(st/emit! (dwc/toggle-collapse id)))))
|
(st/emit! (dwc/toggle-collapse id)))))
|
||||||
|
|
||||||
@@ -244,13 +296,13 @@
|
|||||||
|
|
||||||
select-shape
|
select-shape
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps id filtered? objects)
|
(mf/deps id is-filtered objects)
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(dom/prevent-default event)
|
(dom/prevent-default event)
|
||||||
(reset! scroll-to-middle? false)
|
(mf/set-ref-val! scroll-middle-ref false)
|
||||||
(cond
|
(cond
|
||||||
(kbd/shift? event)
|
(kbd/shift? event)
|
||||||
(if filtered?
|
(if is-filtered
|
||||||
(st/emit! (dw/shift-select-shapes id objects))
|
(st/emit! (dw/shift-select-shapes id objects))
|
||||||
(st/emit! (dw/shift-select-shapes id)))
|
(st/emit! (dw/shift-select-shapes id)))
|
||||||
|
|
||||||
@@ -285,11 +337,11 @@
|
|||||||
|
|
||||||
on-context-menu
|
on-context-menu
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps item read-only?)
|
(mf/deps item is-read-only)
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(dom/prevent-default event)
|
(dom/prevent-default event)
|
||||||
(dom/stop-propagation event)
|
(dom/stop-propagation event)
|
||||||
(when-not read-only?
|
(when-not is-read-only
|
||||||
(let [pos (dom/get-client-position event)]
|
(let [pos (dom/get-client-position event)]
|
||||||
(st/emit! (dw/show-shape-context-menu {:position pos :shape item}))))))
|
(st/emit! (dw/show-shape-context-menu {:position pos :shape item}))))))
|
||||||
|
|
||||||
@@ -302,7 +354,7 @@
|
|||||||
|
|
||||||
on-drop
|
on-drop
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps id objects expanded? selected)
|
(mf/deps id objects is-expanded selected)
|
||||||
(fn [side _data]
|
(fn [side _data]
|
||||||
(let [single? (= (count selected) 1)
|
(let [single? (= (count selected) 1)
|
||||||
same? (and single? (= (first selected) id))]
|
same? (and single? (= (first selected) id))]
|
||||||
@@ -315,32 +367,34 @@
|
|||||||
(= side :center)
|
(= side :center)
|
||||||
id
|
id
|
||||||
|
|
||||||
(and expanded? (= side :bot) (d/not-empty? (:shapes shape)))
|
(and is-expanded (= side :bot) (d/not-empty? (:shapes shape)))
|
||||||
id
|
id
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(cfh/get-parent-id objects id))
|
(cfh/get-parent-id objects id))
|
||||||
|
|
||||||
[parent-id _] (ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files)
|
[parent-id _]
|
||||||
|
(ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files)
|
||||||
|
|
||||||
parent (get objects parent-id)
|
parent (get objects parent-id)
|
||||||
current-index (d/index-of (:shapes parent) id)
|
current-index (d/index-of (:shapes parent) id)
|
||||||
|
|
||||||
to-index (cond
|
to-index (cond
|
||||||
(= side :center) 0
|
(= side :center) 0
|
||||||
(and expanded? (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent))
|
(and is-expanded (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent))
|
||||||
;; target not found in parent (while lazy loading)
|
;; target not found in parent (while lazy loading)
|
||||||
(neg? current-index) nil
|
(neg? current-index) nil
|
||||||
(= side :top) (inc current-index)
|
(= side :top) (inc current-index)
|
||||||
:else current-index)]
|
:else current-index)]
|
||||||
|
|
||||||
(when (some? to-index)
|
(when (some? to-index)
|
||||||
(st/emit! (dw/relocate-selected-shapes parent-id to-index))))))))
|
(st/emit! (dw/relocate-selected-shapes parent-id to-index))))))))
|
||||||
|
|
||||||
on-hold
|
on-hold
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps id expanded?)
|
(mf/deps id is-expanded)
|
||||||
(fn []
|
(fn []
|
||||||
(when-not expanded?
|
(when-not is-expanded
|
||||||
(st/emit! (dwc/toggle-collapse id)))))
|
(st/emit! (dwc/toggle-collapse id)))))
|
||||||
|
|
||||||
zoom-to-selected
|
zoom-to-selected
|
||||||
@@ -361,112 +415,132 @@
|
|||||||
:data {:id (:id item)
|
:data {:id (:id item)
|
||||||
:index index
|
:index index
|
||||||
:name (:name item)}
|
:name (:name item)}
|
||||||
:draggable? (and
|
;; We don't want to change the structure of component copies
|
||||||
sortable?
|
:draggable? (and ^boolean is-sortable
|
||||||
(not read-only?)
|
^boolean (not is-read-only)
|
||||||
(not (ctn/has-any-copy-parent? objects item)))) ;; We don't want to change the structure of component copies
|
^boolean (not (ctn/has-any-copy-parent? objects item))))
|
||||||
|
|
||||||
ref (mf/use-ref)
|
on-tab-press
|
||||||
depth (+ depth 1)
|
(mf/use-fn
|
||||||
component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item))
|
(mf/deps id objects)
|
||||||
|
(fn [event]
|
||||||
|
(let [shift? (kbd/shift? event)
|
||||||
|
shape (get objects id)
|
||||||
|
parent (get objects (:parent-id shape))
|
||||||
|
siblings (:shapes parent)
|
||||||
|
pos (d/index-of siblings id)]
|
||||||
|
(when (some? pos)
|
||||||
|
(let [;; Layers render in reverse: Tab (visually down) = dec index,
|
||||||
|
;; Shift+Tab (visually up) = inc index
|
||||||
|
target-id (if shift?
|
||||||
|
(get siblings (inc pos))
|
||||||
|
(get siblings (dec pos)))]
|
||||||
|
(when (some? target-id)
|
||||||
|
(st/emit! (dw/start-rename-shape target-id))))))))]
|
||||||
|
|
||||||
enable-drag (mf/use-fn #(reset! drag-disabled* false))
|
(mf/with-effect [is-selected selected]
|
||||||
disable-drag (mf/use-fn #(reset! drag-disabled* true))
|
|
||||||
|
|
||||||
;; Lazy loading of child elements via IntersectionObserver
|
|
||||||
children-count* (mf/use-state 0)
|
|
||||||
children-count (deref children-count*)
|
|
||||||
lazy-ref (mf/use-ref nil)
|
|
||||||
observer-var (mf/use-var nil)
|
|
||||||
chunk-size 50]
|
|
||||||
|
|
||||||
(mf/with-effect [selected? selected]
|
|
||||||
(let [single? (= (count selected) 1)
|
(let [single? (= (count selected) 1)
|
||||||
node (mf/ref-val ref)
|
node (mf/ref-val name-node-ref)
|
||||||
scroll-node (dom/get-parent-with-data node "scroll-container")
|
scroll-node (dom/get-parent-with-data node "scroll-container")
|
||||||
parent-node (dom/get-parent-at node 2)
|
parent-node (dom/get-parent-at node 2)
|
||||||
first-child-node (dom/get-first-child parent-node)
|
first-child-node (dom/get-first-child parent-node)
|
||||||
|
scroll-to-middle? (mf/ref-val scroll-middle-ref)
|
||||||
|
|
||||||
subid
|
subid
|
||||||
(when (and single? selected? @scroll-to-middle?)
|
(when (and ^boolean single?
|
||||||
|
^boolean is-selected
|
||||||
|
^boolean scroll-to-middle?)
|
||||||
(ts/schedule
|
(ts/schedule
|
||||||
100
|
100
|
||||||
#(when (and node scroll-node)
|
#(when (and node scroll-node)
|
||||||
(let [scroll-distance-ratio (dom/get-scroll-distance-ratio node scroll-node)
|
(let [scroll-distance-ratio (dom/get-scroll-distance-ratio node scroll-node)
|
||||||
scroll-behavior (if (> scroll-distance-ratio 1) "instant" "smooth")]
|
scroll-behavior (if (> scroll-distance-ratio 1) "instant" "smooth")]
|
||||||
(dom/scroll-into-view-if-needed! first-child-node #js {:block "center" :behavior scroll-behavior :inline "start"})
|
(dom/scroll-into-view-if-needed! first-child-node #js {:block "center" :behavior scroll-behavior :inline "start"})
|
||||||
(reset! scroll-to-middle? true)))))]
|
(mf/set-ref-val! scroll-middle-ref true)))))]
|
||||||
|
|
||||||
#(when (some? subid)
|
#(when (some? subid)
|
||||||
(rx/dispose! subid))))
|
(rx/dispose! subid))))
|
||||||
|
|
||||||
;; Setup scroll-driven lazy loading when expanded
|
;; Setup scroll-driven lazy loading when expanded
|
||||||
;; and ensures selected children are loaded immediately
|
;; and ensures selected children are loaded immediately
|
||||||
(mf/with-effect [expanded? (:shapes item) selected]
|
(mf/with-effect [is-expanded shapes selected]
|
||||||
(let [shapes-vec (:shapes item)
|
(let [total (count shapes)]
|
||||||
total (count shapes-vec)]
|
(if ^boolean is-expanded
|
||||||
(if expanded?
|
|
||||||
(let [;; Children are rendered in reverse order, so index 0 in render = last in shapes-vec
|
(let [;; Children are rendered in reverse order, so index 0 in render = last in shapes-vec
|
||||||
;; Find if any selected id is a direct child and get its render index
|
;; Find if any selected id is a direct child and get its render index
|
||||||
selected-child-render-idx
|
selected-child-render-idx
|
||||||
(when (and (> total chunk-size) (seq selected))
|
(when (> total default-chunk-size)
|
||||||
(let [shapes-reversed (vec (reverse shapes-vec))]
|
|
||||||
(some (fn [sel-id]
|
(some (fn [sel-id]
|
||||||
(let [idx (.indexOf shapes-reversed sel-id)]
|
(let [idx (.indexOf shapes sel-id)]
|
||||||
(when (>= idx 0) idx)))
|
(when (>= idx 0) idx)))
|
||||||
selected)))
|
selected))
|
||||||
|
|
||||||
;; Load at least enough to include the selected child plus extra
|
;; Load at least enough to include the selected child plus extra
|
||||||
;; for context (so it can be centered in the scroll view)
|
;; for context (so it can be centered in the scroll view)
|
||||||
min-count (if selected-child-render-idx
|
min-count
|
||||||
(+ selected-child-render-idx chunk-size)
|
(if selected-child-render-idx
|
||||||
chunk-size)
|
(+ selected-child-render-idx default-chunk-size)
|
||||||
current @children-count*
|
default-chunk-size)
|
||||||
new-count (min total (max current chunk-size min-count))]
|
|
||||||
|
current-count
|
||||||
|
@children-count*
|
||||||
|
|
||||||
|
new-count
|
||||||
|
(mth/min total (mth/max current-count default-chunk-size min-count))]
|
||||||
|
|
||||||
(reset! children-count* new-count))
|
(reset! children-count* new-count))
|
||||||
(reset! children-count* 0))))
|
|
||||||
|
(reset! children-count* 0))
|
||||||
|
|
||||||
|
(fn []
|
||||||
|
(when-let [obs (mf/ref-val observer-ref)]
|
||||||
|
(.disconnect obs)
|
||||||
|
(mf/set-ref-val! obs nil)))))
|
||||||
|
|
||||||
;; Re-observe sentinel whenever children-count changes (sentinel moves)
|
;; Re-observe sentinel whenever children-count changes (sentinel moves)
|
||||||
;; and (shapes item) to reconnect observer after shape changes
|
;; and (shapes item) to reconnect observer after shape changes
|
||||||
(mf/with-effect [children-count expanded? (:shapes item)]
|
(mf/with-effect [children-count is-expanded shapes]
|
||||||
(let [total (count (:shapes item))
|
(let [total (count shapes)
|
||||||
node (mf/ref-val ref)
|
name-node (mf/ref-val name-node-ref)
|
||||||
scroll-node (dom/get-parent-with-data node "scroll-container")
|
scroll-node (dom/get-parent-with-data name-node "scroll-container")
|
||||||
lazy-node (mf/ref-val lazy-ref)]
|
lazy-node (mf/ref-val lazy-ref)]
|
||||||
|
|
||||||
;; Disconnect previous observer
|
;; Disconnect previous observer
|
||||||
(when-let [obs ^js @observer-var]
|
(when-let [obs (mf/ref-val observer-ref)]
|
||||||
(.disconnect obs)
|
(.disconnect obs)
|
||||||
(reset! observer-var nil))
|
(mf/set-ref-val! observer-ref nil))
|
||||||
|
|
||||||
;; Setup new observer if there are more children to load
|
;; Setup new observer if there are more children to load
|
||||||
(when (and expanded?
|
(when (and ^boolean is-expanded
|
||||||
(< children-count total)
|
^boolean (< children-count total)
|
||||||
scroll-node
|
^boolean scroll-node
|
||||||
lazy-node)
|
^boolean lazy-node)
|
||||||
(let [cb (fn [entries]
|
(let [cb (fn [entries]
|
||||||
(when (and (seq entries)
|
(when (and (pos? (alength entries))
|
||||||
(.-isIntersecting (first entries)))
|
(.-isIntersecting ^js (aget entries 0)))
|
||||||
;; Load next chunk when sentinel intersects
|
;; Load next chunk when sentinel intersects
|
||||||
(let [current @children-count*
|
(let [next-count (mth/min total (+ children-count default-chunk-size))]
|
||||||
next-count (min total (+ current chunk-size))]
|
|
||||||
(reset! children-count* next-count))))
|
(reset! children-count* next-count))))
|
||||||
observer (js/IntersectionObserver. cb #js {:root scroll-node})]
|
observer (js/IntersectionObserver. cb #js {:root scroll-node})]
|
||||||
(.observe observer lazy-node)
|
(.observe observer lazy-node)
|
||||||
(reset! observer-var observer)))))
|
(mf/set-ref-val! observer-ref observer)))))
|
||||||
|
|
||||||
[:& layer-item-inner
|
[:> layer-item-inner*
|
||||||
{:ref dref
|
{:ref dref
|
||||||
:item item
|
:item item
|
||||||
:depth depth
|
:depth depth
|
||||||
:parent-size parent-size
|
:parent-size parent-size
|
||||||
:name-ref ref
|
:name-ref name-node-ref
|
||||||
:read-only? read-only?
|
:rename-id rename-id
|
||||||
:highlighted? highlighted?
|
:is-read-only is-read-only
|
||||||
:selected? selected?
|
:is-highlighted is-highlighted
|
||||||
:component-tree? component-tree?
|
:is-selected is-selected
|
||||||
:filtered? filtered?
|
:is-component-tree is-component-tree
|
||||||
:expanded? expanded?
|
:is-filtered is-filtered
|
||||||
:dnd-over? (= (:over dprops) :center)
|
:is-expanded is-expanded
|
||||||
:dnd-over-top? (= (:over dprops) :top)
|
:dnd-over (= (:over dprops) :center)
|
||||||
:dnd-over-bot? (= (:over dprops) :bot)
|
:dnd-over-top (= (:over dprops) :top)
|
||||||
|
:dnd-over-bot (= (:over dprops) :bot)
|
||||||
:on-select-shape select-shape
|
:on-select-shape select-shape
|
||||||
:on-context-menu on-context-menu
|
:on-context-menu on-context-menu
|
||||||
:on-pointer-enter on-pointer-enter
|
:on-pointer-enter on-pointer-enter
|
||||||
@@ -477,31 +551,31 @@
|
|||||||
:on-disable-drag disable-drag
|
:on-disable-drag disable-drag
|
||||||
:on-toggle-visibility toggle-visibility
|
:on-toggle-visibility toggle-visibility
|
||||||
:on-toggle-blocking toggle-blocking
|
:on-toggle-blocking toggle-blocking
|
||||||
|
:on-tab-press on-tab-press
|
||||||
:style style}
|
:style style}
|
||||||
|
|
||||||
(when (and render-children?
|
(when (and ^boolean render-children
|
||||||
(:shapes item)
|
^boolean shapes
|
||||||
expanded?)
|
^boolean is-expanded)
|
||||||
[:div {:class (stl/css-case
|
[:div {:class (stl/css-case
|
||||||
:element-children true
|
:element-children true
|
||||||
:parent-selected selected?
|
:parent-selected is-selected
|
||||||
:sticky-children parent-board?)
|
:sticky-children root-board?)
|
||||||
:data-testid (dm/str "children-" id)}
|
:data-testid (dm/str "children-" id)}
|
||||||
(let [all-children (reverse (d/enumerate (:shapes item)))
|
(for [item (take children-count shapes)]
|
||||||
visible (take children-count all-children)]
|
[:> layer-item*
|
||||||
(for [[index id] visible]
|
|
||||||
(when-let [item (get objects id)]
|
|
||||||
[:& layer-item
|
|
||||||
{:item item
|
{:item item
|
||||||
|
:rename-id rename-id
|
||||||
:highlighted highlighted
|
:highlighted highlighted
|
||||||
:selected selected
|
:selected selected
|
||||||
:index index
|
:index (unchecked-get item "__$__counter")
|
||||||
:objects objects
|
:objects objects
|
||||||
:key (dm/str id)
|
:key (dm/str (get item :id))
|
||||||
:sortable? sortable?
|
:is-sortable is-sortable
|
||||||
:depth depth
|
:depth depth
|
||||||
:parent-size parent-size
|
:parent-size parent-size
|
||||||
:component-child? component-tree?}])))
|
:is-component-child is-component-tree}])
|
||||||
(when (< children-count (count (:shapes item)))
|
|
||||||
|
(when (< children-count (count shapes))
|
||||||
[:div {:ref lazy-ref
|
[:div {:ref lazy-ref
|
||||||
:class (stl/css :lazy-load-sentinel)}])])]))
|
:class (stl/css :lazy-load-sentinel)}])])]))
|
||||||
|
|||||||
@@ -16,39 +16,35 @@
|
|||||||
[app.util.dom :as dom]
|
[app.util.dom :as dom]
|
||||||
[app.util.keyboard :as kbd]
|
[app.util.keyboard :as kbd]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[okulary.core :as l]
|
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
(def ^:private space-for-icons 110)
|
(def ^:private ^:const space-for-icons 110)
|
||||||
|
|
||||||
(def lens:shape-for-rename
|
|
||||||
(-> (l/in [:workspace-local :shape-for-rename])
|
|
||||||
(l/derived st/state)))
|
|
||||||
|
|
||||||
(mf/defc layer-name*
|
(mf/defc layer-name*
|
||||||
{::mf/wrap-props false
|
[{:keys [shape-id rename-id shape-name is-shape-touched disabled-double-click
|
||||||
::mf/forward-ref true}
|
|
||||||
[{:keys [shape-id shape-name is-shape-touched disabled-double-click
|
|
||||||
on-start-edit on-stop-edit depth parent-size is-selected
|
on-start-edit on-stop-edit depth parent-size is-selected
|
||||||
type-comp type-frame component-id is-hidden is-blocked
|
type-comp type-frame component-id is-hidden is-blocked
|
||||||
variant-id variant-name variant-properties variant-error]} external-ref]
|
variant-id variant-name variant-properties variant-error
|
||||||
|
on-tab-press ref]}]
|
||||||
(let [edition* (mf/use-state false)
|
(let [edition* (mf/use-state false)
|
||||||
edition? (deref edition*)
|
edition? (deref edition*)
|
||||||
|
|
||||||
local-ref (mf/use-ref)
|
local-ref (mf/use-ref)
|
||||||
ref (d/nilv external-ref local-ref)
|
ref (d/nilv ref local-ref)
|
||||||
|
|
||||||
shape-for-rename (mf/deref lens:shape-for-rename)
|
shape-name
|
||||||
|
(if variant-id
|
||||||
shape-name (if variant-id
|
|
||||||
(d/nilv variant-error variant-name)
|
(d/nilv variant-error variant-name)
|
||||||
shape-name)
|
shape-name)
|
||||||
|
|
||||||
default-value (if variant-id
|
default-value
|
||||||
|
(mf/with-memo [variant-id variant-error variant-properties]
|
||||||
|
(if variant-id
|
||||||
(or variant-error (ctv/properties-map->formula variant-properties))
|
(or variant-error (ctv/properties-map->formula variant-properties))
|
||||||
shape-name)
|
shape-name))
|
||||||
|
|
||||||
has-path? (str/includes? shape-name "/")
|
has-path?
|
||||||
|
(str/includes? shape-name "/")
|
||||||
|
|
||||||
start-edit
|
start-edit
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
@@ -62,13 +58,14 @@
|
|||||||
|
|
||||||
accept-edit
|
accept-edit
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps shape-id on-stop-edit component-id variant-id variant-name variant-properties)
|
(mf/deps edition? shape-id on-stop-edit component-id variant-id variant-name variant-properties)
|
||||||
(fn []
|
(fn []
|
||||||
|
(when edition?
|
||||||
(let [name-input (mf/ref-val ref)
|
(let [name-input (mf/ref-val ref)
|
||||||
name (str/trim (dom/get-value name-input))]
|
name (str/trim (dom/get-value name-input))]
|
||||||
(on-stop-edit)
|
(on-stop-edit)
|
||||||
(reset! edition* false)
|
(reset! edition* false)
|
||||||
(st/emit! (dw/rename-shape-or-variant shape-id name)))))
|
(st/emit! (dw/rename-shape-or-variant shape-id name))))))
|
||||||
|
|
||||||
cancel-edit
|
cancel-edit
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
@@ -80,15 +77,27 @@
|
|||||||
|
|
||||||
on-key-down
|
on-key-down
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps accept-edit cancel-edit)
|
(mf/deps edition? accept-edit cancel-edit on-tab-press shape-id on-stop-edit)
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(when (kbd/enter? event) (accept-edit))
|
(when (kbd/enter? event) (accept-edit))
|
||||||
(when (kbd/esc? event) (cancel-edit))))
|
(when (kbd/esc? event) (cancel-edit))
|
||||||
|
(when (kbd/tab? event)
|
||||||
|
(dom/prevent-default event)
|
||||||
|
(dom/stop-propagation event)
|
||||||
|
(when edition?
|
||||||
|
(let [name-input (mf/ref-val ref)
|
||||||
|
name (str/trim (dom/get-value name-input))]
|
||||||
|
(on-stop-edit)
|
||||||
|
(reset! edition* false)
|
||||||
|
(st/emit! (dw/end-rename-shape shape-id name))
|
||||||
|
(when (fn? on-tab-press)
|
||||||
|
(on-tab-press event)))))))
|
||||||
|
|
||||||
parent-size (dm/str (- parent-size space-for-icons) "px")]
|
parent-size
|
||||||
|
(dm/str (- parent-size space-for-icons) "px")]
|
||||||
|
|
||||||
(mf/with-effect [shape-for-rename edition? start-edit shape-id]
|
(mf/with-effect [rename-id edition? start-edit shape-id]
|
||||||
(when (and (= shape-for-rename shape-id)
|
(when (and (= rename-id shape-id)
|
||||||
(not ^boolean edition?))
|
(not ^boolean edition?))
|
||||||
(start-edit)))
|
(start-edit)))
|
||||||
|
|
||||||
@@ -110,9 +119,9 @@
|
|||||||
:auto-focus true
|
:auto-focus true
|
||||||
:id (dm/str "layer-name-" shape-id)
|
:id (dm/str "layer-name-" shape-id)
|
||||||
:default-value (d/nilv default-value "")}]
|
:default-value (d/nilv default-value "")}]
|
||||||
|
|
||||||
[:*
|
[:*
|
||||||
[:span
|
[:span {:class (stl/css-case
|
||||||
{:class (stl/css-case
|
|
||||||
:element-name true
|
:element-name true
|
||||||
:left-ellipsis has-path?
|
:left-ellipsis has-path?
|
||||||
:selected is-selected
|
:selected is-selected
|
||||||
@@ -123,8 +132,11 @@
|
|||||||
:style {"--depth" depth "--parent-size" parent-size}
|
:style {"--depth" depth "--parent-size" parent-size}
|
||||||
:ref ref
|
:ref ref
|
||||||
:on-double-click start-edit}
|
:on-double-click start-edit}
|
||||||
(if (dbg/enabled? :show-ids)
|
|
||||||
(str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24))
|
(if ^boolean (dbg/enabled? :show-ids)
|
||||||
|
(dm/str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24))
|
||||||
(d/nilv shape-name ""))]
|
(d/nilv shape-name ""))]
|
||||||
(when (and (dbg/enabled? :show-touched) ^boolean is-shape-touched)
|
|
||||||
|
(when (and ^boolean (dbg/enabled? :show-touched)
|
||||||
|
^boolean is-shape-touched)
|
||||||
[:span {:class (stl/css :element-name-touched)} "*"])])))
|
[:span {:class (stl/css :element-name-touched)} "*"])])))
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
||||||
[app.main.ui.hooks :as hooks]
|
[app.main.ui.hooks :as hooks]
|
||||||
[app.main.ui.notifications.badge :refer [badge-notification]]
|
[app.main.ui.notifications.badge :refer [badge-notification]]
|
||||||
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item]]
|
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item*]]
|
||||||
[app.util.dom :as dom]
|
[app.util.dom :as dom]
|
||||||
[app.util.globals :as globals]
|
[app.util.globals :as globals]
|
||||||
[app.util.i18n :as i18n :refer [tr]]
|
[app.util.i18n :as i18n :refer [tr]]
|
||||||
@@ -31,92 +31,160 @@
|
|||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[goog.events :as events]
|
[goog.events :as events]
|
||||||
[rumext.v2 :as mf])
|
[okulary.core :as l]
|
||||||
(:import
|
[rumext.v2 :as mf]))
|
||||||
goog.events.EventType))
|
|
||||||
|
(def ^:private ref:highlighted-shapes
|
||||||
|
(l/derived (fn [local]
|
||||||
|
(-> local
|
||||||
|
(get :highlighted)
|
||||||
|
(not-empty)))
|
||||||
|
refs/workspace-local))
|
||||||
|
|
||||||
|
(def ^:private ref:shape-for-rename
|
||||||
|
(l/derived (l/key :shape-for-rename) refs/workspace-local))
|
||||||
|
|
||||||
|
(defn- use-selected-shapes
|
||||||
|
"A convencience hook wrapper for get selected shapes"
|
||||||
|
[]
|
||||||
|
(let [selected (mf/deref refs/selected-shapes)]
|
||||||
|
(hooks/use-equal-memo selected)))
|
||||||
|
|
||||||
;; This components is a piece for sharding equality check between top
|
;; This components is a piece for sharding equality check between top
|
||||||
;; level frames and try to avoid rerender frames that are does not
|
;; level frames and try to avoid rerender frames that are does not
|
||||||
;; affected by the selected set.
|
;; affected by the selected set.
|
||||||
(mf/defc frame-wrapper
|
(mf/defc frame-wrapper*
|
||||||
{::mf/props :obj}
|
|
||||||
[{:keys [selected] :as props}]
|
[{:keys [selected] :as props}]
|
||||||
(let [pending-selected (mf/use-var selected)
|
(let [pending-selected-ref
|
||||||
current-selected (mf/use-state selected)
|
(mf/use-ref selected)
|
||||||
props (mf/spread-object props {:selected @current-selected})
|
|
||||||
|
current-selected
|
||||||
|
(mf/use-state selected)
|
||||||
|
|
||||||
|
props
|
||||||
|
(mf/spread-object props {:selected @current-selected})
|
||||||
|
|
||||||
set-selected
|
set-selected
|
||||||
(mf/use-memo
|
(mf/with-memo []
|
||||||
(fn []
|
(throttle-fn 50 #(when-let [pending-selected (mf/ref-val pending-selected-ref)]
|
||||||
(throttle-fn
|
(reset! current-selected pending-selected))))]
|
||||||
50
|
|
||||||
#(when-let [pending-selected @pending-selected]
|
|
||||||
(reset! current-selected pending-selected)))))]
|
|
||||||
|
|
||||||
(mf/with-effect [selected set-selected]
|
(mf/with-effect [selected set-selected]
|
||||||
(reset! pending-selected selected)
|
(mf/set-ref-val! pending-selected-ref selected)
|
||||||
(set-selected)
|
(^function set-selected)
|
||||||
(fn []
|
(fn []
|
||||||
(reset! pending-selected nil)
|
(mf/set-ref-val! pending-selected-ref nil)
|
||||||
#(rx/dispose! set-selected)))
|
(rx/dispose! set-selected)))
|
||||||
|
|
||||||
[:> layer-item props]))
|
[:> layer-item* props]))
|
||||||
|
|
||||||
|
(mf/defc layers-tree*
|
||||||
|
{::mf/wrap [mf/memo]}
|
||||||
|
[{:keys [objects is-filtered parent-size] :as props}]
|
||||||
|
(let [selected (use-selected-shapes)
|
||||||
|
highlighted (mf/deref ref:highlighted-shapes)
|
||||||
|
root (get objects uuid/zero)
|
||||||
|
|
||||||
|
rename-id (mf/deref ref:shape-for-rename)
|
||||||
|
|
||||||
|
shapes (get root :shapes)
|
||||||
|
shapes (mf/with-memo [shapes objects]
|
||||||
|
(loop [counter 0
|
||||||
|
shapes (seq shapes)
|
||||||
|
result (list)]
|
||||||
|
(if-let [id (first shapes)]
|
||||||
|
(if-let [obj (get objects id)]
|
||||||
|
(do
|
||||||
|
;; NOTE: this is a bit hacky, but reduces substantially
|
||||||
|
;; the allocation; If we use enumeration, we allocate
|
||||||
|
;; new sequence and add one iteration on each render,
|
||||||
|
;; independently if objects are changed or not. If we
|
||||||
|
;; store counter on metadata, we still need to create a
|
||||||
|
;; new allocation for each shape; with this method we
|
||||||
|
;; bypass this by mutating a private property on the
|
||||||
|
;; object removing extra allocation and extra iteration
|
||||||
|
;; on every request.
|
||||||
|
(unchecked-set obj "__$__counter" counter)
|
||||||
|
(recur (inc counter)
|
||||||
|
(rest shapes)
|
||||||
|
(conj result obj)))
|
||||||
|
(recur (inc counter)
|
||||||
|
(rest shapes)
|
||||||
|
result))
|
||||||
|
result)))]
|
||||||
|
|
||||||
(mf/defc layers-tree
|
|
||||||
{::mf/wrap [mf/memo #(mf/throttle % 200)]
|
|
||||||
::mf/wrap-props false}
|
|
||||||
[{:keys [objects filtered? parent-size] :as props}]
|
|
||||||
(let [selected (mf/deref refs/selected-shapes)
|
|
||||||
selected (hooks/use-equal-memo selected)
|
|
||||||
highlighted (mf/deref refs/highlighted-shapes)
|
|
||||||
highlighted (hooks/use-equal-memo highlighted)
|
|
||||||
root (get objects uuid/zero)]
|
|
||||||
[:div {:class (stl/css :element-list) :data-testid "layer-item"}
|
[:div {:class (stl/css :element-list) :data-testid "layer-item"}
|
||||||
[:> hooks/sortable-container* {}
|
[:> hooks/sortable-container* {}
|
||||||
(for [[index id] (reverse (d/enumerate (:shapes root)))]
|
(for [obj shapes]
|
||||||
(when-let [obj (get objects id)]
|
|
||||||
(if (cfh/frame-shape? obj)
|
(if (cfh/frame-shape? obj)
|
||||||
[:& frame-wrapper
|
[:> frame-wrapper*
|
||||||
{:item obj
|
{:item obj
|
||||||
|
:rename-id rename-id
|
||||||
:selected selected
|
:selected selected
|
||||||
:highlighted highlighted
|
:highlighted highlighted
|
||||||
:index index
|
:index (unchecked-get obj "__$__counter")
|
||||||
:objects objects
|
:objects objects
|
||||||
:key id
|
:key (dm/str (get obj :id))
|
||||||
:sortable? true
|
:is-sortable true
|
||||||
:filtered? filtered?
|
:is-filtered is-filtered
|
||||||
:parent-size parent-size
|
:parent-size parent-size
|
||||||
:depth -1}]
|
:depth -1}]
|
||||||
[:& layer-item
|
[:> layer-item*
|
||||||
{:item obj
|
{:item obj
|
||||||
|
:rename-id rename-id
|
||||||
:selected selected
|
:selected selected
|
||||||
:highlighted highlighted
|
:highlighted highlighted
|
||||||
:index index
|
:index (unchecked-get obj "__$__counter")
|
||||||
:objects objects
|
:objects objects
|
||||||
:key id
|
:key (dm/str (get obj :id))
|
||||||
:sortable? true
|
:is-sortable true
|
||||||
:filtered? filtered?
|
:is-filtered is-filtered
|
||||||
:depth -1
|
:depth -1
|
||||||
:parent-size parent-size}])))]]))
|
:parent-size parent-size}]))]]))
|
||||||
|
|
||||||
(mf/defc filters-tree
|
(mf/defc layers-tree-wrapper*
|
||||||
{::mf/wrap [mf/memo #(mf/throttle % 200)]
|
{::mf/private true}
|
||||||
::mf/wrap-props false}
|
[{:keys [objects] :as props}]
|
||||||
|
;; This is a performance sensitive componet, so we use lower-level primitives for
|
||||||
|
;; reduce residual allocation for this specific case
|
||||||
|
(let [state-tmp (mf/useState objects)
|
||||||
|
objects' (aget state-tmp 0)
|
||||||
|
set-objects (aget state-tmp 1)
|
||||||
|
|
||||||
|
subject-s (mf/with-memo []
|
||||||
|
(rx/subject))
|
||||||
|
changes-s (mf/with-memo [subject-s]
|
||||||
|
(->> subject-s
|
||||||
|
(rx/debounce 500)))
|
||||||
|
|
||||||
|
props (mf/spread-props props {:objects objects'})]
|
||||||
|
|
||||||
|
(mf/with-effect [objects subject-s]
|
||||||
|
(rx/push! subject-s objects))
|
||||||
|
|
||||||
|
(mf/with-effect [changes-s]
|
||||||
|
(let [sub (rx/subscribe changes-s set-objects)]
|
||||||
|
#(rx/dispose! sub)))
|
||||||
|
|
||||||
|
[:> layers-tree* props]))
|
||||||
|
|
||||||
|
(mf/defc filters-tree*
|
||||||
|
{::mf/wrap [mf/memo #(mf/throttle % 300)]
|
||||||
|
::mf/private true}
|
||||||
[{:keys [objects parent-size]}]
|
[{:keys [objects parent-size]}]
|
||||||
(let [selected (mf/deref refs/selected-shapes)
|
(let [selected (use-selected-shapes)
|
||||||
selected (hooks/use-equal-memo selected)
|
|
||||||
root (get objects uuid/zero)]
|
root (get objects uuid/zero)]
|
||||||
[:ul {:class (stl/css :element-list)}
|
[:ul {:class (stl/css :element-list)}
|
||||||
(for [[index id] (d/enumerate (:shapes root))]
|
(for [[index id] (d/enumerate (:shapes root))]
|
||||||
(when-let [obj (get objects id)]
|
(when-let [obj (get objects id)]
|
||||||
[:& layer-item
|
[:> layer-item*
|
||||||
{:item obj
|
{:item obj
|
||||||
:selected selected
|
:selected selected
|
||||||
:index index
|
:index index
|
||||||
:objects objects
|
:objects objects
|
||||||
:key id
|
:key id
|
||||||
:sortable? false
|
:is-sortable false
|
||||||
:filtered? true
|
:is-filtered true
|
||||||
:depth -1
|
:depth -1
|
||||||
:parent-size parent-size}]))]))
|
:parent-size parent-size}]))]))
|
||||||
|
|
||||||
@@ -132,6 +200,7 @@
|
|||||||
keys
|
keys
|
||||||
(filter #(not= uuid/zero %))
|
(filter #(not= uuid/zero %))
|
||||||
vec)]
|
vec)]
|
||||||
|
|
||||||
(update reparented-objects uuid/zero assoc :shapes reparented-shapes)))
|
(update reparented-objects uuid/zero assoc :shapes reparented-shapes)))
|
||||||
|
|
||||||
;; --- Layers Toolbox
|
;; --- Layers Toolbox
|
||||||
@@ -277,9 +346,11 @@
|
|||||||
(swap! state* update :num-items + 100))))]
|
(swap! state* update :num-items + 100))))]
|
||||||
|
|
||||||
(mf/with-effect []
|
(mf/with-effect []
|
||||||
(let [keys [(events/listen globals/document EventType.KEYDOWN on-key-down)
|
(let [key1 (events/listen globals/document "keydown" on-key-down)
|
||||||
(events/listen globals/document EventType.CLICK hide-menu)]]
|
key2 (events/listen globals/document "click" hide-menu)]
|
||||||
(fn [] (doseq [key keys] (events/unlistenByKey key)))))
|
(fn []
|
||||||
|
(events/unlistenByKey key1)
|
||||||
|
(events/unlistenByKey key2))))
|
||||||
|
|
||||||
[filtered-objects
|
[filtered-objects
|
||||||
handle-show-more
|
handle-show-more
|
||||||
@@ -464,6 +535,8 @@
|
|||||||
{::mf/wrap [mf/memo]}
|
{::mf/wrap [mf/memo]}
|
||||||
[{:keys [size-parent]}]
|
[{:keys [size-parent]}]
|
||||||
(let [page (mf/deref refs/workspace-page)
|
(let [page (mf/deref refs/workspace-page)
|
||||||
|
page-id (get page :id)
|
||||||
|
|
||||||
focus (mf/deref refs/workspace-focus-selected)
|
focus (mf/deref refs/workspace-focus-selected)
|
||||||
|
|
||||||
objects (hooks/with-focus-objects (:objects page) focus)
|
objects (hooks/with-focus-objects (:objects page) focus)
|
||||||
@@ -473,7 +546,8 @@
|
|||||||
observer-var (mf/use-var nil)
|
observer-var (mf/use-var nil)
|
||||||
lazy-load-ref (mf/use-ref nil)
|
lazy-load-ref (mf/use-ref nil)
|
||||||
|
|
||||||
[filtered-objects show-more filter-component] (use-search page objects)
|
[filtered-objects show-more filter-component]
|
||||||
|
(use-search page objects)
|
||||||
|
|
||||||
intersection-callback
|
intersection-callback
|
||||||
(fn [entries]
|
(fn [entries]
|
||||||
@@ -519,8 +593,8 @@
|
|||||||
[:div {:class (stl/css :tool-window-content)
|
[:div {:class (stl/css :tool-window-content)
|
||||||
:data-scroll-container true
|
:data-scroll-container true
|
||||||
:ref on-render-container}
|
:ref on-render-container}
|
||||||
[:& filters-tree {:objects filtered-objects
|
[:> filters-tree* {:objects filtered-objects
|
||||||
:key (dm/str (:id page))
|
:key (dm/str page-id)
|
||||||
:parent-size size-parent}]
|
:parent-size size-parent}]
|
||||||
[:div {:ref lazy-load-ref}]]
|
[:div {:ref lazy-load-ref}]]
|
||||||
[:div {:on-scroll on-scroll
|
[:div {:on-scroll on-scroll
|
||||||
@@ -528,16 +602,16 @@
|
|||||||
:data-scroll-container true
|
:data-scroll-container true
|
||||||
:style {:display (when (some? filtered-objects) "none")}}
|
:style {:display (when (some? filtered-objects) "none")}}
|
||||||
|
|
||||||
[:& layers-tree {:objects filtered-objects
|
[:> layers-tree-wrapper* {:objects filtered-objects
|
||||||
:key (dm/str (:id page))
|
:key (dm/str page-id)
|
||||||
:filtered? true
|
:is-filtered true
|
||||||
:parent-size size-parent}]]]
|
:parent-size size-parent}]]]
|
||||||
|
|
||||||
[:div {:on-scroll on-scroll
|
[:div {:on-scroll on-scroll
|
||||||
:class (stl/css :tool-window-content)
|
:class (stl/css :tool-window-content)
|
||||||
:data-scroll-container true
|
:data-scroll-container true
|
||||||
:style {:display (when (some? filtered-objects) "none")}}
|
:style {:display (when (some? filtered-objects) "none")}}
|
||||||
[:& layers-tree {:objects objects
|
[:> layers-tree-wrapper* {:objects objects
|
||||||
:key (dm/str (:id page))
|
:key (dm/str page-id)
|
||||||
:filtered? false
|
:is-filtered false
|
||||||
:parent-size size-parent}]])]))
|
:parent-size size-parent}]])]))
|
||||||
|
|||||||
@@ -96,14 +96,14 @@
|
|||||||
id (dm/str (:id token) "-name")
|
id (dm/str (:id token) "-name")
|
||||||
swatch-tooltip-content (cond
|
swatch-tooltip-content (cond
|
||||||
not-active
|
not-active
|
||||||
(tr "ds.inputs.token-field.no-active-token-option")
|
(tr "ds.inputs.token-field.no-active-token-option" token-name)
|
||||||
has-errors
|
has-errors
|
||||||
(tr "color-row.token-color-row.deleted-token")
|
(tr "color-row.token-color-row.deleted-token")
|
||||||
:else
|
:else
|
||||||
(tr "workspace.tokens.resolved-value" resolved))
|
(tr "workspace.tokens.resolved-value" resolved))
|
||||||
name-tooltip-content (cond
|
name-tooltip-content (cond
|
||||||
not-active
|
not-active
|
||||||
(tr "ds.inputs.token-field.no-active-token-option")
|
(tr "ds.inputs.token-field.no-active-token-option" token-name)
|
||||||
has-errors
|
has-errors
|
||||||
(tr "color-row.token-color-row.deleted-token")
|
(tr "color-row.token-color-row.deleted-token")
|
||||||
:else
|
:else
|
||||||
|
|||||||
@@ -483,9 +483,6 @@
|
|||||||
(reset! open-sections [[1]])
|
(reset! open-sections [[1]])
|
||||||
(reset! filter-term "")))]
|
(reset! filter-term "")))]
|
||||||
|
|
||||||
(mf/with-effect []
|
|
||||||
(dom/focus! (dom/get-element "shortcut-search")))
|
|
||||||
|
|
||||||
[:div {:class (dm/str class " " (stl/css :shortcuts))}
|
[:div {:class (dm/str class " " (stl/css :shortcuts))}
|
||||||
[:> panel-title* {:class (stl/css :shortcuts-title)
|
[:> panel-title* {:class (stl/css :shortcuts-title)
|
||||||
:text (tr "shortcuts.title")
|
:text (tr "shortcuts.title")
|
||||||
@@ -496,7 +493,8 @@
|
|||||||
:on-clear on-search-clear-click
|
:on-clear on-search-clear-click
|
||||||
:value @filter-term
|
:value @filter-term
|
||||||
:placeholder (tr "shortcuts.title")
|
:placeholder (tr "shortcuts.title")
|
||||||
:icon-id i/search}]]
|
:icon-id i/search
|
||||||
|
:auto-focus true}]]
|
||||||
|
|
||||||
(if match-any?
|
(if match-any?
|
||||||
[:div {:class (stl/css :shortcuts-list)}
|
[:div {:class (stl/css :shortcuts-list)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.types.tokens-lib :as ctob]
|
[app.common.types.tokens-lib :as ctob]
|
||||||
[app.main.data.modal :as modal]
|
[app.main.data.modal :as modal]
|
||||||
|
[app.main.data.notifications :as ntf]
|
||||||
[app.main.data.workspace.tokens.application :as dwta]
|
[app.main.data.workspace.tokens.application :as dwta]
|
||||||
[app.main.data.workspace.tokens.library-edit :as dwtl]
|
[app.main.data.workspace.tokens.library-edit :as dwtl]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
@@ -75,7 +76,11 @@
|
|||||||
is-type-unfolded (contains? (set unfolded-token-paths) (name type))
|
is-type-unfolded (contains? (set unfolded-token-paths) (name type))
|
||||||
|
|
||||||
editing-ref (mf/deref refs/workspace-editor-state)
|
editing-ref (mf/deref refs/workspace-editor-state)
|
||||||
not-editing? (empty? editing-ref)
|
edition (mf/deref refs/selected-edition)
|
||||||
|
objects (mf/deref refs/workspace-page-objects)
|
||||||
|
not-editing? (and (empty? editing-ref)
|
||||||
|
(not (and (some? edition)
|
||||||
|
(= :text (:type (get objects edition))))))
|
||||||
|
|
||||||
can-edit?
|
can-edit?
|
||||||
(mf/use-ctx ctx/can-edit?)
|
(mf/use-ctx ctx/can-edit?)
|
||||||
@@ -132,13 +137,17 @@
|
|||||||
|
|
||||||
on-token-pill-click
|
on-token-pill-click
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps not-editing? selected-ids)
|
(mf/deps not-editing? selected-ids tokens-lib)
|
||||||
(fn [event token]
|
(fn [event token]
|
||||||
(let [token (ctob/get-token tokens-lib selected-token-set-id (:id token))]
|
(let [token (ctob/get-token tokens-lib selected-token-set-id (:id token))]
|
||||||
(dom/stop-propagation event)
|
(dom/stop-propagation event)
|
||||||
(when (and not-editing? (seq selected-shapes) (not= (:type token) :number))
|
(if (and not-editing? (seq selected-shapes) (not= (:type token) :number))
|
||||||
(st/emit! (dwta/toggle-token {:token token
|
(st/emit! (dwta/toggle-token {:token token
|
||||||
:shape-ids selected-ids}))))))]
|
:shape-ids selected-ids}))
|
||||||
|
(st/emit! (ntf/show {:content (tr "workspace.tokens.error-text-edition")
|
||||||
|
:type :toast
|
||||||
|
:level :warning
|
||||||
|
:timeout 3000}))))))]
|
||||||
|
|
||||||
[:div {:class (stl/css :token-section-wrapper)
|
[:div {:class (stl/css :token-section-wrapper)
|
||||||
:data-testid (dm/str "section-" (name type))}
|
:data-testid (dm/str "section-" (name type))}
|
||||||
|
|||||||
@@ -10,12 +10,12 @@
|
|||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.types.shape.layout :as ctl]
|
[app.common.types.shape.layout :as ctl]
|
||||||
[app.main.data.workspace.shape-layout :as dwsl]
|
[app.main.data.workspace.shape-layout :as dwsl]
|
||||||
[app.main.data.workspace.transforms :as dwt]
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
|
[app.plugins.flags :refer [natural-child-ordering?]]
|
||||||
[app.plugins.register :as r]
|
[app.plugins.register :as r]
|
||||||
[app.plugins.utils :as u]
|
[app.plugins.utils :as u]
|
||||||
[app.util.object :as obj]
|
[app.util.object :as obj]))
|
||||||
[potok.v2.core :as ptk]))
|
|
||||||
|
|
||||||
;; Define in `app.plugins.shape` we do this way to prevent circular dependency
|
;; Define in `app.plugins.shape` we do this way to prevent circular dependency
|
||||||
(def shape-proxy? nil)
|
(def shape-proxy? nil)
|
||||||
@@ -259,9 +259,13 @@
|
|||||||
(u/display-not-valid :appendChild child)
|
(u/display-not-valid :appendChild child)
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(let [child-id (obj/get child "$id")]
|
(let [child-id (obj/get child "$id")
|
||||||
(st/emit! (dwt/move-shapes-to-frame #{child-id} id nil nil)
|
shape (u/locate-shape file-id page-id id)
|
||||||
(ptk/data-event :layout/update {:ids [id]})))))
|
index
|
||||||
|
(if (and (natural-child-ordering? plugin-id) (not (ctl/reverse? shape)))
|
||||||
|
0
|
||||||
|
(count (:shapes shape)))]
|
||||||
|
(st/emit! (dwsh/relocate-shapes #{child-id} id index)))))
|
||||||
|
|
||||||
:horizontalSizing
|
:horizontalSizing
|
||||||
{:this true
|
{:this true
|
||||||
|
|||||||
@@ -962,7 +962,8 @@
|
|||||||
:else
|
:else
|
||||||
(let [child-id (obj/get child "$id")
|
(let [child-id (obj/get child "$id")
|
||||||
is-reversed? (ctl/flex-layout? shape)
|
is-reversed? (ctl/flex-layout? shape)
|
||||||
index (if (and (natural-child-ordering? plugin-id) is-reversed?)
|
index
|
||||||
|
(if (or (not (natural-child-ordering? plugin-id)) is-reversed?)
|
||||||
0
|
0
|
||||||
(count (:shapes shape)))]
|
(count (:shapes shape)))]
|
||||||
(st/emit! (dwsh/relocate-shapes #{child-id} id index))))))
|
(st/emit! (dwsh/relocate-shapes #{child-id} id index))))))
|
||||||
@@ -987,7 +988,7 @@
|
|||||||
(let [child-id (obj/get child "$id")
|
(let [child-id (obj/get child "$id")
|
||||||
is-reversed? (ctl/flex-layout? shape)
|
is-reversed? (ctl/flex-layout? shape)
|
||||||
index
|
index
|
||||||
(if (and (natural-child-ordering? plugin-id) is-reversed?)
|
(if (or (not (natural-child-ordering? plugin-id)) is-reversed?)
|
||||||
(- (count (:shapes shape)) index)
|
(- (count (:shapes shape)) index)
|
||||||
index)]
|
index)]
|
||||||
(st/emit! (dwsh/relocate-shapes #{child-id} id index))))))
|
(st/emit! (dwsh/relocate-shapes #{child-id} id index))))))
|
||||||
|
|||||||
@@ -1362,7 +1362,7 @@ msgstr "Token trennen"
|
|||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
msgid "ds.inputs.token-field.no-active-token-option"
|
||||||
msgstr "Dieser Token ist nicht Teil eines aktiven Sets oder ungültig."
|
msgstr "%s ist nicht Teil eines aktiven Sets oder ungültig."
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
|
|||||||
@@ -1284,7 +1284,7 @@ msgstr "Detach token"
|
|||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
msgid "ds.inputs.token-field.no-active-token-option"
|
||||||
msgstr "This token is not in any active set or has an invalid value."
|
msgstr "%s is not in any active set or has an invalid value."
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
@@ -8503,6 +8503,10 @@ msgstr "Invalid value: Units are not allowed."
|
|||||||
msgid "workspace.tokens.warning-name-change"
|
msgid "workspace.tokens.warning-name-change"
|
||||||
msgstr "Renaming this token will break any reference to its old name"
|
msgstr "Renaming this token will break any reference to its old name"
|
||||||
|
|
||||||
|
#: src/app/main/data/workspace/tokens/application.cljs
|
||||||
|
msgid "workspace.tokens.error-text-edition"
|
||||||
|
msgstr "Tokens can't be applied while editing text. Select the text layer instead."
|
||||||
|
|
||||||
#: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166
|
#: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166
|
||||||
msgid "workspace.toolbar.assets"
|
msgid "workspace.toolbar.assets"
|
||||||
msgstr "Assets"
|
msgstr "Assets"
|
||||||
|
|||||||
@@ -1274,7 +1274,7 @@ msgstr "Desvincular token"
|
|||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
msgid "ds.inputs.token-field.no-active-token-option"
|
||||||
msgstr "Este token no está disponible en ningún set o tiene un valor inválido."
|
msgstr "%s no está disponible en ningún set o tiene un valor inválido."
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
@@ -8369,6 +8369,10 @@ msgstr ""
|
|||||||
"Cambiar el nombre de este token romperá cualquier referencia a su nombre "
|
"Cambiar el nombre de este token romperá cualquier referencia a su nombre "
|
||||||
"anterior."
|
"anterior."
|
||||||
|
|
||||||
|
#: src/app/main/data/workspace/tokens/application.cljs
|
||||||
|
msgid "workspace.tokens.error-text-edition"
|
||||||
|
msgstr "No se pueden aplicar tokens mientras se edita texto. Seleccione la capa de texto en su lugar."
|
||||||
|
|
||||||
#: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166
|
#: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166
|
||||||
msgid "workspace.toolbar.assets"
|
msgid "workspace.toolbar.assets"
|
||||||
msgstr "Recursos"
|
msgstr "Recursos"
|
||||||
|
|||||||
@@ -1369,7 +1369,7 @@ msgstr "Détacher le token"
|
|||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
msgid "ds.inputs.token-field.no-active-token-option"
|
||||||
msgstr "Ce token n'est pas disponible dans la collection ou le thème actif."
|
msgstr "%s n'est pas disponible dans la collection ou le thème actif."
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
|
|||||||
@@ -1360,7 +1360,7 @@ msgstr "Détacher du token"
|
|||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
msgid "ds.inputs.token-field.no-active-token-option"
|
||||||
msgstr "Ce token n'est disponible dans aucune collection ou est invalide."
|
msgstr "%s n'est disponible dans aucune collection ou est invalide."
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
|
|||||||
@@ -1185,10 +1185,6 @@ msgstr "פתיחת רשימת אסימונים"
|
|||||||
msgid "ds.inputs.token-field.detach-token"
|
msgid "ds.inputs.token-field.detach-token"
|
||||||
msgstr "ניתוק אסימון"
|
msgstr "ניתוק אסימון"
|
||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
|
||||||
msgstr "האסימון הזה לא זמין באף ערכה או שהערך שלו שגוי."
|
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
msgstr "ספק האימות לא מורשה לפרופיל הזה"
|
msgstr "ספק האימות לא מורשה לפרופיל הזה"
|
||||||
|
|||||||
@@ -1256,10 +1256,6 @@ msgstr "token सूची खोलें"
|
|||||||
msgid "ds.inputs.token-field.detach-token"
|
msgid "ds.inputs.token-field.detach-token"
|
||||||
msgstr "token अलग करें"
|
msgstr "token अलग करें"
|
||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
|
||||||
msgstr "यह token किसी भी सक्रिय सेट में नहीं है या इसका मान अमान्य है।"
|
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
msgstr "इस प्रोफाइल के लिए ऑथ प्रोवाइडर अनुमति नहीं है"
|
msgstr "इस प्रोफाइल के लिए ऑथ प्रोवाइडर अनुमति नहीं है"
|
||||||
|
|||||||
@@ -1355,7 +1355,7 @@ msgstr "Scollega token"
|
|||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
msgid "ds.inputs.token-field.no-active-token-option"
|
||||||
msgstr "Questo token non è disponibile in nessun set o tema attivo."
|
msgstr "%s non è disponibile in nessun set o tema attivo."
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
|
|||||||
@@ -1214,10 +1214,6 @@ msgstr "Atvērt tekstvienību sarakstu"
|
|||||||
msgid "ds.inputs.token-field.detach-token"
|
msgid "ds.inputs.token-field.detach-token"
|
||||||
msgstr "Atdalīt tekstvienību"
|
msgstr "Atdalīt tekstvienību"
|
||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
|
||||||
msgstr "Šī tekstvienība nav nevienā aktīvajā kopā vai tai ir nederīga vērtība."
|
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
msgstr "Autentificēšanās nodrošinātājs nav atļauts šim profilam"
|
msgstr "Autentificēšanās nodrošinātājs nav atļauts šim profilam"
|
||||||
|
|||||||
@@ -1358,7 +1358,7 @@ msgstr "Token loskoppelen"
|
|||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
msgid "ds.inputs.token-field.no-active-token-option"
|
||||||
msgstr "Dit token is niet beschikbaar in een actieve verzameling of thema."
|
msgstr "%s is niet beschikbaar in een actieve verzameling of thema."
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
|
|||||||
@@ -1187,7 +1187,7 @@ msgstr "Desvincular token"
|
|||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
msgid "ds.inputs.token-field.no-active-token-option"
|
||||||
msgstr "Este token não está em nenhum conjunto ativo ou possui um valor inválido."
|
msgstr "%s não está em nenhum conjunto ativo ou possui um valor inválido."
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
|
|||||||
@@ -1204,7 +1204,7 @@ msgstr "Detașează tokenul"
|
|||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
msgid "ds.inputs.token-field.no-active-token-option"
|
||||||
msgstr "Acest token nu este în nici un set activ sau are o valoare invalidă."
|
msgstr "%s nu este în nici un set activ sau are o valoare invalidă."
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
|
|||||||
@@ -1184,12 +1184,6 @@ msgstr "Открыть список токенов"
|
|||||||
msgid "ds.inputs.token-field.detach-token"
|
msgid "ds.inputs.token-field.detach-token"
|
||||||
msgstr "Отсоединить токен"
|
msgstr "Отсоединить токен"
|
||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
|
||||||
msgstr ""
|
|
||||||
"Этот токен не входит ни в один активный набор или имеет недопустимое "
|
|
||||||
"значение."
|
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
msgstr "Поставщик аутентификации не разрешён для этого профиля"
|
msgstr "Поставщик аутентификации не разрешён для этого профиля"
|
||||||
|
|||||||
@@ -1188,7 +1188,7 @@ msgstr "Lösgör token"
|
|||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
msgid "ds.inputs.token-field.no-active-token-option"
|
||||||
msgstr "Denna token är inte i någon aktiv uppsättning eller har ett ogiltigt värde."
|
msgstr "%s är inte i någon aktiv uppsättning eller har ett ogiltigt värde."
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
|
|||||||
@@ -1355,12 +1355,6 @@ msgstr "Token listesini aç"
|
|||||||
msgid "ds.inputs.token-field.detach-token"
|
msgid "ds.inputs.token-field.detach-token"
|
||||||
msgstr "Tokeni ayır"
|
msgstr "Tokeni ayır"
|
||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
|
||||||
msgstr ""
|
|
||||||
"Bu token herhangi bir etkin kümede bulunmuyor veya geçersiz bir değere "
|
|
||||||
"sahip."
|
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
msgstr "Kimlik doğrulama sağlayıcısına bu profil için izin verilmiyor"
|
msgstr "Kimlik doğrulama sağlayıcısına bu profil için izin verilmiyor"
|
||||||
|
|||||||
@@ -1116,10 +1116,6 @@ msgstr "打开token列表"
|
|||||||
msgid "ds.inputs.token-field.detach-token"
|
msgid "ds.inputs.token-field.detach-token"
|
||||||
msgstr "分离token"
|
msgstr "分离token"
|
||||||
|
|
||||||
#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106
|
|
||||||
msgid "ds.inputs.token-field.no-active-token-option"
|
|
||||||
msgstr "该token于任意活动集合或主题皆不可用。"
|
|
||||||
|
|
||||||
#: src/app/main/data/auth.cljs:339
|
#: src/app/main/data/auth.cljs:339
|
||||||
msgid "errors.auth-provider-not-allowed"
|
msgid "errors.auth-provider-not-allowed"
|
||||||
msgstr "认证提供者不允许访问此个人设定"
|
msgstr "认证提供者不允许访问此个人设定"
|
||||||
|
|||||||
1
mcp/.gitignore
vendored
1
mcp/.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
.idea
|
.idea
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
*.tgz
|
||||||
*.bak
|
*.bak
|
||||||
*.orig
|
*.orig
|
||||||
temp
|
temp
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
# Penpot MCP Project Overview - Updated
|
# Penpot MCP Project Overview - Updated
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools with bidirectional WebSocket communication.
|
This project is a Model Context Protocol (MCP) server for Penpot integration.
|
||||||
|
The MCP server communicates with a Penpot plugin via WebSockets, allowing
|
||||||
|
the MCP server to send tasks to the plugin and receive results,
|
||||||
|
enabling advanced AI-driven features in Penpot.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
- **Language**: TypeScript
|
- **Language**: TypeScript
|
||||||
@@ -13,21 +16,22 @@ This project is a Model Context Protocol (MCP) server for Penpot integration. It
|
|||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
```
|
```
|
||||||
penpot-mcp/
|
/ (project root)
|
||||||
├── common/ # Shared type definitions
|
├── packages/common/ # Shared type definitions
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── index.ts # Exports for shared types
|
│ │ ├── index.ts # Exports for shared types
|
||||||
│ │ └── types.ts # PluginTaskResult, request/response interfaces
|
│ │ └── types.ts # PluginTaskResult, request/response interfaces
|
||||||
│ └── package.json # @penpot-mcp/common package
|
│ └── package.json # @penpot-mcp/common package
|
||||||
├── mcp-server/ # Main MCP server implementation
|
├── packages/server/ # Main MCP server implementation
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── index.ts # Main server entry point
|
│ │ ├── index.ts # Main server entry point
|
||||||
│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation
|
│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation
|
||||||
│ │ ├── PluginTask.ts # Now supports result promises
|
│ │ ├── PluginTask.ts # Now supports result promises
|
||||||
│ │ ├── tasks/ # PluginTask implementations
|
│ │ ├── tasks/ # PluginTask implementations
|
||||||
│ │ └── tools/ # Tool implementations
|
│ │ └── tools/ # Tool implementations
|
||||||
|
| ├── data/ # Contains resources, such as API info and prompts
|
||||||
│ └── package.json # Includes @penpot-mcp/common dependency
|
│ └── package.json # Includes @penpot-mcp/common dependency
|
||||||
├── penpot-plugin/ # Penpot plugin with response capability
|
├── packages/plugin/ # Penpot plugin with response capability
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── main.ts # Enhanced WebSocket handling with response forwarding
|
│ │ ├── main.ts # Enhanced WebSocket handling with response forwarding
|
||||||
│ │ └── plugin.ts # Now sends task responses back to server
|
│ │ └── plugin.ts # Now sends task responses back to server
|
||||||
@@ -37,55 +41,24 @@ penpot-mcp/
|
|||||||
|
|
||||||
## Key Tasks
|
## Key Tasks
|
||||||
|
|
||||||
|
### Adjusting the System Prompt
|
||||||
|
|
||||||
|
The system prompt file is located in `packages/server/data/initial_instructions.md`.
|
||||||
|
|
||||||
### Adding a new Tool
|
### Adding a new Tool
|
||||||
|
|
||||||
1. Implement the tool class in `mcp-server/src/tools/` following the `Tool` interface.
|
1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface.
|
||||||
IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally.
|
IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally.
|
||||||
2. Register the tool in `PenpotMcpServer`.
|
2. Register the tool in `PenpotMcpServer`.
|
||||||
|
|
||||||
Look at `PrintTextTool` as an example.
|
Tools can be associated with a `PluginTask` that is executed in the plugin.
|
||||||
|
Many tools build on `ExecuteCodePluginTask`, as many operations can be reduced to code execution.
|
||||||
Many tools are linked to tasks that are handled in the plugin, i.e. they have an associated `PluginTask` implementation in `mcp-server/src/tasks/`.
|
|
||||||
|
|
||||||
### Adding a new PluginTask
|
### Adding a new PluginTask
|
||||||
|
|
||||||
1. Implement the input data interface for the task in `common/src/types.ts`.
|
1. Implement the input data interface for the task in `packages/common/src/types.ts`.
|
||||||
2. Implement the `PluginTask` class in `mcp-server/src/tasks/`.
|
2. Implement the `PluginTask` class in `packages/server/src/tasks/`.
|
||||||
3. Implement the corresponding task handler class in the plugin (`penpot-plugin/src/task-handlers/`).
|
3. Implement the corresponding task handler class in the plugin (`packages/plugin/src/task-handlers/`).
|
||||||
* In the success case, call `task.sendSuccess`.
|
* In the success case, call `task.sendSuccess`.
|
||||||
* In the failure case, just throw an exception, which will be handled centrally!
|
* In the failure case, just throw an exception, which will be handled centrally!
|
||||||
* Look at `PrintTextTaskHandler` as an example.
|
4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list.
|
||||||
4. Register the task handler in `penpot-plugin/src/plugin.ts` in the `taskHandlers` list.
|
|
||||||
|
|
||||||
|
|
||||||
## Key Components
|
|
||||||
|
|
||||||
### Enhanced WebSocket Protocol
|
|
||||||
- **Request Format**: `{id: string, task: string, params: any}`
|
|
||||||
- **Response Format**: `{id: string, result: {success: boolean, error?: string, data?: any}}`
|
|
||||||
- **Request/Response Correlation**: Using unique UUIDs for task tracking
|
|
||||||
- **Timeout Handling**: 30-second timeout with automatic cleanup
|
|
||||||
- **Type Safety**: Shared definitions via @penpot-mcp/common package
|
|
||||||
|
|
||||||
### Core Classes
|
|
||||||
- **PenpotMcpServer**: Enhanced with pending task tracking and response handling
|
|
||||||
- **PluginTask**: Now creates result promises that resolve when plugin responds
|
|
||||||
- **Tool implementations**: Now properly await task completion and report results
|
|
||||||
- **Plugin handlers**: Send structured responses back to server
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
1. **Bidirectional Communication**: Plugin now responds with success/failure status
|
|
||||||
2. **Task Result Promises**: Every executePluginTask() sets and returns a promise
|
|
||||||
3. **Error Reporting**: Failed tasks properly report error messages to tools
|
|
||||||
4. **Shared Type Safety**: Common package ensures consistency across projects
|
|
||||||
5. **Timeout Protection**: Tasks don't hang indefinitely (30s limit)
|
|
||||||
6. **Request Correlation**: Unique IDs match requests to responses
|
|
||||||
|
|
||||||
## Task Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
LLM Tool Call → MCP Server → WebSocket (Request) → Plugin → Penpot API
|
|
||||||
↑ ↓
|
|
||||||
Tool Response ← MCP Server ← WebSocket (Response) ← Plugin Result
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
# whether to use the project's gitignore file to ignore files
|
# whether to use the project's gitignore file to ignore files
|
||||||
# Added on 2025-04-07
|
# Added on 2025-04-07
|
||||||
ignore_all_files_in_gitignore: true
|
ignore_all_files_in_gitignore: true
|
||||||
@@ -65,6 +63,8 @@ initial_prompt: |
|
|||||||
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
|
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
|
||||||
rather than mere functions (i.e. use the strategy pattern, for example).
|
rather than mere functions (i.e. use the strategy pattern, for example).
|
||||||
|
|
||||||
|
Always read the "project_overview" memory.
|
||||||
|
|
||||||
Comments:
|
Comments:
|
||||||
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
|
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
|
||||||
clearly defines *what* it is. Any details then follow in subsequent sentences.
|
clearly defines *what* it is. Any details then follow in subsequent sentences.
|
||||||
@@ -128,3 +128,16 @@ encoding: utf-8
|
|||||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||||
languages:
|
languages:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
|
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||||
|
# such as docstrings or parameter information.
|
||||||
|
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||||
|
# If null or missing, use the setting from the global configuration.
|
||||||
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|||||||
@@ -282,3 +282,5 @@ you may set the following environment variables to configure the two servers
|
|||||||
* The [contribution guidelines for Penpot](../CONTRIBUTING.md) apply
|
* The [contribution guidelines for Penpot](../CONTRIBUTING.md) apply
|
||||||
* Auto-formatting: Use `pnpm run fmt`
|
* Auto-formatting: Use `pnpm run fmt`
|
||||||
* Generating API type data: See [types-generator/README.md](types-generator/README.md)
|
* Generating API type data: See [types-generator/README.md](types-generator/README.md)
|
||||||
|
* Packaging and publishing:
|
||||||
|
- Create npm package: `bash scripts/pack` (sets version and then calls `npm pack`)
|
||||||
22
mcp/bin/mcp-local.js
Normal file
22
mcp/bin/mcp-local.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { execSync } = require("child_process");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function run(command) {
|
||||||
|
execSync(command, { cwd: root, stdio: "inherit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
run("corepack pnpm run bootstrap");
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "ENOENT") {
|
||||||
|
console.error(
|
||||||
|
"corepack is required but was not found. It ships with Node.js >= 16."
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.exit(error.status ?? 1);
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "mcp-meta",
|
"name": "@penpot/mcp",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "MCP server for Penpot integration",
|
||||||
|
"bin": {
|
||||||
|
"penpot-mcp": "./bin/mcp-local.js"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm -r run build",
|
"build": "pnpm -r run build",
|
||||||
"build:multi-user": "pnpm -r run build:multi-user",
|
"build:multi-user": "pnpm -r run build:multi-user",
|
||||||
@@ -18,7 +21,6 @@
|
|||||||
"url": "https://github.com/penpot/penpot.git"
|
"url": "https://github.com/penpot/penpot.git"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
|
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
|
||||||
"private": true,
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"prettier": "^3.0.0"
|
"prettier": "^3.0.0"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Board, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape } from "@penpot/plugin-types";
|
import { Board, Bounds, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape, Text } from "@penpot/plugin-types";
|
||||||
|
|
||||||
export class PenpotUtils {
|
export class PenpotUtils {
|
||||||
/**
|
/**
|
||||||
@@ -189,6 +189,24 @@ export class PenpotUtils {
|
|||||||
return penpot.generateStyle([shape], { type: "css", includeChildren: true });
|
return penpot.generateStyle([shape], { type: "css", includeChildren: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the actual rendering bounds of a shape. For most shapes, this is simply the `bounds` property.
|
||||||
|
* However, for Text shapes, the `bounds` may not reflect the true size of the rendered text content,
|
||||||
|
* so we use the `textBounds` property instead.
|
||||||
|
*
|
||||||
|
* @param shape - The shape to get the bounds for
|
||||||
|
*/
|
||||||
|
public static getBounds(shape: Shape): Bounds {
|
||||||
|
if (shape.type === "text") {
|
||||||
|
const text = shape as Text;
|
||||||
|
// TODO: Remove ts-ignore once type definitions are updated
|
||||||
|
// @ts-ignore
|
||||||
|
return text.textBounds;
|
||||||
|
} else {
|
||||||
|
return shape.bounds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a child shape is fully contained within its parent's bounds.
|
* Checks if a child shape is fully contained within its parent's bounds.
|
||||||
* Visual containment means all edges of the child are within the parent's bounding box.
|
* Visual containment means all edges of the child are within the parent's bounding box.
|
||||||
@@ -198,11 +216,13 @@ export class PenpotUtils {
|
|||||||
* @returns true if child is fully contained within parent bounds, false otherwise
|
* @returns true if child is fully contained within parent bounds, false otherwise
|
||||||
*/
|
*/
|
||||||
public static isContainedIn(child: Shape, parent: Shape): boolean {
|
public static isContainedIn(child: Shape, parent: Shape): boolean {
|
||||||
|
const childBounds = this.getBounds(child);
|
||||||
|
const parentBounds = this.getBounds(parent);
|
||||||
return (
|
return (
|
||||||
child.x >= parent.x &&
|
childBounds.x >= parentBounds.x &&
|
||||||
child.y >= parent.y &&
|
childBounds.y >= parentBounds.y &&
|
||||||
child.x + child.width <= parent.x + parent.width &&
|
childBounds.x + childBounds.width <= parentBounds.x + parentBounds.width &&
|
||||||
child.y + child.height <= parent.y + parent.height
|
childBounds.y + childBounds.height <= parentBounds.y + parentBounds.height
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,39 +318,16 @@ export class PenpotUtils {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes a base64 string to a Uint8Array.
|
* Decodes a base64 string to a Uint8Array.
|
||||||
* This is required because the Penpot plugin environment does not provide the atob function.
|
|
||||||
*
|
*
|
||||||
* @param base64 - The base64-encoded string to decode
|
* @param base64 - The base64-encoded string to decode
|
||||||
* @returns The decoded data as a Uint8Array
|
* @returns The decoded data as a Uint8Array
|
||||||
*/
|
*/
|
||||||
public static atob(base64: string): Uint8Array {
|
public static base64ToByteArray(base64: string): Uint8Array {
|
||||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
const binary = atob(base64);
|
||||||
const lookup = new Uint8Array(256);
|
const bytes = new Uint8Array(binary.length);
|
||||||
for (let i = 0; i < chars.length; i++) {
|
for (let i = 0; i < binary.length; i++) {
|
||||||
lookup[chars.charCodeAt(i)] = i;
|
bytes[i] = binary.charCodeAt(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
let bufferLength = base64.length * 0.75;
|
|
||||||
if (base64[base64.length - 1] === "=") {
|
|
||||||
bufferLength--;
|
|
||||||
if (base64[base64.length - 2] === "=") {
|
|
||||||
bufferLength--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = new Uint8Array(bufferLength);
|
|
||||||
let p = 0;
|
|
||||||
for (let i = 0; i < base64.length; i += 4) {
|
|
||||||
const encoded1 = lookup[base64.charCodeAt(i)];
|
|
||||||
const encoded2 = lookup[base64.charCodeAt(i + 1)];
|
|
||||||
const encoded3 = lookup[base64.charCodeAt(i + 2)];
|
|
||||||
const encoded4 = lookup[base64.charCodeAt(i + 3)];
|
|
||||||
|
|
||||||
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
|
|
||||||
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
|
|
||||||
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +357,7 @@ export class PenpotUtils {
|
|||||||
height: number | undefined
|
height: number | undefined
|
||||||
): Promise<Rectangle> {
|
): Promise<Rectangle> {
|
||||||
// convert base64 to Uint8Array
|
// convert base64 to Uint8Array
|
||||||
const bytes = PenpotUtils.atob(base64);
|
const bytes = PenpotUtils.base64ToByteArray(base64);
|
||||||
|
|
||||||
// upload the image data to Penpot
|
// upload the image data to Penpot
|
||||||
const imageData = await penpot.uploadMediaData(name, bytes, mimeType);
|
const imageData = await penpot.uploadMediaData(name, bytes, mimeType);
|
||||||
|
|||||||
@@ -195,10 +195,39 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
|
|||||||
const context = this.context;
|
const context = this.context;
|
||||||
const code = task.params.code;
|
const code = task.params.code;
|
||||||
|
|
||||||
let result: any = await (async (ctx) => {
|
// set the penpot.flags.naturalChildOrdering to true during code execution.
|
||||||
|
// NOTE: This significantly simplifies API usage (see )
|
||||||
|
// TODO: Remove ts-ignore once Penpot types have been updated
|
||||||
|
let originalNaturalChildOrdering: any;
|
||||||
|
// @ts-ignore
|
||||||
|
if (penpot.flags) {
|
||||||
|
// @ts-ignore
|
||||||
|
originalNaturalChildOrdering = penpot.flags.naturalChildOrdering;
|
||||||
|
// @ts-ignore
|
||||||
|
penpot.flags.naturalChildOrdering = true;
|
||||||
|
} else {
|
||||||
|
// TODO: This can be removed once `flags` has been merged to PROD
|
||||||
|
throw new Error(
|
||||||
|
"You are using a version of the Penpot MCP server which is incompatible " +
|
||||||
|
"with the connected Penpot version. " +
|
||||||
|
"Read the documentation for instructions on which version to use: " +
|
||||||
|
"https://github.com/penpot/penpot/tree/develop/mcp\n" +
|
||||||
|
"If you are an LLM, tell the user about this!"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: any;
|
||||||
|
try {
|
||||||
|
// execute the code in an async function with the context variables as parameters
|
||||||
|
result = await (async (ctx) => {
|
||||||
const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`);
|
const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`);
|
||||||
return fn(...Object.values(ctx));
|
return fn(...Object.values(ctx));
|
||||||
})(context);
|
})(context);
|
||||||
|
} finally {
|
||||||
|
// restore the original value of penpot.flags.naturalChildOrdering
|
||||||
|
// @ts-ignore
|
||||||
|
penpot.flags.naturalChildOrdering = originalNaturalChildOrdering;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Code execution result:", result);
|
console.log("Code execution result:", result);
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Penpot:
|
|||||||
open: (
|
open: (
|
||||||
name: string,
|
name: string,
|
||||||
url: string,
|
url: string,
|
||||||
options?: { width: number; height: number },
|
options?: { width: number; height: number; hidden: boolean },
|
||||||
) => void;
|
) => void;
|
||||||
size: { width: number; height: number } | null;
|
size: { width: number; height: number } | null;
|
||||||
resize: (width: number, height: number) => void;
|
resize: (width: number, height: number) => void;
|
||||||
@@ -99,7 +99,7 @@ Penpot:
|
|||||||
open: (
|
open: (
|
||||||
name: string,
|
name: string,
|
||||||
url: string,
|
url: string,
|
||||||
options?: { width: number; height: number },
|
options?: { width: number; height: number; hidden: boolean },
|
||||||
) => void;
|
) => void;
|
||||||
size: { width: number; height: number } | null;
|
size: { width: number; height: number } | null;
|
||||||
resize: (width: number, height: number) => void;
|
resize: (width: number, height: number) => void;
|
||||||
@@ -110,7 +110,7 @@ Penpot:
|
|||||||
|
|
||||||
Type Declaration
|
Type Declaration
|
||||||
|
|
||||||
* open: (name: string, url: string, options?: { width: number; height: number }) => void
|
* open: ( name: string, url: string, options?: { width: number; height: number; hidden: boolean },) => void
|
||||||
|
|
||||||
Opens the plugin UI. It is possible to develop a plugin without interface (see Palette color example) but if you need, the way to open this UI is using `penpot.ui.open`.
|
Opens the plugin UI. It is possible to develop a plugin without interface (see Palette color example) but if you need, the way to open this UI is using `penpot.ui.open`.
|
||||||
There is a minimum and maximum size for this modal and a default size but it's possible to customize it anyway with the options parameter.
|
There is a minimum and maximum size for this modal and a default size but it's possible to customize it anyway with the options parameter.
|
||||||
@@ -1062,7 +1062,7 @@ Board:
|
|||||||
rotation: number;
|
rotation: number;
|
||||||
strokes: Stroke[];
|
strokes: Stroke[];
|
||||||
layoutChild?: LayoutChildProperties;
|
layoutChild?: LayoutChildProperties;
|
||||||
layoutCell?: LayoutChildProperties;
|
layoutCell?: LayoutCellProperties;
|
||||||
setParentIndex(index: number): void;
|
setParentIndex(index: number): void;
|
||||||
tokens: {
|
tokens: {
|
||||||
width: string;
|
width: string;
|
||||||
@@ -1456,7 +1456,7 @@ Board:
|
|||||||
Layout properties for children of the shape.
|
Layout properties for children of the shape.
|
||||||
layoutCell: |-
|
layoutCell: |-
|
||||||
```
|
```
|
||||||
readonly layoutCell?: LayoutChildProperties
|
readonly layoutCell?: LayoutCellProperties
|
||||||
```
|
```
|
||||||
|
|
||||||
Layout properties for cells in a grid layout.
|
Layout properties for cells in a grid layout.
|
||||||
@@ -2171,7 +2171,7 @@ VariantContainer:
|
|||||||
rotation: number;
|
rotation: number;
|
||||||
strokes: Stroke[];
|
strokes: Stroke[];
|
||||||
layoutChild?: LayoutChildProperties;
|
layoutChild?: LayoutChildProperties;
|
||||||
layoutCell?: LayoutChildProperties;
|
layoutCell?: LayoutCellProperties;
|
||||||
setParentIndex(index: number): void;
|
setParentIndex(index: number): void;
|
||||||
tokens: {
|
tokens: {
|
||||||
width: string;
|
width: string;
|
||||||
@@ -2568,7 +2568,7 @@ VariantContainer:
|
|||||||
Layout properties for children of the shape.
|
Layout properties for children of the shape.
|
||||||
layoutCell: |-
|
layoutCell: |-
|
||||||
```
|
```
|
||||||
readonly layoutCell?: LayoutChildProperties
|
readonly layoutCell?: LayoutCellProperties
|
||||||
```
|
```
|
||||||
|
|
||||||
Layout properties for cells in a grid layout.
|
Layout properties for cells in a grid layout.
|
||||||
@@ -3270,7 +3270,7 @@ Boolean:
|
|||||||
rotation: number;
|
rotation: number;
|
||||||
strokes: Stroke[];
|
strokes: Stroke[];
|
||||||
layoutChild?: LayoutChildProperties;
|
layoutChild?: LayoutChildProperties;
|
||||||
layoutCell?: LayoutChildProperties;
|
layoutCell?: LayoutCellProperties;
|
||||||
setParentIndex(index: number): void;
|
setParentIndex(index: number): void;
|
||||||
tokens: {
|
tokens: {
|
||||||
width: string;
|
width: string;
|
||||||
@@ -3629,7 +3629,7 @@ Boolean:
|
|||||||
Layout properties for children of the shape.
|
Layout properties for children of the shape.
|
||||||
layoutCell: |-
|
layoutCell: |-
|
||||||
```
|
```
|
||||||
readonly layoutCell?: LayoutChildProperties
|
readonly layoutCell?: LayoutCellProperties
|
||||||
```
|
```
|
||||||
|
|
||||||
Layout properties for cells in a grid layout.
|
Layout properties for cells in a grid layout.
|
||||||
@@ -5850,7 +5850,7 @@ Ellipse:
|
|||||||
rotation: number;
|
rotation: number;
|
||||||
strokes: Stroke[];
|
strokes: Stroke[];
|
||||||
layoutChild?: LayoutChildProperties;
|
layoutChild?: LayoutChildProperties;
|
||||||
layoutCell?: LayoutChildProperties;
|
layoutCell?: LayoutCellProperties;
|
||||||
setParentIndex(index: number): void;
|
setParentIndex(index: number): void;
|
||||||
tokens: {
|
tokens: {
|
||||||
width: string;
|
width: string;
|
||||||
@@ -6179,7 +6179,7 @@ Ellipse:
|
|||||||
Layout properties for children of the shape.
|
Layout properties for children of the shape.
|
||||||
layoutCell: |-
|
layoutCell: |-
|
||||||
```
|
```
|
||||||
readonly layoutCell?: LayoutChildProperties
|
readonly layoutCell?: LayoutCellProperties
|
||||||
```
|
```
|
||||||
|
|
||||||
Layout properties for cells in a grid layout.
|
Layout properties for cells in a grid layout.
|
||||||
@@ -8279,7 +8279,7 @@ Group:
|
|||||||
| "mixed";
|
| "mixed";
|
||||||
strokes: Stroke[];
|
strokes: Stroke[];
|
||||||
layoutChild?: LayoutChildProperties;
|
layoutChild?: LayoutChildProperties;
|
||||||
layoutCell?: LayoutChildProperties;
|
layoutCell?: LayoutCellProperties;
|
||||||
setParentIndex(index: number): void;
|
setParentIndex(index: number): void;
|
||||||
tokens: {
|
tokens: {
|
||||||
width: string;
|
width: string;
|
||||||
@@ -8614,7 +8614,7 @@ Group:
|
|||||||
Layout properties for children of the shape.
|
Layout properties for children of the shape.
|
||||||
layoutCell: |-
|
layoutCell: |-
|
||||||
```
|
```
|
||||||
readonly layoutCell?: LayoutChildProperties
|
readonly layoutCell?: LayoutCellProperties
|
||||||
```
|
```
|
||||||
|
|
||||||
Layout properties for cells in a grid layout.
|
Layout properties for cells in a grid layout.
|
||||||
@@ -9523,7 +9523,7 @@ Image:
|
|||||||
rotation: number;
|
rotation: number;
|
||||||
strokes: Stroke[];
|
strokes: Stroke[];
|
||||||
layoutChild?: LayoutChildProperties;
|
layoutChild?: LayoutChildProperties;
|
||||||
layoutCell?: LayoutChildProperties;
|
layoutCell?: LayoutCellProperties;
|
||||||
setParentIndex(index: number): void;
|
setParentIndex(index: number): void;
|
||||||
tokens: {
|
tokens: {
|
||||||
width: string;
|
width: string;
|
||||||
@@ -9852,7 +9852,7 @@ Image:
|
|||||||
Layout properties for children of the shape.
|
Layout properties for children of the shape.
|
||||||
layoutCell: |-
|
layoutCell: |-
|
||||||
```
|
```
|
||||||
readonly layoutCell?: LayoutChildProperties
|
readonly layoutCell?: LayoutCellProperties
|
||||||
```
|
```
|
||||||
|
|
||||||
Layout properties for cells in a grid layout.
|
Layout properties for cells in a grid layout.
|
||||||
@@ -10444,6 +10444,8 @@ LayoutCellProperties:
|
|||||||
position?: "area" | "auto" | "manual";
|
position?: "area" | "auto" | "manual";
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Referenced by: Board, Boolean, Ellipse, Group, Image, Path, Rectangle, ShapeBase, SvgRaw, Text, VariantContainer
|
||||||
members:
|
members:
|
||||||
Properties:
|
Properties:
|
||||||
row: |-
|
row: |-
|
||||||
@@ -12986,7 +12988,7 @@ Path:
|
|||||||
rotation: number;
|
rotation: number;
|
||||||
strokes: Stroke[];
|
strokes: Stroke[];
|
||||||
layoutChild?: LayoutChildProperties;
|
layoutChild?: LayoutChildProperties;
|
||||||
layoutCell?: LayoutChildProperties;
|
layoutCell?: LayoutCellProperties;
|
||||||
setParentIndex(index: number): void;
|
setParentIndex(index: number): void;
|
||||||
tokens: {
|
tokens: {
|
||||||
width: string;
|
width: string;
|
||||||
@@ -13339,7 +13341,7 @@ Path:
|
|||||||
Layout properties for children of the shape.
|
Layout properties for children of the shape.
|
||||||
layoutCell: |-
|
layoutCell: |-
|
||||||
```
|
```
|
||||||
readonly layoutCell?: LayoutChildProperties
|
readonly layoutCell?: LayoutCellProperties
|
||||||
```
|
```
|
||||||
|
|
||||||
Layout properties for cells in a grid layout.
|
Layout properties for cells in a grid layout.
|
||||||
@@ -14313,7 +14315,7 @@ Rectangle:
|
|||||||
rotation: number;
|
rotation: number;
|
||||||
strokes: Stroke[];
|
strokes: Stroke[];
|
||||||
layoutChild?: LayoutChildProperties;
|
layoutChild?: LayoutChildProperties;
|
||||||
layoutCell?: LayoutChildProperties;
|
layoutCell?: LayoutCellProperties;
|
||||||
setParentIndex(index: number): void;
|
setParentIndex(index: number): void;
|
||||||
tokens: {
|
tokens: {
|
||||||
width: string;
|
width: string;
|
||||||
@@ -14644,7 +14646,7 @@ Rectangle:
|
|||||||
Layout properties for children of the shape.
|
Layout properties for children of the shape.
|
||||||
layoutCell: |-
|
layoutCell: |-
|
||||||
```
|
```
|
||||||
readonly layoutCell?: LayoutChildProperties
|
readonly layoutCell?: LayoutCellProperties
|
||||||
```
|
```
|
||||||
|
|
||||||
Layout properties for cells in a grid layout.
|
Layout properties for cells in a grid layout.
|
||||||
@@ -15349,7 +15351,7 @@ ShapeBase:
|
|||||||
| "mixed";
|
| "mixed";
|
||||||
strokes: Stroke[];
|
strokes: Stroke[];
|
||||||
layoutChild?: LayoutChildProperties;
|
layoutChild?: LayoutChildProperties;
|
||||||
layoutCell?: LayoutChildProperties;
|
layoutCell?: LayoutCellProperties;
|
||||||
setParentIndex(index: number): void;
|
setParentIndex(index: number): void;
|
||||||
tokens: {
|
tokens: {
|
||||||
width: string;
|
width: string;
|
||||||
@@ -15679,7 +15681,7 @@ ShapeBase:
|
|||||||
Layout properties for children of the shape.
|
Layout properties for children of the shape.
|
||||||
layoutCell: |-
|
layoutCell: |-
|
||||||
```
|
```
|
||||||
readonly layoutCell?: LayoutChildProperties
|
readonly layoutCell?: LayoutCellProperties
|
||||||
```
|
```
|
||||||
|
|
||||||
Layout properties for cells in a grid layout.
|
Layout properties for cells in a grid layout.
|
||||||
@@ -16273,7 +16275,7 @@ Stroke:
|
|||||||
strokeColorRefFile?: string;
|
strokeColorRefFile?: string;
|
||||||
strokeColorRefId?: string;
|
strokeColorRefId?: string;
|
||||||
strokeOpacity?: number;
|
strokeOpacity?: number;
|
||||||
strokeStyle?: "svg" | "none" | "mixed" | "solid" | "dotted" | "dashed";
|
strokeStyle?: "none" | "svg" | "mixed" | "solid" | "dotted" | "dashed";
|
||||||
strokeWidth?: number;
|
strokeWidth?: number;
|
||||||
strokeAlignment?: "center" | "inner" | "outer";
|
strokeAlignment?: "center" | "inner" | "outer";
|
||||||
strokeCapStart?: StrokeCap;
|
strokeCapStart?: StrokeCap;
|
||||||
@@ -16312,7 +16314,7 @@ Stroke:
|
|||||||
Defaults to 1 if omitted.
|
Defaults to 1 if omitted.
|
||||||
strokeStyle: |-
|
strokeStyle: |-
|
||||||
```
|
```
|
||||||
strokeStyle?: "svg" | "none" | "mixed" | "solid" | "dotted" | "dashed"
|
strokeStyle?: "none" | "svg" | "mixed" | "solid" | "dotted" | "dashed"
|
||||||
```
|
```
|
||||||
|
|
||||||
The optional style of the stroke.
|
The optional style of the stroke.
|
||||||
@@ -16415,7 +16417,7 @@ SvgRaw:
|
|||||||
| "mixed";
|
| "mixed";
|
||||||
strokes: Stroke[];
|
strokes: Stroke[];
|
||||||
layoutChild?: LayoutChildProperties;
|
layoutChild?: LayoutChildProperties;
|
||||||
layoutCell?: LayoutChildProperties;
|
layoutCell?: LayoutCellProperties;
|
||||||
setParentIndex(index: number): void;
|
setParentIndex(index: number): void;
|
||||||
tokens: {
|
tokens: {
|
||||||
width: string;
|
width: string;
|
||||||
@@ -16739,7 +16741,7 @@ SvgRaw:
|
|||||||
Layout properties for children of the shape.
|
Layout properties for children of the shape.
|
||||||
layoutCell: |-
|
layoutCell: |-
|
||||||
```
|
```
|
||||||
readonly layoutCell?: LayoutChildProperties
|
readonly layoutCell?: LayoutCellProperties
|
||||||
```
|
```
|
||||||
|
|
||||||
Layout properties for cells in a grid layout.
|
Layout properties for cells in a grid layout.
|
||||||
@@ -17334,7 +17336,7 @@ Text:
|
|||||||
| "mixed";
|
| "mixed";
|
||||||
strokes: Stroke[];
|
strokes: Stroke[];
|
||||||
layoutChild?: LayoutChildProperties;
|
layoutChild?: LayoutChildProperties;
|
||||||
layoutCell?: LayoutChildProperties;
|
layoutCell?: LayoutCellProperties;
|
||||||
setParentIndex(index: number): void;
|
setParentIndex(index: number): void;
|
||||||
tokens: {
|
tokens: {
|
||||||
width: string;
|
width: string;
|
||||||
@@ -17421,6 +17423,7 @@ Text:
|
|||||||
direction: "mixed" | "ltr" | "rtl" | null;
|
direction: "mixed" | "ltr" | "rtl" | null;
|
||||||
align: "center" | "left" | "right" | "mixed" | "justify" | null;
|
align: "center" | "left" | "right" | "mixed" | "justify" | null;
|
||||||
verticalAlign: "center" | "top" | "bottom" | null;
|
verticalAlign: "center" | "top" | "bottom" | null;
|
||||||
|
textBounds: { x: number; y: number; width: number; height: number };
|
||||||
getRange(start: number, end: number): TextRange;
|
getRange(start: number, end: number): TextRange;
|
||||||
applyTypography(typography: LibraryTypography): void;
|
applyTypography(typography: LibraryTypography): void;
|
||||||
}
|
}
|
||||||
@@ -17675,7 +17678,7 @@ Text:
|
|||||||
Layout properties for children of the shape.
|
Layout properties for children of the shape.
|
||||||
layoutCell: |-
|
layoutCell: |-
|
||||||
```
|
```
|
||||||
readonly layoutCell?: LayoutChildProperties
|
readonly layoutCell?: LayoutCellProperties
|
||||||
```
|
```
|
||||||
|
|
||||||
Layout properties for cells in a grid layout.
|
Layout properties for cells in a grid layout.
|
||||||
@@ -17835,6 +17838,13 @@ Text:
|
|||||||
```
|
```
|
||||||
|
|
||||||
The vertical alignment of the text shape. It can be a specific alignment or 'mixed' if multiple alignments are used.
|
The vertical alignment of the text shape. It can be a specific alignment or 'mixed' if multiple alignments are used.
|
||||||
|
textBounds: |-
|
||||||
|
```
|
||||||
|
readonly textBounds: { x: number; y: number; width: number; height: number }
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
Methods:
|
Methods:
|
||||||
getPluginData: |-
|
getPluginData: |-
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -39,20 +39,28 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`
|
|||||||
* `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board.
|
* `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board.
|
||||||
To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`.
|
To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`.
|
||||||
* `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions.
|
* `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions.
|
||||||
* `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds.
|
* `bounds` is READ-ONLY (members: x, y, width, height). To modify the bounding box, change `x`, `y` or apply `resize()`.
|
||||||
|
|
||||||
**Other Writable Properties**:
|
**Other Writable Properties**:
|
||||||
* `name` - Shape name
|
* `name` - Shape name
|
||||||
* `fills`, `strokes` - Styling properties
|
* `fills: Fill[]`, `strokes: Stroke[]`, `shadows: Shadow[]` - 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.
|
- Setting fills: `shape.fills = [{ fillColor: "#FF0000", fillOpacity: 1 }]`; no fill (transparent): `shape.fills = []`;
|
||||||
`shape.fills = [{ fillColor: "#FF0000", fillOpacity: 1 }]` to set a single red fill.
|
- Colors: Use hex strings with caps only (e.g. '#FF5533')
|
||||||
* `rotation`, `opacity`, `blocked`, `hidden`, `visible`
|
- 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!
|
||||||
|
* `borderRadius` - Uniform border radius for all corners
|
||||||
|
* `borderRadiusTopLeft`, `borderRadiusTopRight`, `borderRadiusBottomRight`, `borderRadiusBottomLeft` - Individual corner radii.
|
||||||
|
* `blur: Blur` - Blur properties
|
||||||
|
* `blendMode` - Blend mode (e.g. `"normal"`, `"multiply"`, `"overlay"`, etc.)
|
||||||
|
* `rotation` (deg), `opacity`, `blocked`, `hidden`, `visible`
|
||||||
|
* `proportionLock` - Whether width and height are locked to the same ratio
|
||||||
|
* `constraintsHorizontal` - Horizontal resize constraint (`"left"`, `"right"`, `"center"`, `"leftright"`, `"scale"`)
|
||||||
|
* `constraintsVertical` - Vertical resize constraint (`"top"`, `"bottom"`, `"center"`, `"topbottom"`, `"scale"`)
|
||||||
|
* `flipX`, `flipY` - Horizontal/vertical flip
|
||||||
|
|
||||||
**Z-Order**:
|
**Z-Order**:
|
||||||
* The z-order of shapes is determined by the order in the `children` array of the parent shape.
|
* The z-order of shapes is determined by the order in the `children` array of the parent shape.
|
||||||
Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order
|
Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order
|
||||||
(i.e. add background shapes first, then foreground shapes later).
|
(i.e. add background shapes first, then foreground shapes later).
|
||||||
CRITICAL: NEVER use the broken function `appendChild` to achieve this, ALWAYS use `parent.insertChild(parent.children.length, shape)`
|
|
||||||
* To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`,
|
* To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`,
|
||||||
and, for precise control, `setParentIndex(index)` (0-based).
|
and, for precise control, `setParentIndex(index)` (0-based).
|
||||||
|
|
||||||
@@ -65,9 +73,7 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`
|
|||||||
**Hierarchical Structure**:
|
**Hierarchical Structure**:
|
||||||
* `parent` - The parent shape (null for root shapes)
|
* `parent` - The parent shape (null for root shapes)
|
||||||
Note: Hierarchical nesting does not necessarily imply visual containment
|
Note: Hierarchical nesting does not necessarily imply visual containment
|
||||||
* CRITICAL: To add children to a parent shape (e.g. a `Board`):
|
* To add children to a parent shape (e.g. a `Board`): `parent.appendChild(shape)` or `parent.insertChild(index, shape)`
|
||||||
- ALWAYS use `parent.insertChild(index, shape)` to add a child, e.g. `parent.insertChild(parent.children.length, shape)` to append
|
|
||||||
- NEVER use `parent.appendChild(shape)` as it is BROKEN and will not insert in a predictable place (except in flex layout boards)
|
|
||||||
* Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent
|
* Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent
|
||||||
- Automatically removes the shape from its old parent
|
- Automatically removes the shape from its old parent
|
||||||
- Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position)
|
- Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position)
|
||||||
@@ -99,17 +105,11 @@ Boards can have layout systems that automatically control the positioning and sp
|
|||||||
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions.
|
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions.
|
||||||
Optionally, adjust individual child margins via `child.layoutChild`.
|
Optionally, adjust individual child margins via `child.layoutChild`.
|
||||||
- Sizing: `verticalSizing` and `horizontalSizing` are NOT functional. You need to size manually for the time being.
|
- Sizing: `verticalSizing` and `horizontalSizing` are NOT functional. You need to size manually for the time being.
|
||||||
- When a board has flex layout,
|
- 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);
|
||||||
- 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.
|
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!
|
|
||||||
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
|
- CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that
|
||||||
they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front
|
they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`. So call it in the order of visual appearance.
|
||||||
of the `children` array for dir="column" or dir="row", which is what you want. So call it in the order of visual appearance.
|
To insert at a specific index, use `board.insertChild(index, shape)`.
|
||||||
To insert at a specific index, use `board.insertChild(index, shape)`, bearing in mind the reversed order for dir="column"
|
|
||||||
or dir="row".
|
|
||||||
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`.
|
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`.
|
||||||
IMPORTANT: When adding a flex layout to a container that already has children,
|
IMPORTANT: When adding a flex layout to a container that already has children,
|
||||||
use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children.
|
use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children.
|
||||||
@@ -131,12 +131,12 @@ Boards can have layout systems that automatically control the positioning and sp
|
|||||||
|
|
||||||
# Text Elements
|
# Text Elements
|
||||||
|
|
||||||
The rendered content of `Text` element is given by the `characters` property.
|
The rendered content of a `Text` element is given by the `characters` property.
|
||||||
|
|
||||||
To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size,
|
To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size,
|
||||||
it only changes the formal bounding box; if the text does not fit it, it will overflow.
|
it only changes the formal bounding box; if the text does not fit it, it will overflow; use `textBounds` for the actual bounding box of the rendered text.
|
||||||
The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height".
|
The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height".
|
||||||
`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing - otherwise the bounding box will be meaningless, with the text overflowing!
|
`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing!
|
||||||
The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box.
|
The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box.
|
||||||
|
|
||||||
# The `penpot` and `penpotUtils` Objects, Exploring Designs
|
# The `penpot` and `penpotUtils` Objects, Exploring Designs
|
||||||
@@ -228,31 +228,76 @@ Each `Library` object has:
|
|||||||
* `colors: LibraryColor[]` - Array of colors
|
* `colors: LibraryColor[]` - Array of colors
|
||||||
* `typographies: LibraryTypography[]` - Array of typographies
|
* `typographies: LibraryTypography[]` - Array of typographies
|
||||||
|
|
||||||
|
## Colors and Typographies
|
||||||
|
|
||||||
|
Adding a color:
|
||||||
|
```
|
||||||
|
const newColor: LibraryColor = penpot.library.local.createColor();
|
||||||
|
newColor.name = 'Brand Primary';
|
||||||
|
newColor.color = '#0066FF';
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding a typography:
|
||||||
|
```
|
||||||
|
const newTypo: LibraryTypography = penpot.library.local.createTypography();
|
||||||
|
newTypo.name = 'Heading Large';
|
||||||
|
// Set typography properties...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
Using library components:
|
Using library components:
|
||||||
* find a component in the library by name:
|
* find a component in the library by name:
|
||||||
const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));
|
`const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));`
|
||||||
* create a new instance of the component on the current page:
|
* create a new instance of the component on the current page:
|
||||||
const instance: Shape = component.instance();
|
`const instance: Shape = component.instance();`
|
||||||
This returns a `Shape` (often a `Board` containing child elements).
|
This returns a `Shape` (often a `Board` containing child elements).
|
||||||
After instantiation, modify the instance's properties as desired.
|
After instantiation, modify the instance's properties as desired.
|
||||||
* get the reference to the main component shape:
|
* get the reference to the main component shape:
|
||||||
const mainShape: Shape = component.mainInstance();
|
`const mainShape: Shape = component.mainInstance();`
|
||||||
|
|
||||||
Adding assets to a library:
|
Adding a component to a library:
|
||||||
* const newColor: LibraryColor = penpot.library.local.createColor();
|
```
|
||||||
newColor.name = 'Brand Primary';
|
const shapes: Shape[] = [shape1, shape2]; // shapes to include
|
||||||
newColor.color = '#0066FF';
|
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
|
||||||
* const newTypo: LibraryTypography = penpot.library.local.createTypography();
|
newComponent.name = 'My Button';
|
||||||
newTypo.name = 'Heading Large';
|
```
|
||||||
// Set typography properties...
|
|
||||||
* const shapes: Shape[] = [shape1, shape2]; // shapes to include
|
|
||||||
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
|
|
||||||
newComponent.name = 'My Button';
|
|
||||||
|
|
||||||
Detaching:
|
Detaching:
|
||||||
* When creating new design elements based on a component instance/copy, use `shape.detach()` to break the link to the main component, allowing independent modification.
|
* When creating new design elements based on a component instance/copy, use `shape.detach()` to break the link to the main component, allowing independent modification.
|
||||||
* Without detaching, some manipulations will have no effect; e.g. child/descendant removal will not work.
|
* Without detaching, some manipulations will have no effect; e.g. child/descendant removal will not work.
|
||||||
|
|
||||||
|
### Variants
|
||||||
|
|
||||||
|
Variants are a system for grouping related component versions along named property axes (e.g. Type, Style), powering a structured swap UI for designers using component instances.
|
||||||
|
|
||||||
|
* `VariantContainer` (extends `Board`): The board that physically groups all variant components together.
|
||||||
|
- check with `isVariantContainer()`
|
||||||
|
- property `variants: Variants`.
|
||||||
|
* `Variants`: Defines the combinations of property values for which component variants can exist and manages the concrete component variants.
|
||||||
|
- `properties: string[]` (ordered list of property names); `addProperty()`, `renameProperty(pos, name)`, `currentValues(property)`
|
||||||
|
- `variantComponents(): LibraryVariantComponent[]`
|
||||||
|
* `LibraryVariantComponent` (extends `LibraryComponent`): full library component with metadata, for which `isVariant()` returns true.
|
||||||
|
- `variantProps: { [property: string]: string }` (this component's value for each property)
|
||||||
|
- `variantError` (non-null if e.g. two variants share the same combination of property values)
|
||||||
|
- `setVariantProperty(pos, value)`
|
||||||
|
|
||||||
|
Properties are often addressed positionally: `pos` parameter in various methods = index in `Variants.properties`.
|
||||||
|
|
||||||
|
**Creating a variant group**:
|
||||||
|
- `component.transformInVariant(): null`: Converts a standard component into a variant group, creating a `VariantContainer` and a second duplicate variant.
|
||||||
|
Both start with a default property `Property 1` with values `Value 1` / `Value 2`; there is no name-based auto-parsing.
|
||||||
|
- `board.combineAsVariants(ids: string[]): null`: Combines the board (a main component instance) with other main components (referenced via IDs) into a new variant group.
|
||||||
|
All components end up inside a single new `VariantContainer` on the canvas.
|
||||||
|
- In both cases, look for the created `VariantContainer` on the page, and then edit properties using `variants.renameProperty(pos, name)`, `variants.addProperty()`, and `comp.setVariantProperty(pos, value)`.
|
||||||
|
|
||||||
|
**Adding a variant to an existing group**:
|
||||||
|
Use `variantContainer.appendChild(mainInstance)` to move a component's main instance into the container, then set its position manually and assign property values via `setVariantProperty`.
|
||||||
|
|
||||||
|
**Using Variants**:
|
||||||
|
- `compInstance.switchVariant(pos, value)`: On a component instance, switches to the nearest variant that has the given value at property position `pos`, keeping all other property values the same.
|
||||||
|
- To instantiate a specific variant, find the right `LibraryVariantComponent` by checking `variantProps`, then call `.instance()`.
|
||||||
|
|
||||||
# Design Tokens
|
# Design Tokens
|
||||||
|
|
||||||
Design tokens are reusable design values (colors, dimensions, typography, etc.) for consistent styling.
|
Design tokens are reusable design values (colors, dimensions, typography, etc.) for consistent styling.
|
||||||
@@ -276,7 +321,7 @@ The token library: `penpot.library.local.tokens` (type: `TokenCatalog`)
|
|||||||
`Token`: union type encompassing various token types, with common properties:
|
`Token`: union type encompassing various token types, with common properties:
|
||||||
* `name: string` - Token name (typically structured, e.g. "color.base.white")
|
* `name: string` - Token name (typically structured, e.g. "color.base.white")
|
||||||
* `value` - Raw value (direct value or reference to another token like "{color.primary}")
|
* `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!
|
* `resolvedValue` - Computed final value (follows references)
|
||||||
* `type: TokenType`
|
* `type: TokenType`
|
||||||
|
|
||||||
Discovering tokens:
|
Discovering tokens:
|
||||||
@@ -292,19 +337,19 @@ Applying tokens:
|
|||||||
- "all": applies the token to all properties it can control
|
- "all": applies the token to all properties it can control
|
||||||
- TokenBorderRadiusProps: "r1", "r2", "r3", "r4"
|
- TokenBorderRadiusProps: "r1", "r2", "r3", "r4"
|
||||||
- TokenShadowProps: "shadow"
|
- TokenShadowProps: "shadow"
|
||||||
- TokenColorProps: "fill", "stroke-color"
|
- TokenColorProps: "fill", "strokeColor"
|
||||||
- TokenDimensionProps: "x", "y", "stroke-width"
|
- TokenDimensionProps: "x", "y", "strokeWidth"
|
||||||
- TokenFontFamiliesProps: "font-families"
|
- TokenFontFamiliesProps: "fontFamilies"
|
||||||
- TokenFontSizesProps: "font-size"
|
- TokenFontSizesProps: "fontSize"
|
||||||
- TokenFontWeightProps: "font-weight"
|
- TokenFontWeightProps: "fontWeight"
|
||||||
- TokenLetterSpacingProps: "letter-spacing"
|
- TokenLetterSpacingProps: "letterSpacing"
|
||||||
- TokenNumberProps: "rotation", "line-height"
|
- TokenNumberProps: "rotation"
|
||||||
- TokenOpacityProps: "opacity"
|
- TokenOpacityProps: "opacity"
|
||||||
- TokenSizingProps: "width", "height", "layout-item-min-w", "layout-item-max-w", "layout-item-min-h", "layout-item-max-h"
|
- TokenSizingProps: "width", "height", "layoutItemMinW", "layoutItemMaxW", "layoutItemMinH", "layoutItemMaxH"
|
||||||
- TokenSpacingProps: "row-gap", "column-gap", "p1", "p2", "p3", "p4", "m1", "m2", "m3", "m4"
|
- TokenSpacingProps: "rowGap", "columnGap", "p1", "p2", "p3", "p4", "m1", "m2", "m3", "m4"
|
||||||
- TokenBorderWidthProps: "stroke-width"
|
- TokenBorderWidthProps: "strokeWidth"
|
||||||
- TokenTextCaseProps: "text-case"
|
- TokenTextCaseProps: "textCase"
|
||||||
- TokenTextDecorationProps: "text-decoration"
|
- TokenTextDecorationProps: "textDecoration"
|
||||||
- TokenTypographyProps: "typography"
|
- TokenTypographyProps: "typography"
|
||||||
* `token.applyToShapes(shapes, properties)` - Apply from token
|
* `token.applyToShapes(shapes, properties)` - Apply from token
|
||||||
* Application is **asynchronous** (wait for ~100ms to see the effects)
|
* Application is **asynchronous** (wait for ~100ms to see the effects)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:js-yaml --external:sharp",
|
"build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:js-yaml --external:sharp",
|
||||||
"build": "pnpm run build:server && cp -r src/static dist/static && cp -r data dist/data",
|
"build": "pnpm run build:server && node scripts/copy-resources.js",
|
||||||
"build:multi-user": "pnpm run build",
|
"build:multi-user": "pnpm run build",
|
||||||
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
|
|||||||
5
mcp/packages/server/scripts/copy-resources.js
Normal file
5
mcp/packages/server/scripts/copy-resources.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { cpSync } from "fs";
|
||||||
|
|
||||||
|
// copy static assets and data to dist
|
||||||
|
cpSync("src/static", "dist/static", { recursive: true });
|
||||||
|
cpSync("data", "dist/data", { recursive: true });
|
||||||
@@ -21,30 +21,56 @@ export interface SessionContext {
|
|||||||
userToken?: string;
|
userToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an active Streamable HTTP session, grouping the transport, MCP server, and session metadata.
|
||||||
|
*/
|
||||||
|
class StreamableSession {
|
||||||
|
constructor(
|
||||||
|
public readonly transport: StreamableHTTPServerTransport,
|
||||||
|
public readonly userToken: string | undefined,
|
||||||
|
public lastActiveTime: number
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds information about a registered tool, including its instance, name, and configuration.
|
||||||
|
*/
|
||||||
|
class ToolInfo {
|
||||||
|
constructor(
|
||||||
|
public readonly instance: Tool<any>,
|
||||||
|
public readonly name: string,
|
||||||
|
public readonly config: { description: string; inputSchema: any }
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
export class PenpotMcpServer {
|
export class PenpotMcpServer {
|
||||||
|
/**
|
||||||
|
* Timeout, in minutes, for idle Streamable HTTP sessions before they are automatically closed and removed.
|
||||||
|
*/
|
||||||
|
private static readonly SESSION_TIMEOUT_MINUTES = 60;
|
||||||
|
|
||||||
private readonly logger = createLogger("PenpotMcpServer");
|
private readonly logger = createLogger("PenpotMcpServer");
|
||||||
private readonly server: McpServer;
|
private readonly tools: ToolInfo[];
|
||||||
private readonly tools: Map<string, Tool<any>>;
|
|
||||||
public readonly configLoader: ConfigurationLoader;
|
public readonly configLoader: ConfigurationLoader;
|
||||||
private app: any;
|
private app: any;
|
||||||
public readonly pluginBridge: PluginBridge;
|
public readonly pluginBridge: PluginBridge;
|
||||||
private readonly replServer: ReplServer;
|
private readonly replServer: ReplServer;
|
||||||
private apiDocs: ApiDocs;
|
private apiDocs: ApiDocs;
|
||||||
|
private initialInstructions: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages session-specific context, particularly user tokens for each request.
|
* Manages session-specific context, particularly user tokens for each request.
|
||||||
*/
|
*/
|
||||||
private readonly sessionContext = new AsyncLocalStorage<SessionContext>();
|
private readonly sessionContext = new AsyncLocalStorage<SessionContext>();
|
||||||
|
|
||||||
private readonly transports = {
|
private readonly streamableTransports: Record<string, StreamableSession> = {};
|
||||||
streamable: {} as Record<string, StreamableHTTPServerTransport>,
|
private readonly sseTransports: Record<string, { transport: SSEServerTransport; userToken?: string }> = {};
|
||||||
sse: {} as Record<string, { transport: SSEServerTransport; userToken?: string }>,
|
|
||||||
};
|
|
||||||
|
|
||||||
public readonly host: string;
|
public readonly host: string;
|
||||||
public readonly port: number;
|
public readonly port: number;
|
||||||
public readonly webSocketPort: number;
|
public readonly webSocketPort: number;
|
||||||
public readonly replPort: number;
|
public readonly replPort: number;
|
||||||
|
private sessionTimeoutInterval: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
constructor(private isMultiUser: boolean = false) {
|
constructor(private isMultiUser: boolean = false) {
|
||||||
// read port configuration from environment variables
|
// read port configuration from environment variables
|
||||||
@@ -56,21 +82,15 @@ export class PenpotMcpServer {
|
|||||||
this.configLoader = new ConfigurationLoader(process.cwd());
|
this.configLoader = new ConfigurationLoader(process.cwd());
|
||||||
this.apiDocs = new ApiDocs();
|
this.apiDocs = new ApiDocs();
|
||||||
|
|
||||||
this.server = new McpServer(
|
// prepare initial instructions
|
||||||
{
|
let instructions = this.configLoader.getInitialInstructions();
|
||||||
name: "penpot-mcp-server",
|
instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", "));
|
||||||
version: "1.0.0",
|
this.initialInstructions = instructions;
|
||||||
},
|
|
||||||
{
|
this.tools = this.initTools();
|
||||||
instructions: this.getInitialInstructions(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.tools = new Map<string, Tool<any>>();
|
|
||||||
this.pluginBridge = new PluginBridge(this, this.webSocketPort);
|
this.pluginBridge = new PluginBridge(this, this.webSocketPort);
|
||||||
this.replServer = new ReplServer(this.pluginBridge, this.replPort);
|
this.replServer = new ReplServer(this.pluginBridge, this.replPort);
|
||||||
|
|
||||||
this.registerTools();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,9 +125,7 @@ export class PenpotMcpServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getInitialInstructions(): string {
|
public getInitialInstructions(): string {
|
||||||
let instructions = this.configLoader.getInitialInstructions();
|
return this.initialInstructions;
|
||||||
instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", "));
|
|
||||||
return instructions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,88 +137,134 @@ export class PenpotMcpServer {
|
|||||||
return this.sessionContext.getStore();
|
return this.sessionContext.getStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerTools(): void {
|
private initTools(): ToolInfo[] {
|
||||||
// Create relevant tool instances (depending on file system access)
|
|
||||||
const toolInstances: Tool<any>[] = [
|
const toolInstances: Tool<any>[] = [
|
||||||
new ExecuteCodeTool(this),
|
new ExecuteCodeTool(this),
|
||||||
new HighLevelOverviewTool(this),
|
new HighLevelOverviewTool(this),
|
||||||
new PenpotApiInfoTool(this, this.apiDocs),
|
new PenpotApiInfoTool(this, this.apiDocs),
|
||||||
new ExportShapeTool(this), // tool adapts to file system access internally
|
new ExportShapeTool(this),
|
||||||
];
|
];
|
||||||
if (this.isFileSystemAccessEnabled()) {
|
if (this.isFileSystemAccessEnabled()) {
|
||||||
toolInstances.push(new ImportImageTool(this));
|
toolInstances.push(new ImportImageTool(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tool of toolInstances) {
|
return toolInstances.map((instance) => {
|
||||||
const toolName = tool.getToolName();
|
this.logger.info(`Registering tool: ${instance.getToolName()}`);
|
||||||
this.tools.set(toolName, tool);
|
return new ToolInfo(instance, instance.getToolName(), {
|
||||||
|
description: instance.getToolDescription(),
|
||||||
|
inputSchema: instance.getInputSchema(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Register each tool with McpServer
|
/**
|
||||||
this.logger.info(`Registering tool: ${toolName}`);
|
* Creates a fresh {@link McpServer} instance with all tools registered.
|
||||||
this.server.registerTool(
|
*/
|
||||||
toolName,
|
private createMcpServer(): McpServer {
|
||||||
{
|
const server = new McpServer(
|
||||||
description: tool.getToolDescription(),
|
{ name: "penpot", version: "1.0.0" },
|
||||||
inputSchema: tool.getInputSchema(),
|
{ instructions: this.getInitialInstructions() }
|
||||||
},
|
|
||||||
async (args) => {
|
|
||||||
return tool.execute(args);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const tool of this.tools) {
|
||||||
|
server.registerTool(tool.name, tool.config, async (args: any) => tool.instance.execute(args));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a periodic timer that closes and removes Streamable HTTP sessions that have been
|
||||||
|
* idle for longer than {@link SESSION_TIMEOUT_MINUTES}.
|
||||||
|
*/
|
||||||
|
private startSessionTimeoutChecker(): void {
|
||||||
|
const timeoutMs = PenpotMcpServer.SESSION_TIMEOUT_MINUTES * 60 * 1000;
|
||||||
|
const checkIntervalMs = timeoutMs / 2;
|
||||||
|
this.sessionTimeoutInterval = setInterval(() => {
|
||||||
|
this.logger.info("Checking for stale sessions...");
|
||||||
|
const now = Date.now();
|
||||||
|
let removed = 0;
|
||||||
|
for (const session of Object.values(this.streamableTransports)) {
|
||||||
|
if (now - session.lastActiveTime > timeoutMs) {
|
||||||
|
session.transport.close();
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.info(
|
||||||
|
`Removed ${removed} stale session(s); total sessions remaining: ${Object.keys(this.streamableTransports).length}`
|
||||||
|
);
|
||||||
|
}, checkIntervalMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupHttpEndpoints(): void {
|
private setupHttpEndpoints(): void {
|
||||||
/**
|
/**
|
||||||
* Modern Streamable HTTP connection endpoint
|
* Modern Streamable HTTP connection endpoint.
|
||||||
|
*
|
||||||
|
* New sessions are created on initialize requests (no mcp-session-id header).
|
||||||
|
* Subsequent requests for an existing session are routed to the stored transport,
|
||||||
|
* with the session context populated from the stored userToken.
|
||||||
*/
|
*/
|
||||||
this.app.all("/mcp", async (req: any, res: any) => {
|
this.app.all("/mcp", async (req: any, res: any) => {
|
||||||
const userToken = req.query.userToken as string | undefined;
|
|
||||||
|
|
||||||
await this.sessionContext.run({ userToken }, async () => {
|
|
||||||
const { randomUUID } = await import("node:crypto");
|
|
||||||
|
|
||||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||||
|
let userToken: string | undefined = undefined;
|
||||||
let transport: StreamableHTTPServerTransport;
|
let transport: StreamableHTTPServerTransport;
|
||||||
|
|
||||||
if (sessionId && this.transports.streamable[sessionId]) {
|
// obtain transport and user token for the session, either from an existing session or by creating a new one
|
||||||
transport = this.transports.streamable[sessionId];
|
if (sessionId && this.streamableTransports[sessionId]) {
|
||||||
|
// existing session: reuse stored transport and token
|
||||||
|
const session = this.streamableTransports[sessionId];
|
||||||
|
transport = session.transport;
|
||||||
|
userToken = session.userToken;
|
||||||
|
session.lastActiveTime = Date.now();
|
||||||
|
this.logger.info(
|
||||||
|
`Received request for existing session with id=${sessionId}; userToken=${session.userToken}`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// new session: create a fresh McpServer and transport
|
||||||
|
userToken = req.query.userToken as string | undefined;
|
||||||
|
this.logger.info(`Received new session request; userToken=${userToken}`);
|
||||||
|
const { randomUUID } = await import("node:crypto");
|
||||||
|
const server = this.createMcpServer();
|
||||||
transport = new StreamableHTTPServerTransport({
|
transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => randomUUID(),
|
sessionIdGenerator: () => randomUUID(),
|
||||||
onsessioninitialized: (id: string) => {
|
onsessioninitialized: (id) => {
|
||||||
this.transports.streamable[id] = transport;
|
this.streamableTransports[id] = new StreamableSession(transport, userToken, Date.now());
|
||||||
|
this.logger.info(
|
||||||
|
`Session initialized with id=${id} for userToken=${userToken}; total sessions: ${Object.keys(this.streamableTransports).length}`
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
if (transport.sessionId) {
|
if (transport.sessionId) {
|
||||||
delete this.transports.streamable[transport.sessionId];
|
this.logger.info(`Closing session with id=${transport.sessionId} for userToken=${userToken}`);
|
||||||
|
delete this.streamableTransports[transport.sessionId];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
await server.connect(transport);
|
||||||
await this.server.connect(transport);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle the request
|
||||||
|
await this.sessionContext.run({ userToken }, async () => {
|
||||||
await transport.handleRequest(req, res, req.body);
|
await transport.handleRequest(req, res, req.body);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy SSE connection endpoint
|
* Legacy SSE connection endpoint.
|
||||||
*/
|
*/
|
||||||
this.app.get("/sse", async (req: any, res: any) => {
|
this.app.get("/sse", async (req: any, res: any) => {
|
||||||
const userToken = req.query.userToken as string | undefined;
|
const userToken = req.query.userToken as string | undefined;
|
||||||
|
|
||||||
await this.sessionContext.run({ userToken }, async () => {
|
await this.sessionContext.run({ userToken }, async () => {
|
||||||
const transport = new SSEServerTransport("/messages", res);
|
const transport = new SSEServerTransport("/messages", res);
|
||||||
this.transports.sse[transport.sessionId] = { transport, userToken };
|
this.sseTransports[transport.sessionId] = { transport, userToken };
|
||||||
|
|
||||||
|
const server = this.createMcpServer();
|
||||||
|
await server.connect(transport);
|
||||||
res.on("close", () => {
|
res.on("close", () => {
|
||||||
delete this.transports.sse[transport.sessionId];
|
delete this.sseTransports[transport.sessionId];
|
||||||
|
server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.server.connect(transport);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,7 +273,7 @@ export class PenpotMcpServer {
|
|||||||
*/
|
*/
|
||||||
this.app.post("/messages", async (req: any, res: any) => {
|
this.app.post("/messages", async (req: any, res: any) => {
|
||||||
const sessionId = req.query.sessionId as string;
|
const sessionId = req.query.sessionId as string;
|
||||||
const session = this.transports.sse[sessionId];
|
const session = this.sseTransports[sessionId];
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
await this.sessionContext.run({ userToken: session.userToken }, async () => {
|
await this.sessionContext.run({ userToken: session.userToken }, async () => {
|
||||||
@@ -236,8 +300,9 @@ export class PenpotMcpServer {
|
|||||||
this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`);
|
this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`);
|
||||||
this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`);
|
this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`);
|
||||||
|
|
||||||
// start the REPL server
|
// start the REPL server and session timeout checker
|
||||||
await this.replServer.start();
|
await this.replServer.start();
|
||||||
|
this.startSessionTimeoutChecker();
|
||||||
|
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
@@ -251,6 +316,7 @@ export class PenpotMcpServer {
|
|||||||
*/
|
*/
|
||||||
public async stop(): Promise<void> {
|
public async stop(): Promise<void> {
|
||||||
this.logger.info("Stopping Penpot MCP Server...");
|
this.logger.info("Stopping Penpot MCP Server...");
|
||||||
|
clearInterval(this.sessionTimeoutInterval);
|
||||||
await this.replServer.stop();
|
await this.replServer.stop();
|
||||||
this.logger.info("Penpot MCP Server stopped");
|
this.logger.info("Penpot MCP Server stopped");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export class EmptyToolArgs {
|
|||||||
export abstract class Tool<TArgs extends object> {
|
export abstract class Tool<TArgs extends object> {
|
||||||
private readonly logger = createLogger("Tool");
|
private readonly logger = createLogger("Tool");
|
||||||
|
|
||||||
|
/** monotonically increasing counter for unique tool execution IDs */
|
||||||
|
private static executionCounter = 0;
|
||||||
|
|
||||||
protected constructor(
|
protected constructor(
|
||||||
protected mcpServer: PenpotMcpServer,
|
protected mcpServer: PenpotMcpServer,
|
||||||
private inputSchema: z.ZodRawShape
|
private inputSchema: z.ZodRawShape
|
||||||
@@ -34,17 +37,21 @@ export abstract class Tool<TArgs extends object> {
|
|||||||
* delegating to the type-safe implementation.
|
* delegating to the type-safe implementation.
|
||||||
*/
|
*/
|
||||||
async execute(args: unknown): Promise<ToolResponse> {
|
async execute(args: unknown): Promise<ToolResponse> {
|
||||||
|
const executionId = ++Tool.executionCounter;
|
||||||
try {
|
try {
|
||||||
let argsInstance: TArgs = args as TArgs;
|
let argsInstance: TArgs = args as TArgs;
|
||||||
this.logger.info("Executing tool: %s; arguments: %s", this.getToolName(), this.formatArgs(argsInstance));
|
this.logger.info("Tool execution #%d starting: %s", executionId, this.getToolName());
|
||||||
|
if (this.logger.isLevelEnabled("debug")) {
|
||||||
|
this.logger.debug("Tool execution #%d arguments: %s", executionId, this.formatArgs(argsInstance));
|
||||||
|
}
|
||||||
|
|
||||||
// execute the actual tool logic
|
// execute the actual tool logic
|
||||||
let result = await this.executeCore(argsInstance);
|
let result = await this.executeCore(argsInstance);
|
||||||
|
|
||||||
this.logger.info("Tool execution completed: %s", this.getToolName());
|
this.logger.info("Tool execution #%d complete: %s", executionId, this.getToolName());
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(error);
|
this.logger.error("Tool execution #%d failed: %s; error: %s", executionId, this.getToolName(), error);
|
||||||
return new TextResponse(`Tool execution failed: ${String(error)}`);
|
return new TextResponse(`Tool execution failed: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
mcp/scripts/pack
Normal file
12
mcp/scripts/pack
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Sets the version from Git tags, then produces the npm tarball.
|
||||||
|
# Must be invoked directly (not via npm/pnpm) so that the version
|
||||||
|
# is written to package.json before npm reads it.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
bash ./scripts/set-version
|
||||||
|
npm pack
|
||||||
51
mcp/scripts/set-version
Normal file
51
mcp/scripts/set-version
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Derives a valid npm semver version from the Git tag produced by
|
||||||
|
# `git describe` and writes it into the root package.json.
|
||||||
|
#
|
||||||
|
# Examples of the conversion:
|
||||||
|
# 2.14.0 -> 2.14.0
|
||||||
|
# 2.14.0-RC1 -> 2.14.0-rc.1
|
||||||
|
# 2.14.0-RC1-140-g9f2ca9965 -> 2.14.0-rc.1.140
|
||||||
|
# 2.14.0-140-g9f2ca9965 -> 2.14.1-dev.140
|
||||||
|
#
|
||||||
|
# The last case (commits after a release tag) bumps the patch level so
|
||||||
|
# the resulting semver sorts higher than the release.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
raw=$(git describe --tags --match "*.*.*")
|
||||||
|
|
||||||
|
# Parse: <major>.<minor>.<patch>[-<label><n>][-<commits>-g<hash>]
|
||||||
|
if [[ "$raw" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-([A-Za-z]+)([0-9]*))?(-([0-9]+)-g[0-9a-f]+)?$ ]]; then
|
||||||
|
base="${BASH_REMATCH[1]}"
|
||||||
|
label="${BASH_REMATCH[3]}" # e.g. RC
|
||||||
|
label_num="${BASH_REMATCH[4]}" # e.g. 1
|
||||||
|
commits="${BASH_REMATCH[6]}" # e.g. 140
|
||||||
|
|
||||||
|
# build dot-separated pre-release identifiers
|
||||||
|
pre=""
|
||||||
|
if [[ -n "$label" ]]; then
|
||||||
|
pre="${label,,}" # use lower-case label
|
||||||
|
[[ -n "$label_num" ]] && pre="${pre}.${label_num}"
|
||||||
|
elif [[ -n "$commits" ]]; then
|
||||||
|
# commits after a release tag: bump patch, mark as dev
|
||||||
|
IFS='.' read -r major minor patch <<< "$base"
|
||||||
|
base="${major}.${minor}.$((patch + 1))"
|
||||||
|
pre="dev"
|
||||||
|
fi
|
||||||
|
[[ -n "$commits" && -n "$pre" ]] && pre="${pre}.${commits}"
|
||||||
|
|
||||||
|
if [[ -n "$pre" ]]; then
|
||||||
|
version="${base}-${pre}"
|
||||||
|
else
|
||||||
|
version="$base"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ERROR: Cannot parse version from git describe output: $raw" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
npm pkg set "version=${version}"
|
||||||
|
echo "$version"
|
||||||
@@ -41,8 +41,13 @@ fn draw_stroke_on_rect(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// By default just draw the rect. Only dotted inner/outer strokes need
|
||||||
|
// clipping to prevent the dotted pattern from appearing in wrong areas.
|
||||||
if let Some(clip_op) = stroke.clip_op() {
|
if let Some(clip_op) = stroke.clip_op() {
|
||||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
// Use a neutral layer (no extra paint) so opacity and filters
|
||||||
|
// come solely from the stroke paint. This avoids applying
|
||||||
|
// stroke alpha twice for dotted inner/outer strokes.
|
||||||
|
let layer_rec = skia::canvas::SaveLayerRec::default();
|
||||||
canvas.save_layer(&layer_rec);
|
canvas.save_layer(&layer_rec);
|
||||||
match corners {
|
match corners {
|
||||||
Some(radii) => {
|
Some(radii) => {
|
||||||
@@ -81,7 +86,10 @@ fn draw_stroke_on_circle(
|
|||||||
// By default just draw the circle. Only dotted inner/outer strokes need
|
// By default just draw the circle. Only dotted inner/outer strokes need
|
||||||
// clipping to prevent the dotted pattern from appearing in wrong areas.
|
// clipping to prevent the dotted pattern from appearing in wrong areas.
|
||||||
if let Some(clip_op) = stroke.clip_op() {
|
if let Some(clip_op) = stroke.clip_op() {
|
||||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
// Use a neutral layer (no extra paint) so opacity and filters
|
||||||
|
// come solely from the stroke paint. This avoids applying
|
||||||
|
// stroke alpha twice for dotted inner/outer strokes.
|
||||||
|
let layer_rec = skia::canvas::SaveLayerRec::default();
|
||||||
canvas.save_layer(&layer_rec);
|
canvas.save_layer(&layer_rec);
|
||||||
let clip_path = {
|
let clip_path = {
|
||||||
let mut pb = skia::PathBuilder::new();
|
let mut pb = skia::PathBuilder::new();
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ fn calculate_cursor_rect(
|
|||||||
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
|
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
|
||||||
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
|
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
|
||||||
if idx == cursor.paragraph {
|
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
|
// For cursor, we get a zero-width range at the position
|
||||||
// We need to handle edge cases:
|
// We need to handle edge cases:
|
||||||
// - At start of paragraph: use position 0
|
// - At start of paragraph: use position 0
|
||||||
@@ -209,13 +209,13 @@ fn calculate_selection_rects(
|
|||||||
.sum();
|
.sum();
|
||||||
|
|
||||||
let range_start = if para_idx == start.paragraph {
|
let range_start = if para_idx == start.paragraph {
|
||||||
start.char_offset
|
start.offset
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
|
||||||
let range_end = if para_idx == end.paragraph {
|
let range_end = if para_idx == end.paragraph {
|
||||||
end.char_offset
|
end.offset
|
||||||
} else {
|
} else {
|
||||||
para_char_count
|
para_char_count
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
|||||||
use skia_safe::{
|
use skia_safe::{
|
||||||
self as skia,
|
self as skia,
|
||||||
paint::{self, Paint},
|
paint::{self, Paint},
|
||||||
|
textlayout::Affinity,
|
||||||
textlayout::ParagraphBuilder,
|
textlayout::ParagraphBuilder,
|
||||||
textlayout::ParagraphStyle,
|
textlayout::ParagraphStyle,
|
||||||
textlayout::PositionWithAffinity,
|
textlayout::PositionWithAffinity,
|
||||||
@@ -112,31 +113,51 @@ impl TextContentSize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
pub struct TextPositionWithAffinity {
|
pub struct TextPositionWithAffinity {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub position_with_affinity: PositionWithAffinity,
|
pub position_with_affinity: PositionWithAffinity,
|
||||||
pub paragraph: i32,
|
pub paragraph: usize,
|
||||||
#[allow(dead_code)]
|
pub offset: usize,
|
||||||
pub span: i32,
|
}
|
||||||
#[allow(dead_code)]
|
|
||||||
pub span_relative_offset: i32,
|
impl PartialEq for TextPositionWithAffinity {
|
||||||
pub offset: i32,
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.paragraph == other.paragraph && self.offset == other.offset
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextPositionWithAffinity {
|
impl TextPositionWithAffinity {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
position_with_affinity: PositionWithAffinity,
|
position_with_affinity: PositionWithAffinity,
|
||||||
paragraph: i32,
|
paragraph: usize,
|
||||||
span: i32,
|
offset: usize,
|
||||||
span_relative_offset: i32,
|
|
||||||
offset: i32,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
position_with_affinity,
|
position_with_affinity,
|
||||||
paragraph,
|
paragraph,
|
||||||
span,
|
offset,
|
||||||
span_relative_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,
|
offset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,10 +454,11 @@ impl TextContent {
|
|||||||
let mut offset_y = 0.0;
|
let mut offset_y = 0.0;
|
||||||
let layout_paragraphs = self.layout.paragraphs.iter().flatten();
|
let layout_paragraphs = self.layout.paragraphs.iter().flatten();
|
||||||
|
|
||||||
let mut paragraph_index: i32 = -1;
|
// IMPORTANT! I'm keeping this because I think it should be better to have the span index
|
||||||
let mut span_index: i32 = -1;
|
// cached the same way we keep the paragraph index.
|
||||||
for layout_paragraph in layout_paragraphs {
|
#[allow(dead_code)]
|
||||||
paragraph_index += 1;
|
let mut _span_index: usize = 0;
|
||||||
|
for (paragraph_index, layout_paragraph) in layout_paragraphs.enumerate() {
|
||||||
let start_y = offset_y;
|
let start_y = offset_y;
|
||||||
let end_y = offset_y + layout_paragraph.height();
|
let end_y = offset_y + layout_paragraph.height();
|
||||||
|
|
||||||
@@ -453,20 +475,22 @@ impl TextContent {
|
|||||||
if matches {
|
if matches {
|
||||||
let position_with_affinity =
|
let position_with_affinity =
|
||||||
layout_paragraph.get_glyph_position_at_coordinate(*point);
|
layout_paragraph.get_glyph_position_at_coordinate(*point);
|
||||||
if let Some(paragraph) = self.paragraphs().get(paragraph_index as usize) {
|
if let Some(paragraph) = self.paragraphs().get(paragraph_index) {
|
||||||
// Computed position keeps the current position in terms
|
// Computed position keeps the current position in terms
|
||||||
// of number of characters of text. This is used to know
|
// of number of characters of text. This is used to know
|
||||||
// in which span we are.
|
// in which span we are.
|
||||||
let mut computed_position = 0;
|
let mut computed_position: usize = 0;
|
||||||
let mut span_offset = 0;
|
|
||||||
|
// This could be useful in the future as part of the TextPositionWithAffinity.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
let mut _span_offset: usize = 0;
|
||||||
|
|
||||||
// If paragraph has no spans, default to span 0, offset 0
|
// If paragraph has no spans, default to span 0, offset 0
|
||||||
if paragraph.children().is_empty() {
|
if paragraph.children().is_empty() {
|
||||||
span_index = 0;
|
_span_index = 0;
|
||||||
span_offset = 0;
|
_span_offset = 0;
|
||||||
} else {
|
} else {
|
||||||
for span in paragraph.children() {
|
for span in paragraph.children() {
|
||||||
span_index += 1;
|
|
||||||
let length = span.text.chars().count();
|
let length = span.text.chars().count();
|
||||||
let start_position = computed_position;
|
let start_position = computed_position;
|
||||||
let end_position = computed_position + length;
|
let end_position = computed_position + length;
|
||||||
@@ -475,27 +499,26 @@ impl TextContent {
|
|||||||
// Handle empty spans: if the span is empty and current position
|
// Handle empty spans: if the span is empty and current position
|
||||||
// matches the start, this is the right span
|
// matches the start, this is the right span
|
||||||
if length == 0 && current_position == start_position {
|
if length == 0 && current_position == start_position {
|
||||||
span_offset = 0;
|
_span_offset = 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if start_position <= current_position
|
if start_position <= current_position
|
||||||
&& end_position >= current_position
|
&& end_position >= current_position
|
||||||
{
|
{
|
||||||
span_offset =
|
_span_offset =
|
||||||
position_with_affinity.position - start_position as i32;
|
position_with_affinity.position as usize - start_position;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
computed_position += length;
|
computed_position += length;
|
||||||
|
_span_index += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Some(TextPositionWithAffinity::new(
|
return Some(TextPositionWithAffinity::new(
|
||||||
position_with_affinity,
|
position_with_affinity,
|
||||||
paragraph_index,
|
paragraph_index,
|
||||||
span_index,
|
position_with_affinity.position as usize,
|
||||||
span_offset,
|
|
||||||
position_with_affinity.position,
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -516,9 +539,7 @@ impl TextContent {
|
|||||||
return Some(TextPositionWithAffinity::new(
|
return Some(TextPositionWithAffinity::new(
|
||||||
default_position,
|
default_position,
|
||||||
0, // paragraph 0
|
0, // paragraph 0
|
||||||
0, // span 0
|
|
||||||
0, // offset 0
|
0, // offset 0
|
||||||
0,
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,34 +7,10 @@ use skia_safe::{
|
|||||||
Color,
|
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
pub struct TextSelection {
|
pub struct TextSelection {
|
||||||
pub anchor: TextCursor,
|
pub anchor: TextPositionWithAffinity,
|
||||||
pub focus: TextCursor,
|
pub focus: TextPositionWithAffinity,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextSelection {
|
impl TextSelection {
|
||||||
@@ -42,10 +18,10 @@ impl TextSelection {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_cursor(cursor: TextCursor) -> Self {
|
pub fn from_position_with_affinity(position: TextPositionWithAffinity) -> Self {
|
||||||
Self {
|
Self {
|
||||||
anchor: cursor,
|
anchor: position,
|
||||||
focus: cursor,
|
focus: position,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,12 +33,12 @@ impl TextSelection {
|
|||||||
!self.is_collapsed()
|
!self.is_collapsed()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_caret(&mut self, cursor: TextCursor) {
|
pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) {
|
||||||
self.anchor = cursor;
|
self.anchor = cursor;
|
||||||
self.focus = cursor;
|
self.focus = cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extend_to(&mut self, cursor: TextCursor) {
|
pub fn extend_to(&mut self, cursor: TextPositionWithAffinity) {
|
||||||
self.focus = cursor;
|
self.focus = cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,24 +50,24 @@ impl TextSelection {
|
|||||||
self.focus = self.anchor;
|
self.focus = self.anchor;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start(&self) -> TextCursor {
|
pub fn start(&self) -> TextPositionWithAffinity {
|
||||||
if self.anchor.paragraph < self.focus.paragraph {
|
if self.anchor.paragraph < self.focus.paragraph {
|
||||||
self.anchor
|
self.anchor
|
||||||
} else if self.anchor.paragraph > self.focus.paragraph {
|
} else if self.anchor.paragraph > self.focus.paragraph {
|
||||||
self.focus
|
self.focus
|
||||||
} else if self.anchor.char_offset <= self.focus.char_offset {
|
} else if self.anchor.offset <= self.focus.offset {
|
||||||
self.anchor
|
self.anchor
|
||||||
} else {
|
} else {
|
||||||
self.focus
|
self.focus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn end(&self) -> TextCursor {
|
pub fn end(&self) -> TextPositionWithAffinity {
|
||||||
if self.anchor.paragraph > self.focus.paragraph {
|
if self.anchor.paragraph > self.focus.paragraph {
|
||||||
self.anchor
|
self.anchor
|
||||||
} else if self.anchor.paragraph < self.focus.paragraph {
|
} else if self.anchor.paragraph < self.focus.paragraph {
|
||||||
self.focus
|
self.focus
|
||||||
} else if self.anchor.char_offset >= self.focus.char_offset {
|
} else if self.anchor.offset >= self.focus.offset {
|
||||||
self.anchor
|
self.anchor
|
||||||
} else {
|
} else {
|
||||||
self.focus
|
self.focus
|
||||||
@@ -102,7 +78,7 @@ impl TextSelection {
|
|||||||
/// Events that the text editor can emit for frontend synchronization
|
/// Events that the text editor can emit for frontend synchronization
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
pub enum EditorEvent {
|
pub enum TextEditorEvent {
|
||||||
None = 0,
|
None = 0,
|
||||||
ContentChanged = 1,
|
ContentChanged = 1,
|
||||||
SelectionChanged = 2,
|
SelectionChanged = 2,
|
||||||
@@ -131,7 +107,7 @@ pub struct TextEditorState {
|
|||||||
pub active_shape_id: Option<Uuid>,
|
pub active_shape_id: Option<Uuid>,
|
||||||
pub cursor_visible: bool,
|
pub cursor_visible: bool,
|
||||||
pub last_blink_time: f64,
|
pub last_blink_time: f64,
|
||||||
pending_events: Vec<EditorEvent>,
|
pending_events: Vec<TextEditorEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextEditorState {
|
impl TextEditorState {
|
||||||
@@ -189,56 +165,44 @@ impl TextEditorState {
|
|||||||
|
|
||||||
pub fn select_all(&mut self, content: &TextContent) -> bool {
|
pub fn select_all(&mut self, content: &TextContent) -> bool {
|
||||||
self.is_pointer_selection_active = false;
|
self.is_pointer_selection_active = false;
|
||||||
self.set_caret_from_position(TextPositionWithAffinity::new(
|
self.set_caret_from_position(&TextPositionWithAffinity::empty());
|
||||||
PositionWithAffinity {
|
let num_paragraphs = content.paragraphs().len() - 1;
|
||||||
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 {
|
let Some(last_paragraph) = content.paragraphs().last() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let num_spans = (last_paragraph.children().len() - 1) as i32;
|
#[allow(dead_code)]
|
||||||
let Some(last_text_span) = last_paragraph.children().last() else {
|
let _num_spans = last_paragraph.children().len() - 1;
|
||||||
|
let Some(_last_text_span) = last_paragraph.children().last() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
for span in last_paragraph.children() {
|
for span in last_paragraph.children() {
|
||||||
offset += span.text.len();
|
offset += span.text.len();
|
||||||
}
|
}
|
||||||
self.extend_selection_from_position(TextPositionWithAffinity::new(
|
self.extend_selection_from_position(&TextPositionWithAffinity::new(
|
||||||
PositionWithAffinity {
|
PositionWithAffinity {
|
||||||
position: offset as i32,
|
position: offset as i32,
|
||||||
affinity: Affinity::Upstream,
|
affinity: Affinity::Upstream,
|
||||||
},
|
},
|
||||||
num_paragraphs,
|
num_paragraphs,
|
||||||
num_spans,
|
offset,
|
||||||
last_text_span.text.len() as i32,
|
|
||||||
offset as i32,
|
|
||||||
));
|
));
|
||||||
self.reset_blink();
|
self.reset_blink();
|
||||||
self.push_event(crate::state::EditorEvent::SelectionChanged);
|
self.push_event(crate::state::TextEditorEvent::SelectionChanged);
|
||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) {
|
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(*position);
|
||||||
self.selection.set_caret(cursor);
|
|
||||||
self.reset_blink();
|
self.reset_blink();
|
||||||
self.push_event(EditorEvent::SelectionChanged);
|
self.push_event(TextEditorEvent::SelectionChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extend_selection_from_position(&mut self, position: TextPositionWithAffinity) {
|
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(*position);
|
||||||
self.selection.extend_to(cursor);
|
|
||||||
self.reset_blink();
|
self.reset_blink();
|
||||||
self.push_event(EditorEvent::SelectionChanged);
|
self.push_event(TextEditorEvent::SelectionChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_blink(&mut self, timestamp_ms: f64) {
|
pub fn update_blink(&mut self, timestamp_ms: f64) {
|
||||||
@@ -264,41 +228,17 @@ impl TextEditorState {
|
|||||||
self.last_blink_time = 0.0;
|
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) {
|
if self.pending_events.last() != Some(&event) {
|
||||||
self.pending_events.push(event);
|
self.pending_events.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn poll_event(&mut self) -> EditorEvent {
|
pub fn poll_event(&mut self) -> TextEditorEvent {
|
||||||
self.pending_events.pop().unwrap_or(EditorEvent::None)
|
self.pending_events.pop().unwrap_or(TextEditorEvent::None)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_pending_events(&self) -> bool {
|
pub fn has_pending_events(&self) -> bool {
|
||||||
!self.pending_events.is_empty()
|
!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,7 +1,7 @@
|
|||||||
use crate::math::{Matrix, Point, Rect};
|
use crate::math::{Matrix, Point, Rect};
|
||||||
use crate::mem;
|
use crate::mem;
|
||||||
use crate::shapes::{Paragraph, Shape, TextContent, Type, VerticalAlign};
|
use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
|
||||||
use crate::state::{TextCursor, TextSelection};
|
use crate::state::TextSelection;
|
||||||
use crate::utils::uuid_from_u32_quartet;
|
use crate::utils::uuid_from_u32_quartet;
|
||||||
use crate::utils::uuid_to_u32_quartet;
|
use crate::utils::uuid_to_u32_quartet;
|
||||||
use crate::{with_state, with_state_mut, STATE};
|
use crate::{with_state, with_state_mut, STATE};
|
||||||
@@ -132,7 +132,7 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
|
|||||||
if let Some(position) =
|
if let Some(position) =
|
||||||
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
|
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
|
||||||
{
|
{
|
||||||
state.text_editor_state.set_caret_from_position(position);
|
state.text_editor_state.set_caret_from_position(&position);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -168,7 +168,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
|
|||||||
{
|
{
|
||||||
state
|
state
|
||||||
.text_editor_state
|
.text_editor_state
|
||||||
.extend_selection_from_position(position);
|
.extend_selection_from_position(&position);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -203,7 +203,7 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
|
|||||||
{
|
{
|
||||||
state
|
state
|
||||||
.text_editor_state
|
.text_editor_state
|
||||||
.extend_selection_from_position(position);
|
.extend_selection_from_position(&position);
|
||||||
}
|
}
|
||||||
state.text_editor_state.stop_pointer_selection();
|
state.text_editor_state.stop_pointer_selection();
|
||||||
});
|
});
|
||||||
@@ -231,7 +231,7 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
|
|||||||
if let Some(position) =
|
if let Some(position) =
|
||||||
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
|
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
|
||||||
{
|
{
|
||||||
state.text_editor_state.set_caret_from_position(position);
|
state.text_editor_state.set_caret_from_position(&position);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -276,7 +276,8 @@ pub extern "C" fn text_editor_insert_text() {
|
|||||||
let cursor = state.text_editor_state.selection.focus;
|
let cursor = state.text_editor_state.selection.focus;
|
||||||
|
|
||||||
if let Some(new_offset) = insert_text_at_cursor(text_content, &cursor, &text) {
|
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);
|
state.text_editor_state.selection.set_caret(new_cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,10 +287,10 @@ pub extern "C" fn text_editor_insert_text() {
|
|||||||
state.text_editor_state.reset_blink();
|
state.text_editor_state.reset_blink();
|
||||||
state
|
state
|
||||||
.text_editor_state
|
.text_editor_state
|
||||||
.push_event(crate::state::EditorEvent::ContentChanged);
|
.push_event(crate::state::TextEditorEvent::ContentChanged);
|
||||||
state
|
state
|
||||||
.text_editor_state
|
.text_editor_state
|
||||||
.push_event(crate::state::EditorEvent::NeedsLayout);
|
.push_event(crate::state::TextEditorEvent::NeedsLayout);
|
||||||
|
|
||||||
state.render_state.mark_touched(shape_id);
|
state.render_state.mark_touched(shape_id);
|
||||||
});
|
});
|
||||||
@@ -336,10 +337,10 @@ pub extern "C" fn text_editor_delete_backward() {
|
|||||||
state.text_editor_state.reset_blink();
|
state.text_editor_state.reset_blink();
|
||||||
state
|
state
|
||||||
.text_editor_state
|
.text_editor_state
|
||||||
.push_event(crate::state::EditorEvent::ContentChanged);
|
.push_event(crate::state::TextEditorEvent::ContentChanged);
|
||||||
state
|
state
|
||||||
.text_editor_state
|
.text_editor_state
|
||||||
.push_event(crate::state::EditorEvent::NeedsLayout);
|
.push_event(crate::state::TextEditorEvent::NeedsLayout);
|
||||||
|
|
||||||
state.render_state.mark_touched(shape_id);
|
state.render_state.mark_touched(shape_id);
|
||||||
});
|
});
|
||||||
@@ -384,10 +385,10 @@ pub extern "C" fn text_editor_delete_forward() {
|
|||||||
state.text_editor_state.reset_blink();
|
state.text_editor_state.reset_blink();
|
||||||
state
|
state
|
||||||
.text_editor_state
|
.text_editor_state
|
||||||
.push_event(crate::state::EditorEvent::ContentChanged);
|
.push_event(crate::state::TextEditorEvent::ContentChanged);
|
||||||
state
|
state
|
||||||
.text_editor_state
|
.text_editor_state
|
||||||
.push_event(crate::state::EditorEvent::NeedsLayout);
|
.push_event(crate::state::TextEditorEvent::NeedsLayout);
|
||||||
|
|
||||||
state.render_state.mark_touched(shape_id);
|
state.render_state.mark_touched(shape_id);
|
||||||
});
|
});
|
||||||
@@ -423,7 +424,8 @@ pub extern "C" fn text_editor_insert_paragraph() {
|
|||||||
let cursor = state.text_editor_state.selection.focus;
|
let cursor = state.text_editor_state.selection.focus;
|
||||||
|
|
||||||
if split_paragraph_at_cursor(text_content, &cursor) {
|
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);
|
state.text_editor_state.selection.set_caret(new_cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,10 +435,10 @@ pub extern "C" fn text_editor_insert_paragraph() {
|
|||||||
state.text_editor_state.reset_blink();
|
state.text_editor_state.reset_blink();
|
||||||
state
|
state
|
||||||
.text_editor_state
|
.text_editor_state
|
||||||
.push_event(crate::state::EditorEvent::ContentChanged);
|
.push_event(crate::state::TextEditorEvent::ContentChanged);
|
||||||
state
|
state
|
||||||
.text_editor_state
|
.text_editor_state
|
||||||
.push_event(crate::state::EditorEvent::NeedsLayout);
|
.push_event(crate::state::TextEditorEvent::NeedsLayout);
|
||||||
|
|
||||||
state.render_state.mark_touched(shape_id);
|
state.render_state.mark_touched(shape_id);
|
||||||
});
|
});
|
||||||
@@ -494,7 +496,7 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel
|
|||||||
state.text_editor_state.reset_blink();
|
state.text_editor_state.reset_blink();
|
||||||
state
|
state
|
||||||
.text_editor_state
|
.text_editor_state
|
||||||
.push_event(crate::state::EditorEvent::SelectionChanged);
|
.push_event(crate::state::TextEditorEvent::SelectionChanged);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,12 +713,12 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 {
|
|||||||
.map(|span| span.text.chars().count())
|
.map(|span| span.text.chars().count())
|
||||||
.sum();
|
.sum();
|
||||||
let range_start = if para_idx == start.paragraph {
|
let range_start = if para_idx == start.paragraph {
|
||||||
start.char_offset
|
start.offset
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let range_end = if para_idx == end.paragraph {
|
let range_end = if para_idx == end.paragraph {
|
||||||
end.char_offset
|
end.offset
|
||||||
} else {
|
} else {
|
||||||
para_char_count
|
para_char_count
|
||||||
};
|
};
|
||||||
@@ -764,9 +766,9 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
|
|||||||
let sel = &state.text_editor_state.selection;
|
let sel = &state.text_editor_state.selection;
|
||||||
unsafe {
|
unsafe {
|
||||||
*buffer_ptr = sel.anchor.paragraph as u32;
|
*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(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
|
1
|
||||||
})
|
})
|
||||||
@@ -776,7 +778,11 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
|
|||||||
// HELPERS: Cursor & Selection
|
// 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();
|
let paragraphs = text_content.paragraphs();
|
||||||
if cursor.paragraph >= paragraphs.len() {
|
if cursor.paragraph >= paragraphs.len() {
|
||||||
return None;
|
return None;
|
||||||
@@ -794,7 +800,7 @@ fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shap
|
|||||||
let mut y_offset = valign_offset;
|
let mut y_offset = valign_offset;
|
||||||
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
|
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
|
||||||
if idx == cursor.paragraph {
|
if idx == cursor.paragraph {
|
||||||
let char_pos = cursor.char_offset;
|
let char_pos = cursor.offset;
|
||||||
|
|
||||||
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||||||
let rects = laid_out_para.get_rects_for_range(
|
let rects = laid_out_para.get_rects_for_range(
|
||||||
@@ -869,13 +875,13 @@ fn get_selection_rects(
|
|||||||
.map(|span| span.text.chars().count())
|
.map(|span| span.text.chars().count())
|
||||||
.sum();
|
.sum();
|
||||||
let range_start = if para_idx == start.paragraph {
|
let range_start = if para_idx == start.paragraph {
|
||||||
start.char_offset
|
start.offset
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
|
||||||
let range_end = if para_idx == end.paragraph {
|
let range_end = if para_idx == end.paragraph {
|
||||||
end.char_offset
|
end.offset
|
||||||
} else {
|
} else {
|
||||||
para_char_count
|
para_char_count
|
||||||
};
|
};
|
||||||
@@ -914,40 +920,49 @@ fn paragraph_char_count(para: &Paragraph) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clamp a cursor position to valid bounds within the text content.
|
/// 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() {
|
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 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.
|
/// Move cursor left by one character.
|
||||||
fn move_cursor_backward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
|
fn move_cursor_backward(
|
||||||
if cursor.char_offset > 0 {
|
cursor: &TextPositionWithAffinity,
|
||||||
TextCursor::new(cursor.paragraph, cursor.char_offset - 1)
|
paragraphs: &[Paragraph],
|
||||||
|
) -> TextPositionWithAffinity {
|
||||||
|
if cursor.offset > 0 {
|
||||||
|
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset - 1)
|
||||||
} else if cursor.paragraph > 0 {
|
} else if cursor.paragraph > 0 {
|
||||||
let prev_para = cursor.paragraph - 1;
|
let prev_para = cursor.paragraph - 1;
|
||||||
let char_count = paragraph_char_count(¶graphs[prev_para]);
|
let char_count = paragraph_char_count(¶graphs[prev_para]);
|
||||||
TextCursor::new(prev_para, char_count)
|
TextPositionWithAffinity::new_without_affinity(prev_para, char_count)
|
||||||
} else {
|
} else {
|
||||||
*cursor
|
*cursor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move cursor right by one character.
|
/// 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 para = ¶graphs[cursor.paragraph];
|
||||||
let char_count = paragraph_char_count(para);
|
let char_count = paragraph_char_count(para);
|
||||||
|
|
||||||
if cursor.char_offset < char_count {
|
if cursor.offset < char_count {
|
||||||
TextCursor::new(cursor.paragraph, cursor.char_offset + 1)
|
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset + 1)
|
||||||
} else if cursor.paragraph < paragraphs.len() - 1 {
|
} else if cursor.paragraph < paragraphs.len() - 1 {
|
||||||
TextCursor::new(cursor.paragraph + 1, 0)
|
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0)
|
||||||
} else {
|
} else {
|
||||||
*cursor
|
*cursor
|
||||||
}
|
}
|
||||||
@@ -955,52 +970,58 @@ fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCur
|
|||||||
|
|
||||||
/// Move cursor up by one line.
|
/// Move cursor up by one line.
|
||||||
fn move_cursor_up(
|
fn move_cursor_up(
|
||||||
cursor: &TextCursor,
|
cursor: &TextPositionWithAffinity,
|
||||||
paragraphs: &[Paragraph],
|
paragraphs: &[Paragraph],
|
||||||
_text_content: &TextContent,
|
_text_content: &TextContent,
|
||||||
_shape: &Shape,
|
_shape: &Shape,
|
||||||
) -> TextCursor {
|
) -> TextPositionWithAffinity {
|
||||||
// TODO: Implement proper line-based navigation using line metrics
|
// TODO: Implement proper line-based navigation using line metrics
|
||||||
if cursor.paragraph > 0 {
|
if cursor.paragraph > 0 {
|
||||||
let prev_para = cursor.paragraph - 1;
|
let prev_para = cursor.paragraph - 1;
|
||||||
let char_count = paragraph_char_count(¶graphs[prev_para]);
|
let char_count = paragraph_char_count(¶graphs[prev_para]);
|
||||||
let new_offset = cursor.char_offset.min(char_count);
|
let new_offset = cursor.offset.min(char_count);
|
||||||
TextCursor::new(prev_para, new_offset)
|
TextPositionWithAffinity::new_without_affinity(prev_para, new_offset)
|
||||||
} else {
|
} else {
|
||||||
TextCursor::new(cursor.paragraph, 0)
|
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move cursor down by one line.
|
/// Move cursor down by one line.
|
||||||
fn move_cursor_down(
|
fn move_cursor_down(
|
||||||
cursor: &TextCursor,
|
cursor: &TextPositionWithAffinity,
|
||||||
paragraphs: &[Paragraph],
|
paragraphs: &[Paragraph],
|
||||||
_text_content: &TextContent,
|
_text_content: &TextContent,
|
||||||
_shape: &Shape,
|
_shape: &Shape,
|
||||||
) -> TextCursor {
|
) -> TextPositionWithAffinity {
|
||||||
// TODO: Implement proper line-based navigation using line metrics
|
// TODO: Implement proper line-based navigation using line metrics
|
||||||
if cursor.paragraph < paragraphs.len() - 1 {
|
if cursor.paragraph < paragraphs.len() - 1 {
|
||||||
let next_para = cursor.paragraph + 1;
|
let next_para = cursor.paragraph + 1;
|
||||||
let char_count = paragraph_char_count(¶graphs[next_para]);
|
let char_count = paragraph_char_count(¶graphs[next_para]);
|
||||||
let new_offset = cursor.char_offset.min(char_count);
|
let new_offset = cursor.offset.min(char_count);
|
||||||
TextCursor::new(next_para, new_offset)
|
TextPositionWithAffinity::new_without_affinity(next_para, new_offset)
|
||||||
} else {
|
} else {
|
||||||
let char_count = paragraph_char_count(¶graphs[cursor.paragraph]);
|
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.
|
/// 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
|
// 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.
|
/// 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
|
// TODO: Implement proper line-end using line metrics
|
||||||
let char_count = paragraph_char_count(¶graphs[cursor.paragraph]);
|
let char_count = paragraph_char_count(¶graphs[cursor.paragraph]);
|
||||||
TextCursor::new(cursor.paragraph, char_count)
|
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1028,7 +1049,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.
|
/// Insert text at a cursor position. Returns the new character offset after insertion.
|
||||||
fn insert_text_at_cursor(
|
fn insert_text_at_cursor(
|
||||||
text_content: &mut TextContent,
|
text_content: &mut TextContent,
|
||||||
cursor: &TextCursor,
|
cursor: &TextPositionWithAffinity,
|
||||||
text: &str,
|
text: &str,
|
||||||
) -> Option<usize> {
|
) -> Option<usize> {
|
||||||
let paragraphs = text_content.paragraphs_mut();
|
let paragraphs = text_content.paragraphs_mut();
|
||||||
@@ -1048,7 +1069,7 @@ fn insert_text_at_cursor(
|
|||||||
return Some(text.chars().count());
|
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 children = para.children_mut();
|
||||||
let span = &mut children[span_idx];
|
let span = &mut children[span_idx];
|
||||||
@@ -1063,7 +1084,7 @@ fn insert_text_at_cursor(
|
|||||||
new_text.insert_str(byte_offset, text);
|
new_text.insert_str(byte_offset, text);
|
||||||
span.set_text(new_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.
|
/// Delete a range of text specified by a selection.
|
||||||
@@ -1077,20 +1098,16 @@ fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelect
|
|||||||
}
|
}
|
||||||
|
|
||||||
if start.paragraph == end.paragraph {
|
if start.paragraph == end.paragraph {
|
||||||
delete_range_in_paragraph(
|
delete_range_in_paragraph(&mut paragraphs[start.paragraph], start.offset, end.offset);
|
||||||
&mut paragraphs[start.paragraph],
|
|
||||||
start.char_offset,
|
|
||||||
end.char_offset,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
let start_para_len = paragraph_char_count(¶graphs[start.paragraph]);
|
let start_para_len = paragraph_char_count(¶graphs[start.paragraph]);
|
||||||
delete_range_in_paragraph(
|
delete_range_in_paragraph(
|
||||||
&mut paragraphs[start.paragraph],
|
&mut paragraphs[start.paragraph],
|
||||||
start.char_offset,
|
start.offset,
|
||||||
start_para_len,
|
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() {
|
if end.paragraph < paragraphs.len() {
|
||||||
let end_para_children: Vec<_> =
|
let end_para_children: Vec<_> =
|
||||||
@@ -1189,13 +1206,19 @@ fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offs
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the character before the cursor. Returns the new cursor position.
|
/// Delete the character before the cursor. Returns the new cursor position.
|
||||||
fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Option<TextCursor> {
|
fn delete_char_before(
|
||||||
if cursor.char_offset > 0 {
|
text_content: &mut TextContent,
|
||||||
|
cursor: &TextPositionWithAffinity,
|
||||||
|
) -> Option<TextPositionWithAffinity> {
|
||||||
|
if cursor.offset > 0 {
|
||||||
let paragraphs = text_content.paragraphs_mut();
|
let paragraphs = text_content.paragraphs_mut();
|
||||||
let para = &mut paragraphs[cursor.paragraph];
|
let para = &mut paragraphs[cursor.paragraph];
|
||||||
let delete_pos = cursor.char_offset - 1;
|
let delete_pos = cursor.offset - 1;
|
||||||
delete_range_in_paragraph(para, delete_pos, cursor.char_offset);
|
delete_range_in_paragraph(para, delete_pos, cursor.offset);
|
||||||
Some(TextCursor::new(cursor.paragraph, delete_pos))
|
Some(TextPositionWithAffinity::new_without_affinity(
|
||||||
|
cursor.paragraph,
|
||||||
|
delete_pos,
|
||||||
|
))
|
||||||
} else if cursor.paragraph > 0 {
|
} else if cursor.paragraph > 0 {
|
||||||
let prev_para_idx = cursor.paragraph - 1;
|
let prev_para_idx = cursor.paragraph - 1;
|
||||||
let paragraphs = text_content.paragraphs_mut();
|
let paragraphs = text_content.paragraphs_mut();
|
||||||
@@ -1211,14 +1234,17 @@ fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Op
|
|||||||
|
|
||||||
paragraphs.remove(cursor.paragraph);
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the character after the cursor.
|
/// 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();
|
let paragraphs = text_content.paragraphs_mut();
|
||||||
if cursor.paragraph >= paragraphs.len() {
|
if cursor.paragraph >= paragraphs.len() {
|
||||||
return;
|
return;
|
||||||
@@ -1226,9 +1252,9 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
|
|||||||
|
|
||||||
let para_len = paragraph_char_count(¶graphs[cursor.paragraph]);
|
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];
|
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 {
|
} else if cursor.paragraph < paragraphs.len() - 1 {
|
||||||
let next_para_idx = cursor.paragraph + 1;
|
let next_para_idx = cursor.paragraph + 1;
|
||||||
let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect();
|
let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect();
|
||||||
@@ -1241,7 +1267,10 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Split a paragraph at the cursor position. Returns true if split was successful.
|
/// 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();
|
let paragraphs = text_content.paragraphs_mut();
|
||||||
if cursor.paragraph >= paragraphs.len() {
|
if cursor.paragraph >= paragraphs.len() {
|
||||||
return false;
|
return false;
|
||||||
@@ -1249,7 +1278,7 @@ fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor
|
|||||||
|
|
||||||
let para = ¶graphs[cursor.paragraph];
|
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;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user