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
55 changed files with 870 additions and 1082 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

@@ -20,7 +20,6 @@
### :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)

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,16 +466,17 @@
::setup/shared-keys ::setup/shared-keys
{::setup/props (ig/ref ::setup/props) {::setup/props (ig/ref ::setup/props)
:nitrate (cf/get :nitrate-shared-key) :nexus (cf/get :nexus-shared-key)
:exporter (cf/get :exporter-shared-key)} :nitrate (cf/get :nitrate-shared-key)
:exporter (cf/get :exporter-shared-key)}
::setup/clock ::setup/clock
{} {}
: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,45 +82,37 @@
(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 "
"PENPOT_SECRET_KEY environment variable"))) "all sessions on each restart, it is highly "
"recommended setting up the "
"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)
(assoc :secret-key secret) (assoc :secret-key secret)
(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 :id (name id) :hint "key is disabled because empty string found")
(l/wrn :hint "exporter key is disabled because empty string found") keys)
nil) (do
(do (l/inf :id (name id) :hint "key initialized" :key (d/obfuscate-string key))
(l/inf :hint "exporter key initialized" :key (d/obfuscate-string key)) (assoc keys id key)))))
key))) {}
[:exporter
:nitrate
:nexus])))
:nitrate (sm/register! ::props [:map-of :keyword ::sm/any])
(let [key (or (get cfg :nitrate) (sm/register! ::shared-keys [:map-of :keyword ::sm/text])
(-> (keys/derive secret :salt "nitrate")
(bc/bytes->b64-str true)))]
(if (or (str/empty? key)
(str/blank? key))
(do
(l/wrn :hint "nitrate key is disabled because empty string found")
nil)
(do
(l/inf :hint "nitrate key initialized" :key (d/obfuscate-string key))
key)))})))

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

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

