mirror of
https://github.com/penpot/penpot.git
synced 2026-02-28 12:48:27 -05:00
Compare commits
1 Commits
develop
...
niwinz-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c0d29bb79 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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;"])))
|
||||||
|
|
||||||
|
|||||||
@@ -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)))})))
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)))))
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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)}])])]))
|
||||||
|
|||||||
@@ -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)} "*"])])))
|
||||||
|
|||||||
@@ -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}]])]))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 "ספק האימות לא מורשה לפרופיל הזה"
|
||||||
|
|||||||
@@ -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 "इस प्रोफाइल के लिए ऑथ प्रोवाइडर अनुमति नहीं है"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 "Поставщик аутентификации не разрешён для этого профиля"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 "认证提供者不允许访问此个人设定"
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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: |-
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(¶graphs[para_idx]);
|
let para_len = paragraph_char_count(¶graphs[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(¶graphs[prev_para]);
|
let char_count = paragraph_char_count(¶graphs[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 = ¶graphs[cursor.paragraph];
|
let para = ¶graphs[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(¶graphs[prev_para]);
|
let char_count = paragraph_char_count(¶graphs[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(¶graphs[next_para]);
|
let char_count = paragraph_char_count(¶graphs[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(¶graphs[cursor.paragraph]);
|
let char_count = paragraph_char_count(¶graphs[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(¶graphs[cursor.paragraph]);
|
let char_count = paragraph_char_count(¶graphs[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(¶graphs[start.paragraph]);
|
let start_para_len = paragraph_char_count(¶graphs[start.paragraph]);
|
||||||
delete_range_in_paragraph(
|
delete_range_in_paragraph(
|
||||||
&mut paragraphs[start.paragraph],
|
&mut paragraphs[start.paragraph],
|
||||||
start.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(¶graphs[cursor.paragraph]);
|
let para_len = paragraph_char_count(¶graphs[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 = ¶graphs[cursor.paragraph];
|
let para = ¶graphs[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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user