Compare commits

..

1 Commits

Author SHA1 Message Date
Andrey Antukh
8c0d29bb79 Add minor compatibility adjustments for audit archive task 2026-02-27 11:50:52 +01:00
68 changed files with 913 additions and 1573 deletions

2
.gitignore vendored
View File

@@ -69,7 +69,7 @@
/frontend/test-results/ /frontend/test-results/
/other/ /other/
/scripts/ /scripts/
/telemetry/ /nexus/
/tmp/ /tmp/
/vendor/**/target /vendor/**/target
/vendor/svgclean/bundle*.js /vendor/svgclean/bundle*.js

View File

@@ -16,12 +16,10 @@
- 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)
@@ -57,7 +55,6 @@
- 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

View File

@@ -2,6 +2,7 @@
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

View File

@@ -103,6 +103,7 @@
[: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]

View File

@@ -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 "event"} [:map {:title "AuditEvent"}
[::type ::sm/text] [::type ::sm/text]
[::name ::sm/text] [::name ::sm/text]
[::profile-id ::sm/uuid] [::profile-id ::sm/uuid]

View File

@@ -10,14 +10,11 @@
[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
@@ -52,19 +49,18 @@
(defn- send! (defn- send!
[{:keys [::uri] :as cfg} events] [{:keys [::uri] :as cfg} events]
(let [token (tokens/generate cfg (let [skey (-> cfg ::setup/shared-keys :nexus)
{: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))
"cookie" (u/map->query-string {:auth-token token})} "x-shared-key" (str "nexus " skey)}
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
@@ -109,7 +105,7 @@
(def ^:private schema:handler-params (def ^:private schema:handler-params
[:map [:map
::db/pool ::db/pool
::setup/props ::setup/shared-keys
::http/client]) ::http/client])
(defmethod ig/assert-key ::handler (defmethod ig/assert-key ::handler

View File

@@ -466,6 +466,7 @@
::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)}
@@ -473,9 +474,9 @@
{} {}
:app.loggers.audit.archive-task/handler :app.loggers.audit.archive-task/handler
{::setup/props (ig/ref ::setup/props) {::setup/shared-keys (ig/ref ::setup/shared-keys)
::db/pool (ig/ref ::db/pool) ::http.client/client (ig/ref ::http.client/client)
::http.client/client (ig/ref ::http.client/client)} ::db/pool (ig/ref ::db/pool)}
:app.loggers.audit.gc-task/handler :app.loggers.audit.gc-task/handler
{::db/pool (ig/ref ::db/pool)} {::db/pool (ig/ref ::db/pool)}

View File

@@ -58,3 +58,4 @@
(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;"])))

View File

@@ -82,8 +82,10 @@
(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/warn :hint (str "using autogenerated secret-key, it will change on each restart and will invalidate " (l/wrn :hint (str "using autogenerated secret-key, it will change "
"all sessions on each restart, it is highly recommended setting up the " "on each restart and will invalidate "
"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)
@@ -91,36 +93,26 @@
(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)]
(d/without-nils (reduce (fn [keys id]
{:exporter (let [key (or (get cfg id)
(let [key (or (get cfg :exporter) (-> (keys/derive secret :salt (name id))
(-> (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 :hint "exporter key is disabled because empty string found") (l/wrn :id (name id) :hint "key is disabled because empty string found")
nil) keys)
(do (do
(l/inf :hint "exporter key initialized" :key (d/obfuscate-string key)) (l/inf :id (name id) :hint "key initialized" :key (d/obfuscate-string key))
key))) (assoc keys id key)))))
{}
[:exporter
:nitrate :nitrate
(let [key (or (get cfg :nitrate) :nexus])))
(-> (keys/derive secret :salt "nitrate")
(bc/bytes->b64-str true)))] (sm/register! ::props [:map-of :keyword ::sm/any])
(if (or (str/empty? key) (sm/register! ::shared-keys [:map-of :keyword ::sm/text])
(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)))})))

View File

@@ -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]) (:refer-clojure :exclude [deref merge parse-uuid parse-long parse-double parse-boolean type keys select-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,6 +93,11 @@
[& 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."
@@ -138,10 +143,10 @@
(mu/optional-keys schema keys default-options))) (mu/optional-keys schema keys default-options)))
(defn required-keys (defn required-keys
([schema] ([s]
(mu/required-keys schema nil default-options)) (mu/required-keys (schema s) nil default-options))
([schema keys] ([s keys]
(mu/required-keys schema keys default-options))) (mu/required-keys (schema s) keys default-options)))
(defn transformer (defn transformer
[& transformers] [& transformers]
@@ -646,7 +651,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 (-> kind sg/generator sg/set) :gen/gen (sg/mcat (fn [_] (sg/generator kind)) sg/int)
:decode/string decode :decode/string decode
:decode/json decode :decode/json decode
:encode/string encode-string :encode/string encode-string
@@ -863,7 +868,7 @@
(defn parse-boolean (defn parse-boolean
[v] [v]
(if (string? v) (if (string? v)
(case (str/lower v) (case v
("true" "t" "1") true ("true" "t" "1") true
("false" "f" "0") false ("false" "f" "0") false
v) v)

View File

@@ -219,6 +219,9 @@ 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>
@@ -242,9 +245,6 @@ 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>

View File

@@ -404,8 +404,6 @@ export class WorkspacePage extends BaseWebSocketPage {
return content !== ""; return content !== "";
}, { timeout: 1000 }); }, { timeout: 1000 });
await this.page.waitForTimeout(3000);
} }
/** /**
@@ -419,8 +417,7 @@ 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();
} }
await this.page.keyboard.press("ControlOrMeta+V"); return this.page.keyboard.press("ControlOrMeta+V");
await this.page.waitForTimeout(3000);
} }
async panOnViewportAt(x, y, width, height) { async panOnViewportAt(x, y, width, height) {

View File

@@ -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: "is not in any active set", name: "This token is not in any",
}); });
await expect(brokenPill).toBeVisible(); await expect(brokenPill).toBeVisible();

View File

@@ -388,7 +388,6 @@ test("User cut paste 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();
@@ -397,7 +396,6 @@ test("User cut paste a component with path inside a variant", async ({
.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");
@@ -429,7 +427,6 @@ test("User drag and drop a component with path inside a variant", async ({
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20); await workspacePage.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();

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

View File

@@ -1,11 +1,10 @@
(ns app.main.data.nitrate (ns app.main.data.nitrate
(:require (:require
[app.common.data.macros :as dm]
[app.common.uri :as u]
[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]))
@@ -16,24 +15,14 @@
(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! (rt/nav-raw :href "/control-center/"))) (st/emit! (dom/open-new-window "/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)))))

View File

@@ -620,14 +620,7 @@
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.
;; The classic text editor sets :workspace-editor-state; the WASM text editor (when (empty? (get state :workspace-editor-state))
;; 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
@@ -681,12 +674,7 @@
(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.

View File

@@ -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 :ignore-touched (:ignore-touched options)))) (rx/of (dwm/apply-wasm-modifiers modif-tree)))
(let [page-id (or (:page-id options) (let [page-id (or (:page-id options)
(:current-page-id state)) (:current-page-id state))

View File

@@ -86,24 +86,6 @@
: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))

View File

@@ -183,6 +183,9 @@
[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))

View File

@@ -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 (or (get token :name) applied-token) label (get token :name)
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)))

View File

@@ -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" label)) (tr "ds.inputs.token-field.no-active-token-option"))
default-id (mf/use-id) default-id (mf/use-id)
id (d/nilv id default-id) id (d/nilv id default-id)

View File

@@ -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,6 +26,7 @@
(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)
@@ -67,17 +68,18 @@
(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
:is-read-only true :read-only? true
:is-highlighted false :highlighted? false
:is-selected selected? :selected? selected?
:is-component-tree component-tree? :component-tree? component-tree?
:is-filtered false :hidden? hidden?
:is-expanded expanded? :filtered? false
:hide-toggle hide-toggle? :expanded? expanded?
:hide-toggle? hide-toggle?
:on-select-shape select-shape :on-select-shape select-shape
:on-toggle-collapse toggle-collapse} :on-toggle-collapse toggle-collapse}

View File

@@ -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,7 +35,8 @@
(mf/use-fn (mf/use-fn
(mf/deps form) (mf/deps form)
(fn [] (fn []
(dnt/go-to-buy-nitrate-license (-> @form :clean-data :subscription name))))] (let [params (:clean-data @form)]
(dom/open-new-window (str "/control-center/licenses/start?subscription=" (name (:subscription params)))))))]
[:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :subscription-success)} [:div {:class (stl/css :modal-dialog :subscription-success)}

View File

@@ -32,7 +32,6 @@
[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]
@@ -105,4 +104,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.14"))) (rc/render-release-notes (assoc params :version "2.13")))

View File

@@ -1,178 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.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)}
"Whats 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 Penpots design system foundations forward, with a big focus on design tokens. Were 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, youll find a handful of quality-of-life improvements and some performance work in the sidebar to keep things feeling smooth as your files grow. Lets dive in."]
[:p {:class (stl/css :feature-content)}
"Lets 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 were 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 youve 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 youre 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 dont 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 wont make them awkward to click (even in deeply nested files)."]
[:p {:class (stl/css :feature-content)}
"Were 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"]]]]]])))

View File

@@ -1,102 +0,0 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
.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;
}

View File

@@ -4,7 +4,6 @@
[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]
@@ -356,37 +355,6 @@
: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)
@@ -406,8 +374,7 @@
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)
@@ -491,8 +458,6 @@
^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")
@@ -500,7 +465,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)}
@@ -676,13 +641,8 @@
(mf/use-fn (mf/use-fn
(mf/deps form) (mf/deps form)
(fn [] (fn []
(let [subscription (-> @form :clean-data :subscription name) (let [params (:clean-data @form)]
return-url (dm/str (dom/open-new-window (str "/control-center/licenses/start?subscription=" (name (:subscription params)))))))]
(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)}

View File

@@ -10,7 +10,6 @@
[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]
@@ -38,8 +37,6 @@
(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
@@ -51,15 +48,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
[{:keys [item depth parent-size name-ref children ref style rename-id {::mf/wrap-props false}
[{:keys [item depth parent-size name-ref children ref style
;; Flags ;; Flags
is-read-only is-highlighted is-selected is-component-tree read-only? highlighted? selected? component-tree?
is-filtered is-expanded dnd-over dnd-over-top dnd-over-bot hide-toggle filtered? 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)
@@ -67,7 +64,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)
root-board? (and (cfh/frame-shape? item) parent-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)
@@ -76,11 +73,9 @@
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))
component-id (get item :component-id) data (deref refs/workspace-data)
data (mf/deref refs/workspace-data) component (ctkl/get-component data (:component-id item))
variant-properties (-> (ctkl/get-component data component-id) variant-properties (:variant-properties component)
(get :variant-properties))
icon-shape (usi/get-shape-icon item)] icon-shape (usi/get-shape-icon item)]
[:* [:*
@@ -90,30 +85,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 is-selected :aria-checked selected?
:class (stl/css-case :class (stl/css-case
:layer-row true :layer-row true
:highlight is-highlighted :highlight highlighted?
:component (ctk/instance-head? item) :component (ctk/instance-head? item)
:masked (:masked-group item) :masked (:masked-group item)
:selected is-selected :selected 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 is-component-tree is-variant-container?) :type-comp (or 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 root-board?) :root-board parent-board?)
:style style} :style style}
[:span {:class (stl/css-case [:span {:class (stl/css-case
:tab-indentation true :tab-indentation true
:filtered is-filtered) :filtered 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 is-filtered :filtered filtered?
:selected is-selected :selected 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
@@ -122,12 +117,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 is-filtered)) (when (and (not hide-toggle?) (not filtered?))
[:button {:class (stl/css-case [:button {:class (stl/css-case
:toggle-content true :toggle-content true
:inverse is-expanded) :inverse expanded?)
:data-testid "toggle-content" :data-testid "toggle-content"
:aria-expanded is-expanded :aria-expanded expanded?
:on-click on-toggle-collapse} :on-click on-toggle-collapse}
deprecated-icon/arrow]) deprecated-icon/arrow])
@@ -138,7 +133,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 is-filtered) (when (not ^boolean 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}
@@ -147,27 +142,25 @@
[:> 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 is-read-only :disabled-double-click 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 is-selected :is-selected selected?
:type-comp (or is-component-tree is-variant-container?) :type-comp (or 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 component-id :component-id (:id component)
:is-hidden hidden? :is-hidden hidden?}]]
:on-tab-press on-tab-press}]] (when (not read-only?)
(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?
@@ -192,86 +185,41 @@
children])) children]))
(mf/defc layer-item* ;; Memoized for performance
{::mf/wrap [mf/memo]} (mf/defc layer-item
[{:keys [index item selected objects rename-id {::mf/props :obj
is-sortable is-filtered depth is-component-child ::mf/wrap [mf/memo]}
highlighted style render-children parent-size] [{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted style render-children?]
:or {render-children true}}] :or {render-children? true}}]
(let [id (get item :id) (let [id (:id item)
blocked? (get item :blocked) blocked? (:blocked item)
hidden? (get item :hidden) hidden? (:hidden item)
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-middle-ref (mf/use-ref true) scroll-to-middle? (mf/use-var true)
expanded-iref (mf/with-memo [id] expanded-iref (mf/with-memo [id]
(l/derived #(dm/get-in % [:expanded id]) refs/workspace-local)) (-> (l/in [:expanded id])
is-expanded (mf/deref expanded-iref) (l/derived refs/workspace-local)))
expanded? (mf/deref expanded-iref)
is-selected (contains? selected id) selected? (contains? selected id)
is-highlighted (contains? highlighted id) highlighted? (contains? highlighted id)
container? (or (cfh/frame-shape? item) container? (or (cfh/frame-shape? item)
(cfh/group-shape? item)) (cfh/group-shape? item))
is-read-only (mf/use-ctx ctx/workspace-read-only?) read-only? (mf/use-ctx ctx/workspace-read-only?)
root-board? (and (cfh/frame-shape? item) parent-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 is-expanded) (mf/deps expanded?)
(fn [event] (fn [event]
(dom/stop-propagation event) (dom/stop-propagation event)
(if (and is-expanded (kbd/shift? event)) (if (and expanded? (kbd/shift? event))
(st/emit! (dwc/collapse-all)) (st/emit! (dwc/collapse-all))
(st/emit! (dwc/toggle-collapse id))))) (st/emit! (dwc/toggle-collapse id)))))
@@ -296,13 +244,13 @@
select-shape select-shape
(mf/use-fn (mf/use-fn
(mf/deps id is-filtered objects) (mf/deps id filtered? objects)
(fn [event] (fn [event]
(dom/prevent-default event) (dom/prevent-default event)
(mf/set-ref-val! scroll-middle-ref false) (reset! scroll-to-middle? false)
(cond (cond
(kbd/shift? event) (kbd/shift? event)
(if is-filtered (if 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)))
@@ -337,11 +285,11 @@
on-context-menu on-context-menu
(mf/use-fn (mf/use-fn
(mf/deps item is-read-only) (mf/deps item read-only?)
(fn [event] (fn [event]
(dom/prevent-default event) (dom/prevent-default event)
(dom/stop-propagation event) (dom/stop-propagation event)
(when-not is-read-only (when-not 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}))))))
@@ -354,7 +302,7 @@
on-drop on-drop
(mf/use-fn (mf/use-fn
(mf/deps id objects is-expanded selected) (mf/deps id objects 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))]
@@ -367,34 +315,32 @@
(= side :center) (= side :center)
id id
(and is-expanded (= side :bot) (d/not-empty? (:shapes shape))) (and 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 _] [parent-id _] (ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files)
(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 is-expanded (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent)) (and expanded? (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent))
;; target not found in parent (while lazy loading) ;; 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 is-expanded) (mf/deps id expanded?)
(fn [] (fn []
(when-not is-expanded (when-not expanded?
(st/emit! (dwc/toggle-collapse id))))) (st/emit! (dwc/toggle-collapse id)))))
zoom-to-selected zoom-to-selected
@@ -415,132 +361,112 @@
:data {:id (:id item) :data {:id (:id item)
:index index :index index
:name (:name item)} :name (:name item)}
;; We don't want to change the structure of component copies :draggable? (and
:draggable? (and ^boolean is-sortable sortable?
^boolean (not is-read-only) (not read-only?)
^boolean (not (ctn/has-any-copy-parent? objects item)))) (not (ctn/has-any-copy-parent? objects item)))) ;; We don't want to change the structure of component copies
on-tab-press ref (mf/use-ref)
(mf/use-fn depth (+ depth 1)
(mf/deps id objects) component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item))
(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))))))))]
(mf/with-effect [is-selected selected] enable-drag (mf/use-fn #(reset! drag-disabled* false))
disable-drag (mf/use-fn #(reset! drag-disabled* true))
;; Lazy loading of child elements via IntersectionObserver
children-count* (mf/use-state 0)
children-count (deref children-count*)
lazy-ref (mf/use-ref nil)
observer-var (mf/use-var nil)
chunk-size 50]
(mf/with-effect [selected? selected]
(let [single? (= (count selected) 1) (let [single? (= (count selected) 1)
node (mf/ref-val name-node-ref) node (mf/ref-val 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 ^boolean single? (when (and single? selected? @scroll-to-middle?)
^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"})
(mf/set-ref-val! scroll-middle-ref true)))))] (reset! scroll-to-middle? 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 [is-expanded shapes selected] (mf/with-effect [expanded? (:shapes item) selected]
(let [total (count shapes)] (let [shapes-vec (:shapes item)
(if ^boolean is-expanded total (count shapes-vec)]
(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 (> total default-chunk-size) (when (and (> total chunk-size) (seq selected))
(let [shapes-reversed (vec (reverse shapes-vec))]
(some (fn [sel-id] (some (fn [sel-id]
(let [idx (.indexOf shapes sel-id)] (let [idx (.indexOf shapes-reversed 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 min-count (if selected-child-render-idx
(if selected-child-render-idx (+ selected-child-render-idx chunk-size)
(+ selected-child-render-idx default-chunk-size) chunk-size)
default-chunk-size) current @children-count*
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 is-expanded shapes] (mf/with-effect [children-count expanded? (:shapes item)]
(let [total (count shapes) (let [total (count (:shapes item))
name-node (mf/ref-val name-node-ref) node (mf/ref-val ref)
scroll-node (dom/get-parent-with-data name-node "scroll-container") scroll-node (dom/get-parent-with-data 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 (mf/ref-val observer-ref)] (when-let [obs ^js @observer-var]
(.disconnect obs) (.disconnect obs)
(mf/set-ref-val! observer-ref nil)) (reset! observer-var nil))
;; Setup new observer if there are more children to load ;; Setup new observer if there are more children to load
(when (and ^boolean is-expanded (when (and expanded?
^boolean (< children-count total) (< children-count total)
^boolean scroll-node scroll-node
^boolean lazy-node) lazy-node)
(let [cb (fn [entries] (let [cb (fn [entries]
(when (and (pos? (alength entries)) (when (and (seq entries)
(.-isIntersecting ^js (aget entries 0))) (.-isIntersecting (first entries)))
;; Load next chunk when sentinel intersects ;; Load next chunk when sentinel intersects
(let [next-count (mth/min total (+ children-count default-chunk-size))] (let [current @children-count*
next-count (min total (+ current chunk-size))]
(reset! children-count* next-count)))) (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)
(mf/set-ref-val! observer-ref observer))))) (reset! observer-var 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 name-node-ref :name-ref ref
:rename-id rename-id :read-only? read-only?
:is-read-only is-read-only :highlighted? highlighted?
:is-highlighted is-highlighted :selected? selected?
:is-selected is-selected :component-tree? component-tree?
:is-component-tree is-component-tree :filtered? filtered?
:is-filtered is-filtered :expanded? expanded?
:is-expanded is-expanded :dnd-over? (= (:over dprops) :center)
:dnd-over (= (:over dprops) :center) :dnd-over-top? (= (:over dprops) :top)
:dnd-over-top (= (:over dprops) :top) :dnd-over-bot? (= (:over dprops) :bot)
: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
@@ -551,31 +477,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 ^boolean render-children (when (and render-children?
^boolean shapes (:shapes item)
^boolean is-expanded) expanded?)
[:div {:class (stl/css-case [:div {:class (stl/css-case
:element-children true :element-children true
:parent-selected is-selected :parent-selected selected?
:sticky-children root-board?) :sticky-children parent-board?)
:data-testid (dm/str "children-" id)} :data-testid (dm/str "children-" id)}
(for [item (take children-count shapes)] (let [all-children (reverse (d/enumerate (:shapes item)))
[:> layer-item* visible (take children-count all-children)]
(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 (unchecked-get item "__$__counter") :index index
:objects objects :objects objects
:key (dm/str (get item :id)) :key (dm/str id)
:is-sortable is-sortable :sortable? sortable?
:depth depth :depth depth
:parent-size parent-size :parent-size parent-size
:is-component-child is-component-tree}]) :component-child? 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)}])])]))

View File

@@ -16,35 +16,39 @@
[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 ^:const space-for-icons 110) (def ^:private space-for-icons 110)
(def lens:shape-for-rename
(-> (l/in [:workspace-local :shape-for-rename])
(l/derived st/state)))
(mf/defc layer-name* (mf/defc layer-name*
[{:keys [shape-id rename-id shape-name is-shape-touched disabled-double-click {::mf/wrap-props false
::mf/forward-ref true}
[{:keys [shape-id shape-name is-shape-touched disabled-double-click
on-start-edit on-stop-edit depth parent-size is-selected 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 variant-id variant-name variant-properties variant-error]} external-ref]
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 ref local-ref) ref (d/nilv external-ref local-ref)
shape-name shape-for-rename (mf/deref lens:shape-for-rename)
(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 default-value (if variant-id
(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? has-path? (str/includes? shape-name "/")
(str/includes? shape-name "/")
start-edit start-edit
(mf/use-fn (mf/use-fn
@@ -58,14 +62,13 @@
accept-edit accept-edit
(mf/use-fn (mf/use-fn
(mf/deps edition? shape-id on-stop-edit component-id variant-id variant-name variant-properties) (mf/deps 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
@@ -77,27 +80,15 @@
on-key-down on-key-down
(mf/use-fn (mf/use-fn
(mf/deps edition? accept-edit cancel-edit on-tab-press shape-id on-stop-edit) (mf/deps accept-edit cancel-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 parent-size (dm/str (- parent-size space-for-icons) "px")]
(dm/str (- parent-size space-for-icons) "px")]
(mf/with-effect [rename-id edition? start-edit shape-id] (mf/with-effect [shape-for-rename edition? start-edit shape-id]
(when (and (= rename-id shape-id) (when (and (= shape-for-rename shape-id)
(not ^boolean edition?)) (not ^boolean edition?))
(start-edit))) (start-edit)))
@@ -119,9 +110,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 {:class (stl/css-case [:span
{:class (stl/css-case
:element-name true :element-name true
:left-ellipsis has-path? :left-ellipsis has-path?
:selected is-selected :selected is-selected
@@ -132,11 +123,8 @@
: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)
(if ^boolean (dbg/enabled? :show-ids) (str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24))
(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)} "*"])])))

View File

@@ -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,160 +31,92 @@
[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]
[okulary.core :as l] [rumext.v2 :as mf])
[rumext.v2 :as mf])) (:import
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-ref (let [pending-selected (mf/use-var selected)
(mf/use-ref selected) current-selected (mf/use-state 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/with-memo [] (mf/use-memo
(throttle-fn 50 #(when-let [pending-selected (mf/ref-val pending-selected-ref)] (fn []
(reset! current-selected pending-selected))))] (throttle-fn
50
#(when-let [pending-selected @pending-selected]
(reset! current-selected pending-selected)))))]
(mf/with-effect [selected set-selected] (mf/with-effect [selected set-selected]
(mf/set-ref-val! pending-selected-ref selected) (reset! pending-selected selected)
(^function set-selected) (set-selected)
(fn [] (fn []
(mf/set-ref-val! pending-selected-ref nil) (reset! pending-selected 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 [obj shapes] (for [[index id] (reverse (d/enumerate (:shapes root)))]
(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 (unchecked-get obj "__$__counter") :index index
:objects objects :objects objects
:key (dm/str (get obj :id)) :key id
:is-sortable true :sortable? true
:is-filtered is-filtered :filtered? 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 (unchecked-get obj "__$__counter") :index index
:objects objects :objects objects
:key (dm/str (get obj :id)) :key id
:is-sortable true :sortable? true
:is-filtered is-filtered :filtered? filtered?
:depth -1 :depth -1
:parent-size parent-size}]))]])) :parent-size parent-size}])))]]))
(mf/defc layers-tree-wrapper* (mf/defc filters-tree
{::mf/private true} {::mf/wrap [mf/memo #(mf/throttle % 200)]
[{:keys [objects] :as props}] ::mf/wrap-props false}
;; 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 (use-selected-shapes) (let [selected (mf/deref refs/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
:is-sortable false :sortable? false
:is-filtered true :filtered? true
:depth -1 :depth -1
:parent-size parent-size}]))])) :parent-size parent-size}]))]))
@@ -200,7 +132,6 @@
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
@@ -346,11 +277,9 @@
(swap! state* update :num-items + 100))))] (swap! state* update :num-items + 100))))]
(mf/with-effect [] (mf/with-effect []
(let [key1 (events/listen globals/document "keydown" on-key-down) (let [keys [(events/listen globals/document EventType.KEYDOWN on-key-down)
key2 (events/listen globals/document "click" hide-menu)] (events/listen globals/document EventType.CLICK hide-menu)]]
(fn [] (fn [] (doseq [key keys] (events/unlistenByKey key)))))
(events/unlistenByKey key1)
(events/unlistenByKey key2))))
[filtered-objects [filtered-objects
handle-show-more handle-show-more
@@ -535,8 +464,6 @@
{::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)
@@ -546,8 +473,7 @@
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] [filtered-objects show-more filter-component] (use-search page objects)
(use-search page objects)
intersection-callback intersection-callback
(fn [entries] (fn [entries]
@@ -593,8 +519,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 page-id) :key (dm/str (:id page))
: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
@@ -602,16 +528,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-wrapper* {:objects filtered-objects [:& layers-tree {:objects filtered-objects
:key (dm/str page-id) :key (dm/str (:id page))
:is-filtered true :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-wrapper* {:objects objects [:& layers-tree {:objects objects
:key (dm/str page-id) :key (dm/str (:id page))
:is-filtered false :filtered? false
:parent-size size-parent}]])])) :parent-size size-parent}]])]))

View File

@@ -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" token-name) (tr "ds.inputs.token-field.no-active-token-option")
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" token-name) (tr "ds.inputs.token-field.no-active-token-option")
has-errors has-errors
(tr "color-row.token-color-row.deleted-token") (tr "color-row.token-color-row.deleted-token")
:else :else

View File

@@ -12,7 +12,6 @@
[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]
@@ -76,11 +75,7 @@
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)
edition (mf/deref refs/selected-edition) not-editing? (empty? editing-ref)
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?)
@@ -137,17 +132,13 @@
on-token-pill-click on-token-pill-click
(mf/use-fn (mf/use-fn
(mf/deps not-editing? selected-ids tokens-lib) (mf/deps not-editing? selected-ids)
(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)
(if (and not-editing? (seq selected-shapes) (not= (:type token) :number)) (when (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))}

View File

@@ -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.shapes :as dwsh] [app.main.data.workspace.transforms :as dwt]
[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,13 +259,9 @@
(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")]
shape (u/locate-shape file-id page-id id) (st/emit! (dwt/move-shapes-to-frame #{child-id} id nil nil)
index (ptk/data-event :layout/update {:ids [id]})))))
(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

View File

@@ -962,8 +962,7 @@
: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 index (if (and (natural-child-ordering? plugin-id) is-reversed?)
(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))))))
@@ -988,7 +987,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 (or (not (natural-child-ordering? plugin-id)) is-reversed?) (if (and (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))))))

View File

@@ -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 "%s ist nicht Teil eines aktiven Sets oder ungültig." msgstr "Dieser Token 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"

View File

@@ -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 "%s is not in any active set or has an invalid value." msgstr "This token 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,10 +8503,6 @@ 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"

View File

@@ -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 "%s no está disponible en ningún set o tiene un valor inválido." msgstr "Este token 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,10 +8369,6 @@ 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"

View File

@@ -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 "%s n'est pas disponible dans la collection ou le thème actif." msgstr "Ce token 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"

View File

@@ -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 "%s n'est disponible dans aucune collection ou est invalide." msgstr "Ce token 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"

View File

@@ -1185,6 +1185,10 @@ 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 "ספק האימות לא מורשה לפרופיל הזה"

View File

@@ -1256,6 +1256,10 @@ 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 "इस प्रोफाइल के लिए ऑथ प्रोवाइडर अनुमति नहीं है"

View File

@@ -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 "%s non è disponibile in nessun set o tema attivo." msgstr "Questo token 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"

View File

@@ -1214,6 +1214,10 @@ 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"

View File

@@ -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 "%s is niet beschikbaar in een actieve verzameling of thema." msgstr "Dit token 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"

View File

@@ -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 "%s não está em nenhum conjunto ativo ou possui um valor inválido." msgstr "Este token 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"

View File

@@ -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 "%s nu este în nici un set activ sau are o valoare invalidă." msgstr "Acest token 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"

View File

@@ -1184,6 +1184,12 @@ 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 "Поставщик аутентификации не разрешён для этого профиля"

View File

@@ -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 "%s är inte i någon aktiv uppsättning eller har ett ogiltigt värde." msgstr "Denna token ä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"

View File

@@ -1355,6 +1355,12 @@ 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"

View File

@@ -1116,6 +1116,10 @@ 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 "认证提供者不允许访问此个人设定"

View File

@@ -1,10 +1,7 @@
# Penpot MCP Project Overview - Updated # Penpot MCP Project Overview - Updated
## Purpose ## Purpose
This project is a Model Context Protocol (MCP) server for Penpot integration. 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.
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
@@ -16,22 +13,21 @@ enabling advanced AI-driven features in Penpot.
## Project Structure ## Project Structure
``` ```
/ (project root) penpot-mcp/
├── packages/common/ # Shared type definitions ├── 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
├── packages/server/ # Main MCP server implementation ├── mcp-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
├── packages/plugin/ # Penpot plugin with response capability ├── penpot-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
@@ -41,24 +37,55 @@ enabling advanced AI-driven features in Penpot.
## 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 `packages/server/src/tools/` following the `Tool` interface. 1. Implement the tool class in `mcp-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`.
Tools can be associated with a `PluginTask` that is executed in the plugin. Look at `PrintTextTool` as an example.
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 `packages/common/src/types.ts`. 1. Implement the input data interface for the task in `common/src/types.ts`.
2. Implement the `PluginTask` class in `packages/server/src/tasks/`. 2. Implement the `PluginTask` class in `mcp-server/src/tasks/`.
3. Implement the corresponding task handler class in the plugin (`packages/plugin/src/task-handlers/`). 3. Implement the corresponding task handler class in the plugin (`penpot-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!
4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list. * Look at `PrintTextTaskHandler` as an example.
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
```

View File

@@ -1,3 +1,5 @@
# 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
@@ -63,8 +65,6 @@ 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,16 +128,3 @@ 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:

View File

@@ -1,4 +1,4 @@
import { Board, Bounds, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape, Text } from "@penpot/plugin-types"; import { Board, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape } from "@penpot/plugin-types";
export class PenpotUtils { export class PenpotUtils {
/** /**
@@ -189,24 +189,6 @@ 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.
@@ -216,13 +198,11 @@ 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 (
childBounds.x >= parentBounds.x && child.x >= parent.x &&
childBounds.y >= parentBounds.y && child.y >= parent.y &&
childBounds.x + childBounds.width <= parentBounds.x + parentBounds.width && child.x + child.width <= parent.x + parent.width &&
childBounds.y + childBounds.height <= parentBounds.y + parentBounds.height child.y + child.height <= parent.y + parent.height
); );
} }
@@ -318,16 +298,39 @@ 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 base64ToByteArray(base64: string): Uint8Array { public static atob(base64: string): Uint8Array {
const binary = atob(base64); const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const bytes = new Uint8Array(binary.length); const lookup = new Uint8Array(256);
for (let i = 0; i < binary.length; i++) { for (let i = 0; i < chars.length; i++) {
bytes[i] = binary.charCodeAt(i); lookup[chars.charCodeAt(i)] = 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;
} }
@@ -357,7 +360,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.base64ToByteArray(base64); const bytes = PenpotUtils.atob(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);

View File

@@ -195,39 +195,10 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
const context = this.context; const context = this.context;
const code = task.params.code; const code = task.params.code;
// set the penpot.flags.naturalChildOrdering to true during code execution. let result: any = await (async (ctx) => {
// 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);

View File

@@ -11,7 +11,7 @@ Penpot:
open: ( open: (
name: string, name: string,
url: string, url: string,
options?: { width: number; height: number; hidden: boolean }, options?: { width: number; height: number },
) => 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; hidden: boolean }, options?: { width: number; height: number },
) => 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; hidden: boolean },) => void * open: (name: string, url: string, options?: { width: number; height: number }) => 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?: LayoutCellProperties; layoutCell?: LayoutChildProperties;
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?: LayoutCellProperties readonly layoutCell?: LayoutChildProperties
``` ```
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?: LayoutCellProperties; layoutCell?: LayoutChildProperties;
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?: LayoutCellProperties readonly layoutCell?: LayoutChildProperties
``` ```
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?: LayoutCellProperties; layoutCell?: LayoutChildProperties;
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?: LayoutCellProperties readonly layoutCell?: LayoutChildProperties
``` ```
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?: LayoutCellProperties; layoutCell?: LayoutChildProperties;
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?: LayoutCellProperties readonly layoutCell?: LayoutChildProperties
``` ```
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?: LayoutCellProperties; layoutCell?: LayoutChildProperties;
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?: LayoutCellProperties readonly layoutCell?: LayoutChildProperties
``` ```
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?: LayoutCellProperties; layoutCell?: LayoutChildProperties;
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?: LayoutCellProperties readonly layoutCell?: LayoutChildProperties
``` ```
Layout properties for cells in a grid layout. Layout properties for cells in a grid layout.
@@ -10444,8 +10444,6 @@ 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: |-
@@ -12988,7 +12986,7 @@ Path:
rotation: number; rotation: number;
strokes: Stroke[]; strokes: Stroke[];
layoutChild?: LayoutChildProperties; layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties; layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void; setParentIndex(index: number): void;
tokens: { tokens: {
width: string; width: string;
@@ -13341,7 +13339,7 @@ Path:
Layout properties for children of the shape. Layout properties for children of the shape.
layoutCell: |- layoutCell: |-
``` ```
readonly layoutCell?: LayoutCellProperties readonly layoutCell?: LayoutChildProperties
``` ```
Layout properties for cells in a grid layout. Layout properties for cells in a grid layout.
@@ -14315,7 +14313,7 @@ Rectangle:
rotation: number; rotation: number;
strokes: Stroke[]; strokes: Stroke[];
layoutChild?: LayoutChildProperties; layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties; layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void; setParentIndex(index: number): void;
tokens: { tokens: {
width: string; width: string;
@@ -14646,7 +14644,7 @@ Rectangle:
Layout properties for children of the shape. Layout properties for children of the shape.
layoutCell: |- layoutCell: |-
``` ```
readonly layoutCell?: LayoutCellProperties readonly layoutCell?: LayoutChildProperties
``` ```
Layout properties for cells in a grid layout. Layout properties for cells in a grid layout.
@@ -15351,7 +15349,7 @@ ShapeBase:
| "mixed"; | "mixed";
strokes: Stroke[]; strokes: Stroke[];
layoutChild?: LayoutChildProperties; layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties; layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void; setParentIndex(index: number): void;
tokens: { tokens: {
width: string; width: string;
@@ -15681,7 +15679,7 @@ ShapeBase:
Layout properties for children of the shape. Layout properties for children of the shape.
layoutCell: |- layoutCell: |-
``` ```
readonly layoutCell?: LayoutCellProperties readonly layoutCell?: LayoutChildProperties
``` ```
Layout properties for cells in a grid layout. Layout properties for cells in a grid layout.
@@ -16275,7 +16273,7 @@ Stroke:
strokeColorRefFile?: string; strokeColorRefFile?: string;
strokeColorRefId?: string; strokeColorRefId?: string;
strokeOpacity?: number; strokeOpacity?: number;
strokeStyle?: "none" | "svg" | "mixed" | "solid" | "dotted" | "dashed"; strokeStyle?: "svg" | "none" | "mixed" | "solid" | "dotted" | "dashed";
strokeWidth?: number; strokeWidth?: number;
strokeAlignment?: "center" | "inner" | "outer"; strokeAlignment?: "center" | "inner" | "outer";
strokeCapStart?: StrokeCap; strokeCapStart?: StrokeCap;
@@ -16314,7 +16312,7 @@ Stroke:
Defaults to 1 if omitted. Defaults to 1 if omitted.
strokeStyle: |- strokeStyle: |-
``` ```
strokeStyle?: "none" | "svg" | "mixed" | "solid" | "dotted" | "dashed" strokeStyle?: "svg" | "none" | "mixed" | "solid" | "dotted" | "dashed"
``` ```
The optional style of the stroke. The optional style of the stroke.
@@ -16417,7 +16415,7 @@ SvgRaw:
| "mixed"; | "mixed";
strokes: Stroke[]; strokes: Stroke[];
layoutChild?: LayoutChildProperties; layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties; layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void; setParentIndex(index: number): void;
tokens: { tokens: {
width: string; width: string;
@@ -16741,7 +16739,7 @@ SvgRaw:
Layout properties for children of the shape. Layout properties for children of the shape.
layoutCell: |- layoutCell: |-
``` ```
readonly layoutCell?: LayoutCellProperties readonly layoutCell?: LayoutChildProperties
``` ```
Layout properties for cells in a grid layout. Layout properties for cells in a grid layout.
@@ -17336,7 +17334,7 @@ Text:
| "mixed"; | "mixed";
strokes: Stroke[]; strokes: Stroke[];
layoutChild?: LayoutChildProperties; layoutChild?: LayoutChildProperties;
layoutCell?: LayoutCellProperties; layoutCell?: LayoutChildProperties;
setParentIndex(index: number): void; setParentIndex(index: number): void;
tokens: { tokens: {
width: string; width: string;
@@ -17423,7 +17421,6 @@ 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;
} }
@@ -17678,7 +17675,7 @@ Text:
Layout properties for children of the shape. Layout properties for children of the shape.
layoutCell: |- layoutCell: |-
``` ```
readonly layoutCell?: LayoutCellProperties readonly layoutCell?: LayoutChildProperties
``` ```
Layout properties for cells in a grid layout. Layout properties for cells in a grid layout.
@@ -17838,13 +17835,6 @@ 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: |-
``` ```

View File

@@ -39,28 +39,20 @@ 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 READ-ONLY (members: x, y, width, height). To modify the bounding box, change `x`, `y` or apply `resize()`. * `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds.
**Other Writable Properties**: **Other Writable Properties**:
* `name` - Shape name * `name` - Shape name
* `fills: Fill[]`, `strokes: Stroke[]`, `shadows: Shadow[]` - Styling properties * `fills`, `strokes` - Styling properties
- Setting fills: `shape.fills = [{ fillColor: "#FF0000", fillOpacity: 1 }]`; no fill (transparent): `shape.fills = []`; 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.
- Colors: Use hex strings with caps only (e.g. '#FF5533') `shape.fills = [{ fillColor: "#FF0000", fillOpacity: 1 }]` to set a single red fill.
- 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! * `rotation`, `opacity`, `blocked`, `hidden`, `visible`
* `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).
@@ -73,7 +65,9 @@ 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
* To add children to a parent shape (e.g. a `Board`): `parent.appendChild(shape)` or `parent.insertChild(index, shape)` * CRITICAL: To add children to a parent shape (e.g. a `Board`):
- 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)
@@ -105,11 +99,17 @@ 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, child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true); - When a board has flex layout,
- child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
appending or inserting children automatically positions them according to the layout rules. 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)`. So call it in the order of visual appearance. they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front
To insert at a specific index, use `board.insertChild(index, shape)`. 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)`, 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 a `Text` element is given by the `characters` property. The rendered content of `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; use `textBounds` for the actual bounding box of the rendered text. it only changes the formal bounding box; if the text does not fit it, it will overflow.
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! `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!
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,76 +228,31 @@ 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 a component to a library: Adding assets to a library:
``` * const newColor: LibraryColor = penpot.library.local.createColor();
const shapes: Shape[] = [shape1, shape2]; // shapes to include newColor.name = 'Brand Primary';
newColor.color = '#0066FF';
* const newTypo: LibraryTypography = penpot.library.local.createTypography();
newTypo.name = 'Heading Large';
// Set typography properties...
* const shapes: Shape[] = [shape1, shape2]; // shapes to include
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes); const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
newComponent.name = 'My Button'; 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.
@@ -321,7 +276,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) * `resolvedValue` - Computed final value (follows references) - currently NOT working, do not use!
* `type: TokenType` * `type: TokenType`
Discovering tokens: Discovering tokens:
@@ -337,19 +292,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", "strokeColor" - TokenColorProps: "fill", "stroke-color"
- TokenDimensionProps: "x", "y", "strokeWidth" - TokenDimensionProps: "x", "y", "stroke-width"
- TokenFontFamiliesProps: "fontFamilies" - TokenFontFamiliesProps: "font-families"
- TokenFontSizesProps: "fontSize" - TokenFontSizesProps: "font-size"
- TokenFontWeightProps: "fontWeight" - TokenFontWeightProps: "font-weight"
- TokenLetterSpacingProps: "letterSpacing" - TokenLetterSpacingProps: "letter-spacing"
- TokenNumberProps: "rotation" - TokenNumberProps: "rotation", "line-height"
- TokenOpacityProps: "opacity" - TokenOpacityProps: "opacity"
- TokenSizingProps: "width", "height", "layoutItemMinW", "layoutItemMaxW", "layoutItemMinH", "layoutItemMaxH" - TokenSizingProps: "width", "height", "layout-item-min-w", "layout-item-max-w", "layout-item-min-h", "layout-item-max-h"
- TokenSpacingProps: "rowGap", "columnGap", "p1", "p2", "p3", "p4", "m1", "m2", "m3", "m4" - TokenSpacingProps: "row-gap", "column-gap", "p1", "p2", "p3", "p4", "m1", "m2", "m3", "m4"
- TokenBorderWidthProps: "strokeWidth" - TokenBorderWidthProps: "stroke-width"
- TokenTextCaseProps: "textCase" - TokenTextCaseProps: "text-case"
- TokenTextDecorationProps: "textDecoration" - TokenTextDecorationProps: "text-decoration"
- 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)

View File

@@ -21,56 +21,30 @@ 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 tools: ToolInfo[]; private readonly server: McpServer;
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 streamableTransports: Record<string, StreamableSession> = {}; private readonly transports = {
private readonly sseTransports: Record<string, { transport: SSEServerTransport; userToken?: string }> = {}; streamable: {} as Record<string, StreamableHTTPServerTransport>,
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
@@ -82,15 +56,21 @@ export class PenpotMcpServer {
this.configLoader = new ConfigurationLoader(process.cwd()); this.configLoader = new ConfigurationLoader(process.cwd());
this.apiDocs = new ApiDocs(); this.apiDocs = new ApiDocs();
// prepare initial instructions this.server = new McpServer(
let instructions = this.configLoader.getInitialInstructions(); {
instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", ")); name: "penpot-mcp-server",
this.initialInstructions = instructions; version: "1.0.0",
},
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();
} }
/** /**
@@ -125,7 +105,9 @@ export class PenpotMcpServer {
} }
public getInitialInstructions(): string { public getInitialInstructions(): string {
return this.initialInstructions; let instructions = this.configLoader.getInitialInstructions();
instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", "));
return instructions;
} }
/** /**
@@ -137,134 +119,88 @@ export class PenpotMcpServer {
return this.sessionContext.getStore(); return this.sessionContext.getStore();
} }
private initTools(): ToolInfo[] { private registerTools(): void {
// 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), new ExportShapeTool(this), // tool adapts to file system access internally
]; ];
if (this.isFileSystemAccessEnabled()) { if (this.isFileSystemAccessEnabled()) {
toolInstances.push(new ImportImageTool(this)); toolInstances.push(new ImportImageTool(this));
} }
return toolInstances.map((instance) => { for (const tool of toolInstances) {
this.logger.info(`Registering tool: ${instance.getToolName()}`); const toolName = tool.getToolName();
return new ToolInfo(instance, instance.getToolName(), { this.tools.set(toolName, tool);
description: instance.getToolDescription(),
inputSchema: instance.getInputSchema(),
});
});
}
/** // Register each tool with McpServer
* Creates a fresh {@link McpServer} instance with all tools registered. this.logger.info(`Registering tool: ${toolName}`);
*/ this.server.registerTool(
private createMcpServer(): McpServer { toolName,
const server = new McpServer( {
{ name: "penpot", version: "1.0.0" }, description: tool.getToolDescription(),
{ instructions: this.getInitialInstructions() } inputSchema: tool.getInputSchema(),
},
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;
// obtain transport and user token for the session, either from an existing session or by creating a new one if (sessionId && this.transports.streamable[sessionId]) {
if (sessionId && this.streamableTransports[sessionId]) { transport = this.transports.streamable[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) => { onsessioninitialized: (id: string) => {
this.streamableTransports[id] = new StreamableSession(transport, userToken, Date.now()); this.transports.streamable[id] = transport;
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) {
this.logger.info(`Closing session with id=${transport.sessionId} for userToken=${userToken}`); delete this.transports.streamable[transport.sessionId];
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.sseTransports[transport.sessionId] = { transport, userToken }; this.transports.sse[transport.sessionId] = { transport, userToken };
const server = this.createMcpServer();
await server.connect(transport);
res.on("close", () => { res.on("close", () => {
delete this.sseTransports[transport.sessionId]; delete this.transports.sse[transport.sessionId];
server.close();
}); });
await this.server.connect(transport);
}); });
}); });
@@ -273,7 +209,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.sseTransports[sessionId]; const session = this.transports.sse[sessionId];
if (session) { if (session) {
await this.sessionContext.run({ userToken: session.userToken }, async () => { await this.sessionContext.run({ userToken: session.userToken }, async () => {
@@ -300,9 +236,8 @@ 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 and session timeout checker // start the REPL server
await this.replServer.start(); await this.replServer.start();
this.startSessionTimeoutChecker();
resolve(); resolve();
}); });
@@ -316,7 +251,6 @@ 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");
} }

View File

@@ -22,9 +22,6 @@ 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
@@ -37,21 +34,17 @@ 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("Tool execution #%d starting: %s", executionId, this.getToolName()); this.logger.info("Executing tool: %s; arguments: %s", this.getToolName(), this.formatArgs(argsInstance));
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 #%d complete: %s", executionId, this.getToolName()); this.logger.info("Tool execution completed: %s", this.getToolName());
return result; return result;
} catch (error) { } catch (error) {
this.logger.error("Tool execution #%d failed: %s; error: %s", executionId, this.getToolName(), error); this.logger.error(error);
return new TextResponse(`Tool execution failed: ${String(error)}`); return new TextResponse(`Tool execution failed: ${String(error)}`);
} }
} }

View File

@@ -41,13 +41,8 @@ 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() {
// Use a neutral layer (no extra paint) so opacity and filters let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
// 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) => {
@@ -86,10 +81,7 @@ 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() {
// Use a neutral layer (no extra paint) so opacity and filters let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
// 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();

View File

@@ -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.offset; let char_pos = cursor.char_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.offset start.char_offset
} else { } else {
0 0
}; };
let range_end = if para_idx == end.paragraph { let range_end = if para_idx == end.paragraph {
end.offset end.char_offset
} else { } else {
para_char_count para_char_count
}; };

View File

@@ -11,7 +11,6 @@ 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,
@@ -113,51 +112,31 @@ impl TextContentSize {
} }
} }
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Copy, Clone)]
pub struct TextPositionWithAffinity { pub struct TextPositionWithAffinity {
#[allow(dead_code)] #[allow(dead_code)]
pub position_with_affinity: PositionWithAffinity, pub position_with_affinity: PositionWithAffinity,
pub paragraph: usize, pub paragraph: i32,
pub offset: usize, #[allow(dead_code)]
} pub span: i32,
#[allow(dead_code)]
impl PartialEq for TextPositionWithAffinity { pub span_relative_offset: i32,
fn eq(&self, other: &Self) -> bool { pub offset: i32,
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: usize, paragraph: i32,
offset: usize, span: i32,
span_relative_offset: i32,
offset: i32,
) -> Self { ) -> Self {
Self { Self {
position_with_affinity, position_with_affinity,
paragraph, paragraph,
offset, span,
} 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,
} }
} }
@@ -454,11 +433,10 @@ 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();
// IMPORTANT! I'm keeping this because I think it should be better to have the span index let mut paragraph_index: i32 = -1;
// cached the same way we keep the paragraph index. let mut span_index: i32 = -1;
#[allow(dead_code)] for layout_paragraph in layout_paragraphs {
let mut _span_index: usize = 0; paragraph_index += 1;
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();
@@ -475,22 +453,20 @@ 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) { if let Some(paragraph) = self.paragraphs().get(paragraph_index as usize) {
// 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: usize = 0; let mut computed_position = 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;
@@ -499,26 +475,27 @@ 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 as usize - start_position; position_with_affinity.position - start_position as i32;
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,
position_with_affinity.position as usize, span_index,
span_offset,
position_with_affinity.position,
)); ));
} }
} }
@@ -539,7 +516,9 @@ 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,
)); ));
} }