@@ -383,26 +383,24 @@ test("User cut paste a component with path inside a variant", async ({
const variant = await findVariant(workspacePage, 0); const variant = await findVariant(workspacePage, 0);
// Create a component //Create a component
await workspacePage.ellipseShapeButton.click(); await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20); await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse"); await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("ControlOrMeta+k"); await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.waitForTimeout(3000);
// Rename the component //Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick(); await workspacePage.layers.getByText("Ellipse").dblclick();
await workspacePage.page await workspacePage.page
.getByTestId("layer-item") .getByTestId("layer-item")
.getByRole("textbox") .getByRole("textbox")
.pressSequentially("button / hover"); .pressSequentially("button / hover");
await workspacePage.page.keyboard.press("Enter"); await workspacePage.page.keyboard.press("Enter");
await workspacePage.page.waitForTimeout(3000);
// Cut the component //Cut the component
await workspacePage.cut("keyboard"); await workspacePage.cut("keyboard");
// Paste the component inside the variant //Paste the component inside the variant
await variant.container.click(); await variant.container.click();
await workspacePage.paste("keyboard"); await workspacePage.paste("keyboard");
@@ -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

@@ -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,68 +620,61 @@
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 [attributes-to-remove
(let [edition (get-in state [:workspace-local :edition]) ;; Remove atomic typography tokens when applying composite and vice-verca
objects (dsh/lookup-page-objects state) (cond
text-editing? (and (some? edition) (ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
(= :text (:type (get objects edition))))] (ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
(when (and (empty? (get state :workspace-editor-state)) :else attributes-to-remove)]
(not text-editing?)) (when-let [tokens (some-> (dsh/lookup-file-data state)
(let [attributes-to-remove (get :tokens-lib)
;; Remove atomic typography tokens when applying composite and vice-verca (ctob/get-tokens-in-active-sets))]
(cond (->> (if (contains? cf/flags :tokenscript)
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys) (rx/of (ts/resolve-tokens tokens))
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys) (sd/resolve-tokens tokens))
:else attributes-to-remove)] (rx/mapcat
(when-let [tokens (some-> (dsh/lookup-file-data state) (fn [resolved-tokens]
(get :tokens-lib) (let [undo-id (js/Symbol)
(ctob/get-tokens-in-active-sets))] objects (dsh/lookup-page-objects state)
(->> (if (contains? cf/flags :tokenscript) selected-shapes (select-keys objects shape-ids)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [undo-id (js/Symbol)
objects (dsh/lookup-page-objects state)
selected-shapes (select-keys objects shape-ids)
shapes (->> selected-shapes shapes (->> selected-shapes
(filter (fn [[_ shape]] (filter (fn [[_ shape]]
(or (or
(and (ctsl/any-layout-immediate-child? objects shape) (and (ctsl/any-layout-immediate-child? objects shape)
(some ctt/spacing-margin-keys attributes)) (some ctt/spacing-margin-keys attributes))
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape)) (and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
(all-attrs-appliable-for-token? attributes (:type token))))))) (all-attrs-appliable-for-token? attributes (:type token)))))))
shape-ids (d/nilv (keys shapes) []) shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean) any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value]) resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript) resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value) (ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value) resolved-value)
tokenized-attributes (cfo/attributes-map attributes token) tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)] type (:type token)]
(rx/concat (rx/concat
(rx/of (rx/of
(st/emit! (ev/event {::ev/name "apply-tokens" (st/emit! (ev/event {::ev/name "apply-tokens"
:type type :type type
:applied-to attributes :applied-to attributes
:applied-to-variant any-variant?})) :applied-to-variant any-variant?}))
(dwu/start-undo-transaction undo-id) (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes shape-ids (fn [shape] (dwsh/update-shapes shape-ids (fn [shape]
(cond-> shape (cond-> shape
attributes-to-remove attributes-to-remove
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove)) (update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always :always
(update :applied-tokens merge tokenized-attributes))))) (update :applied-tokens merge tokenized-attributes)))))
(when on-update-shape (when on-update-shape
(let [res (on-update-shape resolved-value shape-ids attributes)] (let [res (on-update-shape resolved-value shape-ids attributes)]
;; Composed updates return observables and need to be executed differently ;; Composed updates return observables and need to be executed differently
(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)))))))))))))
(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

@@ -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

@@ -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,16 +458,14 @@
^boolean show-subscription-success-modal? ^boolean show-subscription-success-modal?
(st/emit! (st/emit!
(if (= params-subscription "subscribed-to-penpot-nitrate") (modal/show :subscription-success
(modal/show :nitrate-success {}) {:subscription-name (if (= params-subscription "subscribed-to-penpot-unlimited")
(modal/show :subscription-success (if (= success-modal-is-trial? "true")
{:subscription-name (if (= params-subscription "subscribed-to-penpot-unlimited") (tr "subscription.settings.unlimited-trial")
(if (= success-modal-is-trial? "true") (tr "subscription.settings.unlimited"))
(tr "subscription.settings.unlimited-trial") (if (= success-modal-is-trial? "true")
(tr "subscription.settings.unlimited")) (tr "subscription.settings.enterprise-trial")
(if (= success-modal-is-trial? "true") (tr "subscription.settings.enterprise")))})
(tr "subscription.settings.enterprise-trial")
(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,11 +48,12 @@
(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]}]
@@ -66,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)
@@ -75,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)]
[:* [:*
@@ -89,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
@@ -121,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])
@@ -137,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}
@@ -146,26 +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?}]]
(when (not ^boolean is-read-only) (when (not 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?
@@ -190,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)))))
@@ -294,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)))
@@ -335,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}))))))
@@ -352,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))]
@@ -365,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
@@ -413,114 +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
(mf/with-effect [is-selected selected] ref (mf/use-ref)
depth (+ depth 1)
component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item))
enable-drag (mf/use-fn #(reset! drag-disabled* false))
disable-drag (mf/use-fn #(reset! drag-disabled* true))
;; Lazy loading of child elements via IntersectionObserver
children-count* (mf/use-state 0)
children-count (deref children-count*)
lazy-ref (mf/use-ref nil)
observer-var (mf/use-var nil)
chunk-size 50]
(mf/with-effect [selected? selected]
(let [single? (= (count selected) 1) (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))
(some (fn [sel-id] (let [shapes-reversed (vec (reverse shapes-vec))]
(let [idx (.indexOf shapes sel-id)] (some (fn [sel-id]
(when (>= idx 0) idx))) (let [idx (.indexOf shapes-reversed sel-id)]
selected)) (when (>= idx 0) idx)))
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
@@ -533,28 +479,29 @@
:on-toggle-blocking toggle-blocking :on-toggle-blocking toggle-blocking
: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)]
{:item item (for [[index id] visible]
:rename-id rename-id (when-let [item (get objects id)]
:highlighted highlighted [:& layer-item
:selected selected {:item item
:index (unchecked-get item "__$__counter") :highlighted highlighted
:objects objects :selected selected
:key (dm/str (get item :id)) :index index
:is-sortable is-sortable :objects objects
:depth depth :key (dm/str id)
:parent-size parent-size :sortable? sortable?
:is-component-child is-component-tree}]) :depth depth
:parent-size parent-size
(when (< children-count (count shapes)) :component-child? component-tree?}])))
(when (< children-count (count (:shapes item)))
[: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 ref]}] variant-id variant-name variant-properties variant-error]} external-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
(d/nilv variant-error variant-name)
shape-name)
default-value shape-name (if variant-id
(mf/with-memo [variant-id variant-error variant-properties] (d/nilv variant-error variant-name)
(if variant-id shape-name)
(or variant-error (ctv/properties-map->formula variant-properties))
shape-name))
has-path? default-value (if variant-id
(str/includes? shape-name "/") (or variant-error (ctv/properties-map->formula variant-properties))
shape-name)
has-path? (str/includes? shape-name "/")
start-edit start-edit
(mf/use-fn (mf/use-fn
@@ -81,11 +85,10 @@
(when (kbd/enter? event) (accept-edit)) (when (kbd/enter? event) (accept-edit))
(when (kbd/esc? event) (cancel-edit)))) (when (kbd/esc? event) (cancel-edit))))
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)))
@@ -107,24 +110,21 @@
: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
:element-name true {:class (stl/css-case
:left-ellipsis has-path? :element-name true
:selected is-selected :left-ellipsis has-path?
:hidden is-hidden :selected is-selected
:type-comp type-comp :hidden is-hidden
:type-frame type-frame) :type-comp type-comp
:id (dm/str "layer-name-" shape-id) :type-frame type-frame)
:style {"--depth" depth "--parent-size" parent-size} :id (dm/str "layer-name-" shape-id)
:ref ref :style {"--depth" depth "--parent-size" parent-size}
:on-double-click start-edit} :ref ref
:on-double-click start-edit}
(if ^boolean (dbg/enabled? :show-ids) (if (dbg/enabled? :show-ids)
(dm/str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24)) (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)))]
(if (cfh/frame-shape? obj) (when-let [obj (get objects id)]
[:> frame-wrapper* (if (cfh/frame-shape? obj)
{:item obj [:& frame-wrapper
:rename-id rename-id {:item obj
: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 index
:index (unchecked-get obj "__$__counter") :objects objects
:objects objects :key id
:key (dm/str (get obj :id)) :sortable? true
:is-sortable true :filtered? filtered?
:is-filtered is-filtered :depth -1
:depth -1 :parent-size parent-size}])))]]))
:parent-size parent-size}]))]]))
(mf/defc 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)
root (get objects uuid/zero)] selected (hooks/use-equal-memo selected)
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,25 +519,25 @@
[: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
: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 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

@@ -75,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?)

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"

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"

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 ) const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`);
// TODO: Remove ts-ignore once Penpot types have been updated return fn(...Object.values(ctx));
let originalNaturalChildOrdering: any; })(context);
// @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} })();`);
return fn(...Object.values(ctx));
})(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,
appending or inserting children automatically positions them according to the layout rules. - child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
appending or inserting children automatically positions them according to the layout rules.
- CRITICAL: For dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
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';
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes); newColor.color = '#0066FF';
newComponent.name = 'My Button'; * 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);
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

@@ -23,6 +23,7 @@ export interface SessionContext {
export class PenpotMcpServer { export class PenpotMcpServer {
private readonly logger = createLogger("PenpotMcpServer"); private readonly logger = createLogger("PenpotMcpServer");
private readonly server: McpServer;
private readonly tools: Map<string, Tool<any>>; private readonly tools: Map<string, Tool<any>>;
public readonly configLoader: ConfigurationLoader; public readonly configLoader: ConfigurationLoader;
private app: any; private app: any;
@@ -35,7 +36,10 @@ export class PenpotMcpServer {
*/ */
private readonly sessionContext = new AsyncLocalStorage<SessionContext>(); private readonly sessionContext = new AsyncLocalStorage<SessionContext>();
private readonly sseTransports: Record<string, { transport: SSEServerTransport; userToken?: string }> = {}; private readonly transports = {
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;
@@ -52,11 +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();
this.server = new McpServer(
{
name: "penpot-mcp-server",
version: "1.0.0",
},
{
instructions: this.getInitialInstructions(),
}
);
this.tools = new Map<string, Tool<any>>(); 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.initTools(); this.registerTools();
} }
/** /**
@@ -105,44 +119,35 @@ export class PenpotMcpServer {
return this.sessionContext.getStore(); return this.sessionContext.getStore();
} }
private initTools(): void { 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));
} }
for (const tool of toolInstances) { for (const tool of toolInstances) {
this.logger.info(`Registering tool: ${tool.getToolName()}`); const toolName = tool.getToolName();
this.tools.set(tool.getToolName(), tool); this.tools.set(toolName, tool);
}
}
/** // 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-mcp-server", version: "1.0.0" },
{ instructions: this.getInitialInstructions() }
);
for (const tool of this.tools.values()) {
server.registerTool(
tool.getToolName(),
{ {
description: tool.getToolDescription(), description: tool.getToolDescription(),
inputSchema: tool.getInputSchema(), inputSchema: tool.getInputSchema(),
}, },
async (args) => tool.execute(args) async (args) => {
return tool.execute(args);
}
); );
} }
return server;
} }
private setupHttpEndpoints(): void { private setupHttpEndpoints(): void {
@@ -151,38 +156,51 @@ export class PenpotMcpServer {
*/ */
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; const userToken = req.query.userToken as string | undefined;
this.logger.info(`Received /mcp request with userToken: ${userToken}`);
await this.sessionContext.run({ userToken }, async () => { await this.sessionContext.run({ userToken }, async () => {
const transport = new StreamableHTTPServerTransport({ const { randomUUID } = await import("node:crypto");
sessionIdGenerator: undefined,
}); const sessionId = req.headers["mcp-session-id"] as string | undefined;
const server = this.createMcpServer(); let transport: StreamableHTTPServerTransport;
await server.connect(transport);
if (sessionId && this.transports.streamable[sessionId]) {
transport = this.transports.streamable[sessionId];
} else {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id: string) => {
this.transports.streamable[id] = transport;
},
});
transport.onclose = () => {
if (transport.sessionId) {
delete this.transports.streamable[transport.sessionId];
}
};
await this.server.connect(transport);
}
await transport.handleRequest(req, res, req.body); await transport.handleRequest(req, res, req.body);
res.on("close", () => {
transport.close();
server.close();
});
}); });
}); });
/** /**
* 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);
}); });
}); });
@@ -191,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 () => {

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;
}; };