View File

@@ -7,10 +7,34 @@ 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: TextPositionWithAffinity, pub anchor: TextCursor,
pub focus: TextPositionWithAffinity, pub focus: TextCursor,
} }
impl TextSelection { impl TextSelection {
@@ -18,10 +42,10 @@ impl TextSelection {
Self::default() Self::default()
} }
pub fn from_position_with_affinity(position: TextPositionWithAffinity) -> Self { pub fn from_cursor(cursor: TextCursor) -> Self {
Self { Self {
anchor: position, anchor: cursor,
focus: position, focus: cursor,
} }
} }
@@ -33,12 +57,12 @@ impl TextSelection {
!self.is_collapsed() !self.is_collapsed()
} }
pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) { pub fn set_caret(&mut self, cursor: TextCursor) {
self.anchor = cursor; self.anchor = cursor;
self.focus = cursor; self.focus = cursor;
} }
pub fn extend_to(&mut self, cursor: TextPositionWithAffinity) { pub fn extend_to(&mut self, cursor: TextCursor) {
self.focus = cursor; self.focus = cursor;
} }
@@ -50,24 +74,24 @@ impl TextSelection {
self.focus = self.anchor; self.focus = self.anchor;
} }
pub fn start(&self) -> TextPositionWithAffinity { pub fn start(&self) -> TextCursor {
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.offset <= self.focus.offset { } else if self.anchor.char_offset <= self.focus.char_offset {
self.anchor self.anchor
} else { } else {
self.focus self.focus
} }
} }
pub fn end(&self) -> TextPositionWithAffinity { pub fn end(&self) -> TextCursor {
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.offset >= self.focus.offset { } else if self.anchor.char_offset >= self.focus.char_offset {
self.anchor self.anchor
} else { } else {
self.focus self.focus
@@ -78,7 +102,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 TextEditorEvent { pub enum EditorEvent {
None = 0, None = 0,
ContentChanged = 1, ContentChanged = 1,
SelectionChanged = 2, SelectionChanged = 2,
@@ -107,7 +131,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<TextEditorEvent>, pending_events: Vec<EditorEvent>,
} }
impl TextEditorState { impl TextEditorState {
@@ -165,44 +189,56 @@ 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::empty()); self.set_caret_from_position(TextPositionWithAffinity::new(
let num_paragraphs = content.paragraphs().len() - 1; PositionWithAffinity {
position: 0,
affinity: Affinity::Downstream,
},
0,
0,
0,
0,
));
let num_paragraphs = (content.paragraphs().len() - 1) as i32;
let Some(last_paragraph) = content.paragraphs().last() else { let Some(last_paragraph) = content.paragraphs().last() else {
return false; return false;
}; };
#[allow(dead_code)] let num_spans = (last_paragraph.children().len() - 1) as i32;
let _num_spans = last_paragraph.children().len() - 1; let Some(last_text_span) = last_paragraph.children().last() else {
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,
offset, num_spans,
last_text_span.text.len() as i32,
offset as i32,
)); ));
self.reset_blink(); self.reset_blink();
self.push_event(crate::state::TextEditorEvent::SelectionChanged); self.push_event(crate::state::EditorEvent::SelectionChanged);
true true
} }
pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) {
self.selection.set_caret(*position); let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
self.selection.set_caret(cursor);
self.reset_blink(); self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged); self.push_event(EditorEvent::SelectionChanged);
} }
pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) { pub fn extend_selection_from_position(&mut self, position: TextPositionWithAffinity) {
self.selection.extend_to(*position); let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
self.selection.extend_to(cursor);
self.reset_blink(); self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged); self.push_event(EditorEvent::SelectionChanged);
} }
pub fn update_blink(&mut self, timestamp_ms: f64) { pub fn update_blink(&mut self, timestamp_ms: f64) {
@@ -228,17 +264,41 @@ impl TextEditorState {
self.last_blink_time = 0.0; self.last_blink_time = 0.0;
} }
pub fn push_event(&mut self, event: TextEditorEvent) { pub fn push_event(&mut self, event: EditorEvent) {
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) -> TextEditorEvent { pub fn poll_event(&mut self) -> EditorEvent {
self.pending_events.pop().unwrap_or(TextEditorEvent::None) self.pending_events.pop().unwrap_or(EditorEvent::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
}
} }

View File

@@ -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, TextPositionWithAffinity, Type, VerticalAlign}; use crate::shapes::{Paragraph, Shape, TextContent, Type, VerticalAlign};
use crate::state::TextSelection; use crate::state::{TextCursor, 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,8 +276,7 @@ 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 = let new_cursor = TextCursor::new(cursor.paragraph, new_offset);
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);
} }
@@ -287,10 +286,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::TextEditorEvent::ContentChanged); .push_event(crate::state::EditorEvent::ContentChanged);
state state
.text_editor_state .text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout); .push_event(crate::state::EditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id); state.render_state.mark_touched(shape_id);
}); });
@@ -337,10 +336,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::TextEditorEvent::ContentChanged); .push_event(crate::state::EditorEvent::ContentChanged);
state state
.text_editor_state .text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout); .push_event(crate::state::EditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id); state.render_state.mark_touched(shape_id);
}); });
@@ -385,10 +384,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::TextEditorEvent::ContentChanged); .push_event(crate::state::EditorEvent::ContentChanged);
state state
.text_editor_state .text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout); .push_event(crate::state::EditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id); state.render_state.mark_touched(shape_id);
}); });
@@ -424,8 +423,7 @@ 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 = let new_cursor = TextCursor::new(cursor.paragraph + 1, 0);
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);
} }
@@ -435,10 +433,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::TextEditorEvent::ContentChanged); .push_event(crate::state::EditorEvent::ContentChanged);
state state
.text_editor_state .text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout); .push_event(crate::state::EditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id); state.render_state.mark_touched(shape_id);
}); });
@@ -496,7 +494,7 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel
state.text_editor_state.reset_blink(); state.text_editor_state.reset_blink();
state state
.text_editor_state .text_editor_state
.push_event(crate::state::TextEditorEvent::SelectionChanged); .push_event(crate::state::EditorEvent::SelectionChanged);
}); });
} }
@@ -713,12 +711,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.offset start.char_offset
} else { } else {
0 0
}; };
let range_end = if para_idx == end.paragraph { let range_end = if para_idx == end.paragraph {
end.offset end.char_offset
} else { } else {
para_char_count para_char_count
}; };
@@ -766,9 +764,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.offset as u32; *buffer_ptr.add(1) = sel.anchor.char_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.offset as u32; *buffer_ptr.add(3) = sel.focus.char_offset as u32;
} }
1 1
}) })
@@ -778,11 +776,7 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
// HELPERS: Cursor & Selection // HELPERS: Cursor & Selection
// ============================================================================ // ============================================================================
fn get_cursor_rect( fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shape) -> Option<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;
@@ -800,7 +794,7 @@ fn get_cursor_rect(
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.offset; let char_pos = cursor.char_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(
@@ -875,13 +869,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.offset start.char_offset
} else { } else {
0 0
}; };
let range_end = if para_idx == end.paragraph { let range_end = if para_idx == end.paragraph {
end.offset end.char_offset
} else { } else {
para_char_count para_char_count
}; };
@@ -920,49 +914,40 @@ 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( fn clamp_cursor(cursor: TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
position: TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
if paragraphs.is_empty() { if paragraphs.is_empty() {
return TextPositionWithAffinity::new_without_affinity(0, 0); return TextCursor::new(0, 0);
} }
let para_idx = position.paragraph.min(paragraphs.len() - 1); let para_idx = cursor.paragraph.min(paragraphs.len() - 1);
let para_len = paragraph_char_count(&paragraphs[para_idx]); let para_len = paragraph_char_count(&paragraphs[para_idx]);
let char_offset = position.offset.min(para_len); let char_offset = cursor.char_offset.min(para_len);
TextPositionWithAffinity::new_without_affinity(para_idx, char_offset) TextCursor::new(para_idx, char_offset)
} }
/// Move cursor left by one character. /// Move cursor left by one character.
fn move_cursor_backward( fn move_cursor_backward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
cursor: &TextPositionWithAffinity, if cursor.char_offset > 0 {
paragraphs: &[Paragraph], TextCursor::new(cursor.paragraph, cursor.char_offset - 1)
) -> 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(&paragraphs[prev_para]); let char_count = paragraph_char_count(&paragraphs[prev_para]);
TextPositionWithAffinity::new_without_affinity(prev_para, char_count) TextCursor::new(prev_para, char_count)
} else { } else {
*cursor *cursor
} }
} }
/// Move cursor right by one character. /// Move cursor right by one character.
fn move_cursor_forward( fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
let para = &paragraphs[cursor.paragraph]; let para = &paragraphs[cursor.paragraph];
let char_count = paragraph_char_count(para); let char_count = paragraph_char_count(para);
if cursor.offset < char_count { if cursor.char_offset < char_count {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset + 1) TextCursor::new(cursor.paragraph, cursor.char_offset + 1)
} else if cursor.paragraph < paragraphs.len() - 1 { } else if cursor.paragraph < paragraphs.len() - 1 {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0) TextCursor::new(cursor.paragraph + 1, 0)
} else { } else {
*cursor *cursor
} }
@@ -970,58 +955,52 @@ fn move_cursor_forward(
/// Move cursor up by one line. /// Move cursor up by one line.
fn move_cursor_up( fn move_cursor_up(
cursor: &TextPositionWithAffinity, cursor: &TextCursor,
paragraphs: &[Paragraph], paragraphs: &[Paragraph],
_text_content: &TextContent, _text_content: &TextContent,
_shape: &Shape, _shape: &Shape,
) -> TextPositionWithAffinity { ) -> TextCursor {
// 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(&paragraphs[prev_para]); let char_count = paragraph_char_count(&paragraphs[prev_para]);
let new_offset = cursor.offset.min(char_count); let new_offset = cursor.char_offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(prev_para, new_offset) TextCursor::new(prev_para, new_offset)
} else { } else {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) TextCursor::new(cursor.paragraph, 0)
} }
} }
/// Move cursor down by one line. /// Move cursor down by one line.
fn move_cursor_down( fn move_cursor_down(
cursor: &TextPositionWithAffinity, cursor: &TextCursor,
paragraphs: &[Paragraph], paragraphs: &[Paragraph],
_text_content: &TextContent, _text_content: &TextContent,
_shape: &Shape, _shape: &Shape,
) -> TextPositionWithAffinity { ) -> TextCursor {
// 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(&paragraphs[next_para]); let char_count = paragraph_char_count(&paragraphs[next_para]);
let new_offset = cursor.offset.min(char_count); let new_offset = cursor.char_offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(next_para, new_offset) TextCursor::new(next_para, new_offset)
} else { } else {
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]); let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) TextCursor::new(cursor.paragraph, char_count)
} }
} }
/// Move cursor to start of current line. /// Move cursor to start of current line.
fn move_cursor_line_start( fn move_cursor_line_start(cursor: &TextCursor, _paragraphs: &[Paragraph]) -> TextCursor {
cursor: &TextPositionWithAffinity,
_paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
// TODO: Implement proper line-start using line metrics // TODO: Implement proper line-start using line metrics
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) TextCursor::new(cursor.paragraph, 0)
} }
/// Move cursor to end of current line. /// Move cursor to end of current line.
fn move_cursor_line_end( fn move_cursor_line_end(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
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(&paragraphs[cursor.paragraph]); let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) TextCursor::new(cursor.paragraph, char_count)
} }
// ============================================================================ // ============================================================================
@@ -1049,7 +1028,7 @@ fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, u
/// Insert text at a cursor position. Returns the new character offset after insertion. /// 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: &TextPositionWithAffinity, cursor: &TextCursor,
text: &str, text: &str,
) -> Option<usize> { ) -> Option<usize> {
let paragraphs = text_content.paragraphs_mut(); let paragraphs = text_content.paragraphs_mut();
@@ -1069,7 +1048,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.offset)?; let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.char_offset)?;
let children = para.children_mut(); let children = para.children_mut();
let span = &mut children[span_idx]; let span = &mut children[span_idx];
@@ -1084,7 +1063,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.offset + text.chars().count()) Some(cursor.char_offset + text.chars().count())
} }
/// Delete a range of text specified by a selection. /// Delete a range of text specified by a selection.
@@ -1098,16 +1077,20 @@ fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelect
} }
if start.paragraph == end.paragraph { if start.paragraph == end.paragraph {
delete_range_in_paragraph(&mut paragraphs[start.paragraph], start.offset, end.offset); delete_range_in_paragraph(
&mut paragraphs[start.paragraph],
start.char_offset,
end.char_offset,
);
} else { } else {
let start_para_len = paragraph_char_count(&paragraphs[start.paragraph]); let start_para_len = paragraph_char_count(&paragraphs[start.paragraph]);
delete_range_in_paragraph( delete_range_in_paragraph(
&mut paragraphs[start.paragraph], &mut paragraphs[start.paragraph],
start.offset, start.char_offset,
start_para_len, start_para_len,
); );
delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset); delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.char_offset);
if end.paragraph < paragraphs.len() { if end.paragraph < paragraphs.len() {
let end_para_children: Vec<_> = let end_para_children: Vec<_> =
@@ -1206,19 +1189,13 @@ fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offs
} }
/// Delete the character before the cursor. Returns the new cursor position. /// Delete the character before the cursor. Returns the new cursor position.
fn delete_char_before( fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Option<TextCursor> {
text_content: &mut TextContent, if cursor.char_offset > 0 {
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.offset - 1; let delete_pos = cursor.char_offset - 1;
delete_range_in_paragraph(para, delete_pos, cursor.offset); delete_range_in_paragraph(para, delete_pos, cursor.char_offset);
Some(TextPositionWithAffinity::new_without_affinity( Some(TextCursor::new(cursor.paragraph, delete_pos))
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();
@@ -1234,17 +1211,14 @@ fn delete_char_before(
paragraphs.remove(cursor.paragraph); paragraphs.remove(cursor.paragraph);
Some(TextPositionWithAffinity::new_without_affinity( Some(TextCursor::new(prev_para_idx, prev_para_len))
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: &TextPositionWithAffinity) { fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
let paragraphs = text_content.paragraphs_mut(); let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() { if cursor.paragraph >= paragraphs.len() {
return; return;
@@ -1252,9 +1226,9 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAf
let para_len = paragraph_char_count(&paragraphs[cursor.paragraph]); let para_len = paragraph_char_count(&paragraphs[cursor.paragraph]);
if cursor.offset < para_len { if cursor.char_offset < para_len {
let para = &mut paragraphs[cursor.paragraph]; let para = &mut paragraphs[cursor.paragraph];
delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1); delete_range_in_paragraph(para, cursor.char_offset, cursor.char_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();
@@ -1267,10 +1241,7 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAf
} }
/// 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( fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor) -> bool {
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;
@@ -1278,7 +1249,7 @@ fn split_paragraph_at_cursor(
let para = &paragraphs[cursor.paragraph]; let para = &paragraphs[cursor.paragraph];
let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else { let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.char_offset) else {
return false; return false;
}; };