From 3a39676969a7c8222e239554db69fc06ade9e6c9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 16 Apr 2026 11:30:36 +0200 Subject: [PATCH 01/13] :rewind: Backport MCP from staging (part 1) --- CHANGES.md | 2 + backend/src/app/migrations.clj | 4 +- .../sql/0146-mod-access-token-table.sql | 2 + backend/src/app/rpc/commands/access_token.clj | 29 +- backend/src/app/rpc/commands/profile.clj | 1 + .../backend_tests/http_middleware_test.clj | 2 +- .../backend_tests/rpc_access_tokens_test.clj | 16 +- common/src/app/common/flags.cljc | 4 +- common/src/app/common/i18n.cljc | 7 + common/src/app/common/schema/messages.cljc | 105 +++ common/src/app/common/types/tokens_lib.cljc | 100 +-- docker/devenv/files/Caddyfile | 2 +- docker/devenv/files/bashrc | 3 + docker/devenv/files/nginx.conf | 6 + docker/images/Dockerfile.mcp | 3 +- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 4 +- frontend/scripts/build | 2 +- frontend/src/app/config.cljs | 7 + frontend/src/app/main/broadcast.cljs | 5 +- frontend/src/app/main/data/plugins.cljs | 61 +- frontend/src/app/main/data/profile.cljs | 1 - frontend/src/app/main/data/workspace.cljs | 292 ++++--- frontend/src/app/main/data/workspace/mcp.cljs | 292 +++++++ .../main/data/workspace/notifications.cljs | 1 + .../data/workspace/tokens/application.cljs | 182 ++-- .../src/app/main/data/workspace/variants.cljs | 4 +- frontend/src/app/main/errors.cljs | 96 +- frontend/src/app/main/refs.cljs | 3 + frontend/src/app/main/ui.cljs | 2 +- frontend/src/app/main/ui/confirm.cljs | 39 +- frontend/src/app/main/ui/confirm.scss | 32 +- frontend/src/app/main/ui/ds/_sizes.scss | 1 + .../src/app/main/ui/ds/controls/input.cljs | 11 +- frontend/src/app/main/ui/forms.cljs | 20 +- frontend/src/app/main/ui/routes.cljs | 2 +- frontend/src/app/main/ui/settings.cljs | 6 +- .../app/main/ui/settings/access_tokens.cljs | 291 ------- .../app/main/ui/settings/access_tokens.scss | 202 ----- .../app/main/ui/settings/integrations.cljs | 635 ++++++++++++++ .../app/main/ui/settings/integrations.scss | 239 +++++ .../src/app/main/ui/settings/sidebar.cljs | 17 +- .../app/main/ui/workspace/left_header.cljs | 18 +- .../src/app/main/ui/workspace/main_menu.cljs | 590 +++++++------ .../src/app/main/ui/workspace/main_menu.scss | 237 +++-- .../src/app/main/ui/workspace/sidebar.cljs | 12 +- .../tokens/management/context_menu.cljs | 2 +- frontend/src/app/plugins.cljs | 3 + frontend/src/app/plugins/api.cljs | 100 ++- frontend/src/app/plugins/comments.cljs | 26 +- frontend/src/app/plugins/file.cljs | 28 +- frontend/src/app/plugins/flags.cljs | 26 +- frontend/src/app/plugins/flex.cljs | 129 ++- frontend/src/app/plugins/fonts.cljs | 28 +- frontend/src/app/plugins/format.cljs | 7 + frontend/src/app/plugins/grid.cljs | 162 ++-- frontend/src/app/plugins/history.cljs | 6 +- frontend/src/app/plugins/library.cljs | 216 ++--- frontend/src/app/plugins/local_storage.cljs | 12 +- frontend/src/app/plugins/page.cljs | 70 +- frontend/src/app/plugins/public_utils.cljs | 4 +- frontend/src/app/plugins/register.cljs | 6 + frontend/src/app/plugins/ruler_guides.cljs | 10 +- frontend/src/app/plugins/shape.cljs | 324 +++---- frontend/src/app/plugins/text.cljs | 122 +-- frontend/src/app/plugins/tokens.cljs | 90 +- frontend/src/app/plugins/utils.cljs | 71 +- frontend/src/app/plugins/viewport.cljs | 8 +- frontend/src/app/util/forms.cljs | 96 +- frontend/src/debug.cljs | 6 + frontend/translations/en.po | 383 +++++--- frontend/translations/es.po | 374 +++++--- mcp/.gitignore | 2 + mcp/.serena/memories/project_overview.md | 67 +- mcp/.serena/project.yml | 48 +- mcp/README.md | 129 ++- mcp/bin/mcp-local.js | 31 + mcp/package.json | 16 +- mcp/packages/common/package.json | 2 +- mcp/packages/plugin/index.html | 81 +- mcp/packages/plugin/package.json | 3 +- mcp/packages/plugin/public/icon.jpg | Bin 0 -> 7632 bytes mcp/packages/plugin/public/manifest.json | 1 + mcp/packages/plugin/src/PenpotUtils.ts | 70 +- mcp/packages/plugin/src/index.d.ts | 21 + mcp/packages/plugin/src/main.ts | 159 +++- mcp/packages/plugin/src/plugin.ts | 69 +- mcp/packages/plugin/src/style.css | 188 +++- .../task-handlers/ExecuteCodeTaskHandler.ts | 42 +- mcp/packages/plugin/src/vite-env.d.ts | 1 + mcp/packages/plugin/vite.config.ts | 15 +- mcp/packages/plugin/vite.release.config.ts | 1 - mcp/packages/server/.gitignore | 1 + mcp/packages/server/data/api_types.yml | 818 ++++++++++-------- mcp/packages/server/data/base_instructions.md | 2 + .../server/data/initial_instructions.md | 227 +++-- mcp/packages/server/package.json | 6 +- mcp/packages/server/scripts/copy-resources.js | 5 + .../server/src/ConfigurationLoader.ts | 23 +- mcp/packages/server/src/PenpotMcpServer.ts | 217 +++-- mcp/packages/server/src/PluginBridge.ts | 15 + mcp/packages/server/src/Tool.ts | 13 +- .../server/src/tools/ExecuteCodeTool.ts | 5 +- .../server/src/tools/ExportShapeTool.ts | 8 +- .../server/src/tools/HighLevelOverviewTool.ts | 2 +- mcp/pnpm-lock.yaml | 3 + mcp/scripts/build | 4 +- mcp/scripts/pack | 11 + mcp/scripts/set-version | 51 ++ plugins/CHANGELOG.md | 6 + plugins/angular.json | 23 +- .../apps/colors-to-tokens-plugin/package.json | 4 +- .../src/app/app.config.ts | 4 +- .../colors-to-tokens-plugin/src/index.html | 1 - .../colors-to-tokens-plugin/src/manifest.json | 8 + .../colors-to-tokens-plugin/wrangler.toml | 2 +- plugins/apps/contrast-plugin/package.json | 4 +- .../contrast-plugin/src/app/app.config.ts | 4 +- plugins/apps/contrast-plugin/src/index.html | 1 - .../apps/contrast-plugin/src/manifest.json | 8 + plugins/apps/contrast-plugin/wrangler.toml | 2 +- .../apps/create-palette-plugin/package.json | 8 +- .../public/manifest.json | 8 + .../apps/create-palette-plugin/vite.config.ts | 5 +- plugins/apps/example-styles/index.html | 2 - plugins/apps/example-styles/package.json | 5 +- plugins/apps/example-styles/vite.config.ts | 4 +- plugins/apps/icons-plugin/src/index.html | 1 - plugins/apps/icons-plugin/src/manifest.json | 8 + plugins/apps/lorem-ipsum-plugin/package.json | 4 +- .../apps/lorem-ipsum-plugin/src/index.html | 1 - .../apps/lorem-ipsum-plugin/src/manifest.json | 8 + plugins/apps/poc-state-plugin/package.json | 4 +- .../poc-state-plugin/src/app/app.component.ts | 2 +- plugins/apps/poc-state-plugin/src/index.html | 1 - .../apps/poc-state-plugin/src/manifest.json | 14 + plugins/apps/poc-tokens-plugin/package.json | 4 +- .../src/app/app.component.ts | 2 +- plugins/apps/poc-tokens-plugin/src/index.html | 1 - .../apps/poc-tokens-plugin/src/manifest.json | 15 + .../apps/rename-layers-plugin/package.json | 4 +- .../apps/rename-layers-plugin/src/index.html | 1 - .../rename-layers-plugin/src/manifest.json | 8 + plugins/apps/table-plugin/package.json | 4 +- .../apps/table-plugin/src/app/app.config.ts | 4 +- plugins/apps/table-plugin/src/index.html | 1 - plugins/apps/table-plugin/src/manifest.json | 8 + plugins/apps/table-plugin/vite.config.ts | 6 + plugins/apps/table-plugin/wrangler.toml | 2 +- plugins/libs/plugin-types/index.d.ts | 56 +- plugins/libs/plugins-runtime/src/index.ts | 3 + .../libs/plugins-runtime/src/lib/api/index.ts | 9 + .../plugins-runtime/src/lib/create-modal.ts | 4 + .../src/lib/create-plugin.spec.ts | 9 +- .../plugins-runtime/src/lib/create-plugin.ts | 11 +- .../plugins-runtime/src/lib/create-sandbox.ts | 87 +- .../src/lib/load-plugin.spec.ts | 16 +- .../plugins-runtime/src/lib/load-plugin.ts | 15 +- .../src/lib/models/manifest.schema.ts | 1 + .../src/lib/models/open-ui-options.schema.ts | 1 + .../plugins-runtime/src/lib/parse-manifest.ts | 28 +- .../src/lib/plugin-manager.spec.ts | 12 +- .../plugins-runtime/src/lib/plugin-manager.ts | 14 +- plugins/libs/plugins-runtime/vite.config.ts | 2 +- plugins/package.json | 4 +- 165 files changed, 5714 insertions(+), 3018 deletions(-) create mode 100644 backend/src/app/migrations/sql/0146-mod-access-token-table.sql create mode 100644 common/src/app/common/schema/messages.cljc create mode 100644 frontend/src/app/main/data/workspace/mcp.cljs delete mode 100644 frontend/src/app/main/ui/settings/access_tokens.cljs delete mode 100644 frontend/src/app/main/ui/settings/access_tokens.scss create mode 100644 frontend/src/app/main/ui/settings/integrations.cljs create mode 100644 frontend/src/app/main/ui/settings/integrations.scss create mode 100644 mcp/bin/mcp-local.js create mode 100644 mcp/packages/plugin/public/icon.jpg create mode 100644 mcp/packages/plugin/src/index.d.ts create mode 100644 mcp/packages/server/data/base_instructions.md create mode 100644 mcp/packages/server/scripts/copy-resources.js create mode 100644 mcp/scripts/pack create mode 100644 mcp/scripts/set-version create mode 100644 plugins/apps/colors-to-tokens-plugin/src/manifest.json create mode 100644 plugins/apps/contrast-plugin/src/manifest.json create mode 100644 plugins/apps/create-palette-plugin/public/manifest.json create mode 100644 plugins/apps/icons-plugin/src/manifest.json create mode 100644 plugins/apps/lorem-ipsum-plugin/src/manifest.json create mode 100644 plugins/apps/poc-state-plugin/src/manifest.json create mode 100644 plugins/apps/poc-tokens-plugin/src/manifest.json create mode 100644 plugins/apps/rename-layers-plugin/src/manifest.json create mode 100644 plugins/apps/table-plugin/src/manifest.json create mode 100644 plugins/apps/table-plugin/vite.config.ts diff --git a/CHANGES.md b/CHANGES.md index 0d431c0d2b..3dcc585934 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -90,6 +90,8 @@ - Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017) - Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007) - Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215) +- [MCP server] Integrations section [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112) +- [Access Tokens] Look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114) ### :bug: Bugs fixed diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 2a9d9eba0b..4c9199a6f5 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -463,8 +463,10 @@ :fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")} {:name "0145-fix-plugins-uri-on-profile" - :fn mg0145/migrate}]) + :fn mg0145/migrate} + {:name "0146-mod-access-token-table" + :fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0146-mod-access-token-table.sql b/backend/src/app/migrations/sql/0146-mod-access-token-table.sql new file mode 100644 index 0000000000..574257859d --- /dev/null +++ b/backend/src/app/migrations/sql/0146-mod-access-token-table.sql @@ -0,0 +1,2 @@ +ALTER TABLE access_token + ADD COLUMN type text NULL; diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index a302b82053..eedb119d06 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -23,7 +23,7 @@ (dissoc row :perms)) (defn create-access-token - [{:keys [::db/conn] :as cfg} profile-id name expiration] + [{:keys [::db/conn] :as cfg} profile-id name expiration type] (let [token-id (uuid/next) expires-at (some-> expiration (ct/in-future)) created-at (ct/now) @@ -36,6 +36,7 @@ {:id token-id :name name :token token + :type type :profile-id profile-id :created-at created-at :updated-at created-at @@ -50,17 +51,18 @@ (def ^:private schema:create-access-token [:map {:title "create-access-token"} [:name [:string {:max 250 :min 1}]] - [:expiration {:optional true} ::ct/duration]]) + [:expiration {:optional true} ::ct/duration] + [:type {:optional true} :string]]) (sv/defmethod ::create-access-token {::doc/added "1.18" ::sm/params schema:create-access-token} - [cfg {:keys [::rpc/profile-id name expiration]}] + [cfg {:keys [::rpc/profile-id name expiration type]}] (quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile ::quotes/profile-id profile-id}) - (db/tx-run! cfg create-access-token profile-id name expiration)) + (db/tx-run! cfg create-access-token profile-id name expiration type)) (def ^:private schema:delete-access-token [:map {:title "delete-access-token"} @@ -83,5 +85,22 @@ (->> (db/query pool :access-token {:profile-id profile-id} {:order-by [[:expires-at :asc] [:created-at :asc]] - :columns [:id :name :perms :created-at :updated-at :expires-at]}) + :columns [:id :name :perms :type :created-at :updated-at :expires-at]}) (mapv decode-row))) + +(def ^:private schema:get-current-mcp-token + [:map {:title "get-current-mcp-token"}]) + +(sv/defmethod ::get-current-mcp-token + {::doc/added "2.15" + ::sm/params schema:get-current-mcp-token} + [{:keys [::db/pool]} {:keys [::rpc/profile-id ::rpc/request-at]}] + (->> (db/query pool :access-token + {:profile-id profile-id + :type "mcp"} + {:order-by [[:expires-at :asc] [:created-at :asc]] + :columns [:token :expires-at]}) + (remove #(and (some? (:expires-at %)) + (ct/is-after? request-at (:expires-at %)))) + (map decode-row) + (first))) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 3d2f2b1351..efe99c4a70 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -48,6 +48,7 @@ (def schema:props [:map {:title "ProfileProps"} [:plugins {:optional true} schema:plugin-registry] + [:mcp-enabled {:optional true} ::sm/boolean] [:newsletter-updates {:optional true} ::sm/boolean] [:newsletter-news {:optional true} ::sm/boolean] [:onboarding-team-id {:optional true} ::sm/uuid] diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index b4fa5062d5..809e43f9e3 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -102,7 +102,7 @@ (t/deftest access-token-authz (let [profile (th/create-profile* 1) - token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil) + token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil nil) handler (#'app.http.access-token/wrap-authz identity th/*system*)] (let [response (handler nil)] diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index fe0269d609..6dc96ac0f6 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -107,4 +107,18 @@ ;; (th/print-result! out) (t/is (nil? (:error out))) (let [results (:result out)] - (t/is (= 2 (count results)))))))) + (t/is (= 2 (count results)))))) + + (t/testing "get mcp token" + (let [_ (th/command! {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :type "mcp" + :name "token 1" + :perms ["get-profile"]}) + {:keys [error result]} + (th/command! {::th/type :get-current-mcp-token + ::rpc/profile-id (:id prof)})] + ;; (th/print-result! result) + (t/is (nil? error)) + (t/is (string? (:token result))))))) + diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 816bc2edbb..64cb7f9d68 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -152,7 +152,9 @@ :redis-cache ;; Activates the nitrate module - :nitrate}) + :nitrate + + :mcp}) (def all-flags (set/union email login varia)) diff --git a/common/src/app/common/i18n.cljc b/common/src/app/common/i18n.cljc index bdd80b9741..f363329f2d 100644 --- a/common/src/app/common/i18n.cljc +++ b/common/src/app/common/i18n.cljc @@ -13,3 +13,10 @@ unit tests or backend code for logs or error messages." [key & _args] key) + +(defn c + "This function will be monkeypatched at runtime with the real function in frontend i18n. + Here it just returns the key passed as argument. This way the result can be used in + unit tests or backend code for logs or error messages." + [x] + x) diff --git a/common/src/app/common/schema/messages.cljc b/common/src/app/common/schema/messages.cljc new file mode 100644 index 0000000000..93903c1b9c --- /dev/null +++ b/common/src/app/common/schema/messages.cljc @@ -0,0 +1,105 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.common.schema.messages + (:require + [app.common.data :as d] + [app.common.i18n :as i18n :refer [tr]] + [app.common.schema :as sm] + [malli.core :as m])) + +;; --- Handlers Helpers + +(defn- translate-code + [code] + (if (vector? code) + (tr (nth code 0) (i18n/c (nth code 1))) + (tr code))) + +(defn- handle-error-fn + [props problem] + (let [v-fn (:error/fn props) + result (v-fn problem)] + (if (string? result) + {:message result} + {:message (or (some-> (get result :code) + (translate-code)) + (get result :message) + (tr "errors.invalid-data"))}))) + +(defn- handle-error-message + [props] + {:message (get props :error/message)}) + +(defn- handle-error-code + [props] + (let [code (get props :error/code)] + {:message (translate-code code)})) + +(defn interpret-schema-problem + [acc {:keys [schema in value type] :as problem}] + (let [props (m/properties schema) + tprops (m/type-properties schema) + field (or (:error/field props) + in) + field (if (vector? field) + field + [field])] + + (if (and (= 1 (count field)) + (contains? acc (first field))) + acc + (cond + (or (nil? field) + (empty? field)) + acc + + (or (= type :malli.core/missing-key) + (nil? value)) + (assoc-in acc field {:message (tr "errors.field-missing")}) + + ;; --- CHECK on schema props + (contains? props :error/fn) + (assoc-in acc field (handle-error-fn props problem)) + + (contains? props :error/message) + (assoc-in acc field (handle-error-message props)) + + (contains? props :error/code) + (assoc-in acc field (handle-error-code props)) + + ;; --- CHECK on type props + (contains? tprops :error/fn) + (assoc-in acc field (handle-error-fn tprops problem)) + + (contains? tprops :error/message) + (assoc-in acc field (handle-error-message tprops)) + + (contains? tprops :error/code) + (assoc-in acc field (handle-error-code tprops)) + + :else + (assoc-in acc field {:message (tr "errors.invalid-data")}))))) + + + +(defn- apply-validators + [validators state errors] + (reduce (fn [errors validator-fn] + (merge errors (validator-fn errors (:data state)))) + errors + validators)) + +(defn collect-schema-errors + [schema validators state] + (let [explain (sm/explain schema (:data state)) + errors (->> (reduce interpret-schema-problem {} (:errors explain)) + (apply-validators validators state))] + + (-> (:errors state) + (merge errors) + (d/without-nils) + (not-empty)))) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 8ab9c6bcd0..050de69c02 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -242,17 +242,19 @@ (update-token- [this token-id f] (assert (uuid? token-id) "expected uuid for `token-id`") (if-let [token (get-token- this token-id)] - (let [token' (-> (make-token (f token)) - (assoc :modified-at (ct/now)))] - (TokenSet. id - name - description - (ct/now) - (if (= (:name token) (:name token')) - (assoc tokens (:name token') token') - (-> tokens - (d/oassoc-before (:name token) (:name token') token') - (dissoc (:name token)))))) + (let [token' (f token)] + (if (not= token token') + (let [token' (assoc token' :modified-at (ct/now))] + (TokenSet. id + name + description + (ct/now) + (if (= (:name token) (:name token')) + (assoc tokens (:name token') token') + (-> tokens + (d/oassoc-before (:name token) (:name token') token') + (dissoc (:name token)))))) + this)) this)) (delete-token- [this token-id] @@ -303,6 +305,35 @@ (-clj->js [this] (clj->js (datafy this))))) +(def ^:private set-prefix "S-") + +(def ^:private set-group-prefix "G-") + +(def ^:private set-separator "/") + +(defn get-set-path + [token-set] + (cpn/split-path (get-name token-set) :separator set-separator)) + +(defn split-set-name + [name] + (cpn/split-path name :separator set-separator)) + +(defn join-set-path [path] + (cpn/join-path path :separator set-separator :with-spaces? false)) + +(defn normalize-set-name + "Normalize a set name (ensure that there are no extra spaces, like ' group / set' -> 'group/set'). + + If `relative-to` is provided, the normalized name will preserve the same group prefix as reference name." + ([name] + (-> (split-set-name (str name)) + (cpn/join-path :separator set-separator :with-spaces? false))) + ([name relative-to] + (-> (concat (butlast (split-set-name relative-to)) + (split-set-name (str name))) + (cpn/join-path :separator set-separator :with-spaces? false)))) + (defn token-set? [o] (instance? TokenSet o)) @@ -357,6 +388,7 @@ (def check-token-set (sm/check-fn schema:token-set :hint "expected valid token set")) + (defn map->token-set [& {:as attrs}] (TokenSet. (:id attrs) @@ -372,38 +404,10 @@ (update :modified-at #(or % (ct/now))) (update :tokens #(into (d/ordered-map) %)) (update :description d/nilv "") + (update :name normalize-set-name) (check-token-set-attrs) (map->token-set))) -(def ^:private set-prefix "S-") - -(def ^:private set-group-prefix "G-") - -(def ^:private set-separator "/") - -(defn get-set-path - [token-set] - (cpn/split-path (get-name token-set) :separator set-separator)) - -(defn split-set-name - [name] - (cpn/split-path name :separator set-separator)) - -(defn join-set-path [path] - (cpn/join-path path :separator set-separator :with-spaces? false)) - -(defn normalize-set-name - "Normalize a set name (ensure that there are no extra spaces, like ' group / set' -> 'group/set'). - - If `relative-to` is provided, the normalized name will preserve the same group prefix as reference name." - ([name] - (-> (split-set-name name) - (cpn/join-path :separator set-separator :with-spaces? false))) - ([name relative-to] - (-> (concat (butlast (split-set-name relative-to)) - (split-set-name name)) - (cpn/join-path :separator set-separator :with-spaces? false)))) - (defn normalized-set-name? "Check if a set name is normalized (no extra spaces)." [name] @@ -609,7 +613,7 @@ is-source external-id (ct/now) - set-names)) + (into #{} (filter some?) set-names))) (enable-set [this set-name] (set-sets this (conj sets set-name))) @@ -630,14 +634,9 @@ (update-set-name [this prev-set-name set-name] (if (get sets prev-set-name) - (TokenTheme. id - name - group - description - is-source - external-id - (ct/now) - (conj (disj sets prev-set-name) set-name)) + (let [sets (-> (disj sets prev-set-name) + (conj set-name))] + (set-sets this sets)) this)) (theme-matches-group-name [this group name] @@ -722,7 +721,8 @@ (update :is-source d/nilv false) (update :external-id #(or % (str new-id))) (update :modified-at #(or % (ct/now))) - (update :sets set) + (update :sets #(into #{} (comp (filter some?) + (map normalize-set-name)) %)) (check-token-theme-attrs) (map->TokenTheme)))) diff --git a/docker/devenv/files/Caddyfile b/docker/devenv/files/Caddyfile index eda140d5e9..a4e81434b0 100644 --- a/docker/devenv/files/Caddyfile +++ b/docker/devenv/files/Caddyfile @@ -8,7 +8,7 @@ localhost:3449 { header -Strict-Transport-Security } -http://localhost:3450 { +:3450 { # For subpath test # handle_path /penpot/* { # reverse_proxy localhost:4449 diff --git a/docker/devenv/files/bashrc b/docker/devenv/files/bashrc index 98fc4a96dc..79ef2bd532 100644 --- a/docker/devenv/files/bashrc +++ b/docker/devenv/files/bashrc @@ -5,6 +5,9 @@ EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh; export PATH="/home/penpot/.cargo/bin:/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" export CARGO_HOME="/home/penpot/.cargo" +export PENPOT_MCP_PLUGIN_SERVER_HOST=0.0.0.0 +export PENPOT_MCP_SERVER_HOST=0.0.0.0 + alias l='ls --color -GFlh' alias ll='ls --color -GFlh' alias rm='rm -rf' diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index ca770e04c7..3a6f50b4be 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -126,6 +126,12 @@ http { proxy_http_version 1.1; } + location /plugins { + autoindex on; + alias /home/penpot/penpot/plugins/dist/apps; + proxy_http_version 1.1; + } + location /mcp/ws { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index f4d5544c89..14b1172035 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -5,7 +5,8 @@ ENV LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ NODE_VERSION=v22.21.1 \ DEBIAN_FRONTEND=noninteractive \ - PATH=/opt/node/bin:$PATH + PATH=/opt/node/bin:$PATH \ + PENPOT_MCP_SERVER_HOST=0.0.0.0 RUN set -ex; \ useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ diff --git a/frontend/package.json b/frontend/package.json index ed3c29f34a..2e06b79905 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,7 +50,7 @@ "devDependencies": { "@penpot/draft-js": "workspace:./packages/draft-js", "@penpot/mousetrap": "workspace:./packages/mousetrap", - "@penpot/plugins-runtime": "link:../plugins/dist/plugins-runtime", + "@penpot/plugins-runtime": "link:../plugins/libs/plugins-runtime", "@penpot/svgo": "penpot/svgo#v3.2", "@penpot/text-editor": "workspace:./text-editor", "@penpot/tokenscript": "workspace:./packages/tokenscript", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 539b36dbe4..e98f362410 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: specifier: workspace:./packages/mousetrap version: link:packages/mousetrap '@penpot/plugins-runtime': - specifier: link:../plugins/dist/plugins-runtime - version: link:../plugins/dist/plugins-runtime + specifier: link:../plugins/libs/plugins-runtime + version: link:../plugins/libs/plugins-runtime '@penpot/svgo': specifier: penpot/svgo#v3.2 version: svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b diff --git a/frontend/scripts/build b/frontend/scripts/build index 262a90ff45..eb8e42ea1b 100755 --- a/frontend/scripts/build +++ b/frontend/scripts/build @@ -36,7 +36,7 @@ popd pushd ../mcp; rm -rf node_modules; ./scripts/setup -WS_URI="/mcp/ws" pnpm run --filter "mcp-plugin" build:multi-user +WS_URI="/mcp/ws" pnpm run --filter "mcp-plugin" build popd; pnpm run build:app:main $EXTRA_PARAMS; diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 75f5010280..19339a8eca 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -172,6 +172,10 @@ (normalize-uri (or (obj/get global "penpotPublicURI") (obj/get location "origin")))) +(def mcp-ws-uri + (or (some-> (obj/get global "penpotMcpServerURI") u/uri) + (u/join public-uri "mcp/ws"))) + (def rasterizer-uri (or (some-> (obj/get global "penpotRasterizerURI") normalize-uri) public-uri)) @@ -205,6 +209,9 @@ (let [f (obj/get global "initializeExternalConfigInfo")] (when (fn? f) (f)))) +(def mcp-server-url (-> public-uri u/ensure-path-slash (u/join "mcp/stream") str)) +(def mcp-help-center-uri "https://help.penpot.app/mcp/") + ;; --- Helper Functions (defn ^boolean check-browser? [candidate] diff --git a/frontend/src/app/main/broadcast.cljs b/frontend/src/app/main/broadcast.cljs index 33e12f12a6..0a4ccf1070 100644 --- a/frontend/src/app/main/broadcast.cljs +++ b/frontend/src/app/main/broadcast.cljs @@ -57,5 +57,6 @@ [type data] (ptk/reify ::event ptk/EffectEvent - (effect [_ _ _] - (emit! type data)))) + (effect [_ state _] + (let [session-id (get state :session-id)] + (emit! session-id type data))))) diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index e9f5266c1b..b091518b67 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -7,12 +7,14 @@ (ns app.main.data.plugins (:require [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.files.changes-builder :as pcb] [app.common.time :as ct] [app.main.data.changes :as dch] [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] + [app.main.errors :as errors] [app.main.store :as st] [app.plugins.flags :as pflag] [app.plugins.register :as preg] @@ -20,7 +22,8 @@ [app.util.http :as http] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] - [potok.v2.core :as ptk])) + [potok.v2.core :as ptk] + [promesa.core :as p])) (defn save-plugin-permissions-peek [id permissions] @@ -52,27 +55,47 @@ (update [_ state] (update-in state [:workspace-local :open-plugins] (fnil disj #{}) id)))) +(defn start-plugin! + [{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions] + (-> (.ɵloadPlugin + ^js ug/global + #js {:pluginId plugin-id + :name name + :version version + :description description + :host host + :code code + :allowBackground (boolean allow-background) + :permissions (apply array permissions)} + nil + extensions) + + (p/catch (fn [cause] + (ex/print-throwable cause :prefix "Plugin Error") + (errors/flash :cause cause :type :handled))))) + (defn- load-plugin! - [{:keys [plugin-id name description host code icon permissions]}] - (try - (st/emit! (pflag/clear plugin-id) - (save-current-plugin plugin-id)) + [{:keys [plugin-id name version description host code icon permissions]}] + (st/emit! (pflag/clear plugin-id) + (save-current-plugin plugin-id)) - (.ɵloadPlugin - ^js ug/global - #js {:pluginId plugin-id - :name name - :description description - :host host - :code code - :icon icon - :permissions (apply array permissions)} - (fn [] - (st/emit! (remove-current-plugin plugin-id)))) + (-> (.ɵloadPlugin + ^js ug/global + #js {:pluginId plugin-id + :name name + :description description + :version version + :host host + :code code + :icon icon + :permissions (apply array permissions)} + (fn [] + (st/emit! (remove-current-plugin plugin-id)))) - (catch :default e - (st/emit! (remove-current-plugin plugin-id)) - (.error js/console "Error" e)))) + (p/catch (fn [cause] + (st/emit! (remove-current-plugin plugin-id)) + (ex/print-throwable cause :prefix "Plugin Error") + (errors/flash :cause cause :type :handled))))) (defn open-plugin! [{:keys [url] :as manifest} user-can-edit?] diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index e7828a0302..66ded6fc8b 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -498,4 +498,3 @@ (->> (rp/cmd! :delete-access-token params) (rx/tap on-success) (rx/catch on-error)))))) - diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 9116c99024..540d9d3881 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -52,6 +52,7 @@ [app.main.data.workspace.layers :as dwly] [app.main.data.workspace.layout :as layout] [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.notifications :as dwn] [app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.path :as dwdp] @@ -211,8 +212,11 @@ ptk/WatchEvent (watch [_ _ _] - (rx/of (dp/check-open-plugin) - (fdf/fix-deleted-fonts-for-local-library file-id))))) + (rx/merge + (rx/of (dp/check-open-plugin) + (fdf/fix-deleted-fonts-for-local-library file-id)) + (when (contains? cf/flags :mcp) + (rx/of (mcp/init))))))) (defn- bundle-fetched [{:keys [file file-id thumbnails] :as bundle}] @@ -304,163 +308,169 @@ :team-id (dm/str team-id) :file-id (dm/str file-id)) - (->> (rx/merge - (rx/concat - ;; Fetch all essential data that should be loaded before the file - (rx/merge - (if ^boolean render-wasm? - (->> (rx/from @wasm/module) - (rx/filter true?) - (rx/tap (fn [_] - (let [event (ug/event "penpot:wasm:loaded")] - (ug/dispatch! event)))) - (rx/ignore)) - (rx/empty)) + (rx/concat + (->> (rx/merge + (rx/concat + ;; Fetch all essential data that should be loaded before the file + (rx/merge + (if ^boolean render-wasm? + (->> (rx/from @wasm/module) + (rx/filter true?) + (rx/tap (fn [_] + (let [event (ug/event "penpot:wasm:loaded")] + (ug/dispatch! event)))) + (rx/ignore)) + (rx/empty)) - (->> stream - (rx/filter (ptk/type? ::df/fonts-loaded)) - (rx/take 1) - (rx/ignore)) + (->> stream + (rx/filter (ptk/type? ::df/fonts-loaded)) + (rx/take 1) + (rx/ignore)) - (rx/of (ntf/hide) - (dcmt/retrieve-comment-threads file-id) - (dcmt/fetch-profiles) - (df/fetch-fonts team-id))) + (rx/of (ntf/hide) + (dcmt/retrieve-comment-threads file-id) + (dcmt/fetch-profiles) + (df/fetch-fonts team-id)) - ;; Once the essential data is fetched, lets proceed to - ;; fetch teh file bunldle - (rx/of (fetch-bundle file-id features))) + (when (contains? cf/flags :mcp) + (rx/of (du/fetch-access-tokens)))) - (->> stream - (rx/filter (ptk/type? ::bundle-fetched)) - (rx/take 1) - (rx/map deref) - (rx/mapcat - (fn [{:keys [file]}] - (log/debug :hint "bundle fetched" - :team-id (dm/str team-id) - :file-id (dm/str file-id)) + ;; Once the essential data is fetched, lets proceed to + ;; fetch teh file bunldle + (rx/of (fetch-bundle file-id features))) - (rx/of (dpj/initialize-project (:project-id file)) - (dwn/initialize team-id file-id) - (dwsl/initialize-shape-layout) - (fetch-libraries file-id features) - (-> (workspace-initialized file-id) - (with-meta {:team-id team-id - :file-id file-id})))))) + (->> stream + (rx/filter (ptk/type? ::bundle-fetched)) + (rx/take 1) + (rx/map deref) + (rx/mapcat + (fn [{:keys [file]}] + (log/debug :hint "bundle fetched" + :team-id (dm/str team-id) + :file-id (dm/str file-id)) - ;; Install dev perf observers once the workspace is ready - (when (contains? cf/flags :perf-logs) - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/take 1) - (rx/tap (fn [_] (perf/setup))))) + (rx/of (dpj/initialize-project (:project-id file)) + (dwn/initialize team-id file-id) + (dwsl/initialize-shape-layout) + (fetch-libraries file-id features) + (-> (workspace-initialized file-id) + (with-meta {:team-id team-id + :file-id file-id})))))) - (->> stream - (rx/filter (ptk/type? ::dps/persistence-notification)) - (rx/take 1) - (rx/map dwc/set-workspace-visited)) + ;; Install dev perf observers once the workspace is ready + (when (contains? cf/flags :perf-logs) + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/take 1) + (rx/tap (fn [_] (perf/setup))))) - (when-let [component-id (some-> rparams :component-id uuid/parse)] - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/observe-on :async) - (rx/take 1) - (rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams))))) + (->> stream + (rx/filter (ptk/type? ::dps/persistence-notification)) + (rx/take 1) + (rx/map dwc/set-workspace-visited)) - (when (:board-id rparams) - (->> stream - (rx/filter (ptk/type? ::dwv/initialize-viewport)) - (rx/take 1) - (rx/map zoom-to-frame))) + (when-let [component-id (some-> rparams :component-id uuid/parse)] + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/observe-on :async) + (rx/take 1) + (rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams))))) - (when-let [comment-id (some-> rparams :comment-id uuid/parse)] - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/observe-on :async) - (rx/take 1) - (rx/map #(dwcm/navigate-to-comment-id comment-id)))) + (when (:board-id rparams) + (->> stream + (rx/filter (ptk/type? ::dwv/initialize-viewport)) + (rx/take 1) + (rx/map zoom-to-frame))) - (when render-wasm? - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/mapcat - (fn [{:keys [redo-changes]}] - (let [added (->> redo-changes - (filter #(= (:type %) :add-obj)) - (map :id))] - (->> (rx/from added) - (rx/map process-wasm-object))))))) + (when-let [comment-id (some-> rparams :comment-id uuid/parse)] + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/observe-on :async) + (rx/take 1) + (rx/map #(dwcm/navigate-to-comment-id comment-id)))) - (when render-wasm? - (let [local-commits-s - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/filter #(and (= :local (:source %)) - (not (contains? (:tags %) :position-data)))) - (rx/filter (complement empty?))) + (when render-wasm? + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/mapcat + (fn [{:keys [redo-changes]}] + (let [added (->> redo-changes + (filter #(= (:type %) :add-obj)) + (map :id))] + (->> (rx/from added) + (rx/map process-wasm-object))))))) - notifier-s - (rx/merge - (->> local-commits-s (rx/debounce 1000)) - (->> stream (rx/filter dps/force-persist?))) + (when render-wasm? + (let [local-commits-s + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/filter #(and (= :local (:source %)) + (not (contains? (:tags %) :position-data)))) + (rx/filter (complement empty?))) - objects-s - (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) + notifier-s + (rx/merge + (->> local-commits-s (rx/debounce 1000)) + (->> stream (rx/filter dps/force-persist?))) - current-page-id-s - (rx/from-atom refs/current-page-id {:emit-current-value? true})] + objects-s + (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) - (->> local-commits-s - (rx/buffer-until notifier-s) - (rx/with-latest-from objects-s) - (rx/map - (fn [[commits objects]] - (->> commits - (mapcat :redo-changes) - (filter #(contains? #{:mod-obj :add-obj} (:type %))) - (filter #(cfh/text-shape? objects (:id %))) - (map #(vector - (:id %) - (wasm.api/calculate-position-data (get objects (:id %)))))))) + current-page-id-s + (rx/from-atom refs/current-page-id {:emit-current-value? true})] - (rx/with-latest-from current-page-id-s) - (rx/map - (fn [[text-position-data page-id]] - (let [changes - (->> text-position-data - (mapv (fn [[id position-data]] - {:type :mod-obj - :id id - :page-id page-id - :operations - [{:type :set - :attr :position-data - :val position-data - :ignore-touched true - :ignore-geometry true}]})))] - (when (d/not-empty? changes) - (dch/commit-changes - {:redo-changes changes :undo-changes [] - :save-undo? false - :tags #{:position-data}}))))) - (rx/take-until stoper-s)))) + (->> local-commits-s + (rx/buffer-until notifier-s) + (rx/with-latest-from objects-s) + (rx/map + (fn [[commits objects]] + (->> commits + (mapcat :redo-changes) + (filter #(contains? #{:mod-obj :add-obj} (:type %))) + (filter #(cfh/text-shape? objects (:id %))) + (map #(vector + (:id %) + (wasm.api/calculate-position-data (get objects (:id %)))))))) - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/mapcat - (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}] - (if (and save-undo? (seq undo-changes)) - (let [entry {:undo-changes undo-changes - :redo-changes redo-changes - :undo-group undo-group - :tags tags}] - (rx/of (dwu/append-undo entry stack-undo?))) - (rx/empty)))))) - (rx/take-until stoper-s)))) + (rx/with-latest-from current-page-id-s) + (rx/map + (fn [[text-position-data page-id]] + (let [changes + (->> text-position-data + (mapv (fn [[id position-data]] + {:type :mod-obj + :id id + :page-id page-id + :operations + [{:type :set + :attr :position-data + :val position-data + :ignore-touched true + :ignore-geometry true}]})))] + (when (d/not-empty? changes) + (dch/commit-changes + {:redo-changes changes :undo-changes [] + :save-undo? false + :tags #{:position-data}}))))) + (rx/take-until stoper-s)))) + + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/mapcat + (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}] + (if (and save-undo? (seq undo-changes)) + (let [entry {:undo-changes undo-changes + :redo-changes redo-changes + :undo-group undo-group + :tags tags}] + (rx/of (dwu/append-undo entry stack-undo?))) + (rx/empty)))))) + (rx/take-until stoper-s)) + + (rx/of (mcp/notify-other-tabs-disconnect))))) ptk/EffectEvent (effect [_ _ _] diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs new file mode 100644 index 0000000000..f4a9c9bacc --- /dev/null +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -0,0 +1,292 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.data.workspace.mcp + (:require + [app.common.logging :as log] + [app.common.uri :as u] + [app.config :as cf] + [app.main.broadcast :as mbc] + [app.main.data.event :as ev] + [app.main.data.notifications :as ntf] + [app.main.data.plugins :as dp] + [app.main.repo :as rp] + [app.main.store :as st] + [app.plugins.register :refer [mcp-plugin-id]] + [app.util.i18n :refer [tr]] + [app.util.timers :as ts] + [beicon.v2.core :as rx] + [potok.v2.core :as ptk])) + +(def retry-interval 10000) + +(log/set-level! :info) + +(def ^:private default-manifest + {:code "plugin.js" + :name "Penpot MCP Plugin" + :version 2 + :plugin-id mcp-plugin-id + :description "This plugin enables interaction with the Penpot MCP server" + :allow-background true + :permissions + #{"library:read" "library:write" + "comment:read" "comment:write" + "content:write" "content:read"}}) + +(defonce interval-sub (atom nil)) + +(defn finalize-workspace? + [event] + (= (ptk/type event) :app.main.data.workspace/finalize-workspace)) + +(defn set-mcp-active + [value] + (ptk/reify ::set-mcp-active + ptk/UpdateEvent + (update [_ state] + (assoc-in state [:mcp :active] value)))) + +(defn start-reconnect-watcher! + [] + (st/emit! (set-mcp-active true)) + (when (nil? @interval-sub) + (reset! + interval-sub + (ts/interval + retry-interval + (fn [] + ;; Try to reconnect if active and not connected + (when-not (contains? #{"connecting" "connected"} + (-> @st/state :mcp :connection-status)) + (.log js/console "Reconnecting to MCP...") + (st/emit! (ptk/data-event ::connect)))))))) + +(defn stop-reconnect-watcher! + [] + (st/emit! (set-mcp-active false)) + (when @interval-sub + (rx/dispose! @interval-sub) + (reset! interval-sub nil))) + +(declare manage-mcp-notification) + +(defn handle-pong + [{:keys [id data]}] + (ptk/reify ::handle-pong + ptk/UpdateEvent + (update [_ state] + (let [mcp-state (get state :mcp)] + (cond + (= "connected" (:connection-status data)) + (update state :mcp assoc :connected-tab id) + + (and (= "disconnected" (:connection-status data)) + (= id (:connection-status mcp-state))) + (update state :mcp dissoc :connected-tab) + + :else + state))) + + ptk/WatchEvent + (watch [_ _ _] + (rx/of (manage-mcp-notification))))) + +;; This event will arrive when a new workspace is open in another tab +(defn handle-ping + [] + (ptk/reify ::handle-ping + ptk/WatchEvent + (watch [_ state _] + (let [conn-status (get-in state [:mcp :connection-status])] + (rx/of (mbc/event :mcp/pong {:connection-status conn-status})))))) + +(defn notify-other-tabs-disconnect + [] + (ptk/reify ::notify-other-tabs-disconnect + ptk/WatchEvent + (watch [_ _ _] + (rx/of (mbc/event :mcp/pong {:connection-status "disconnected"}))))) + +;; This event will arrive when the mcp is enabled in the dashboard +(defn update-mcp-status + [value] + (ptk/reify ::update-mcp-status + ptk/UpdateEvent + (update [_ state] + (update-in state [:profile :props] assoc :mcp-enabled value)) + + ptk/WatchEvent + (watch [_ _ _] + (rx/merge + (rx/of (manage-mcp-notification)) + (case value + true (rx/of (ptk/data-event ::connect)) + false (rx/of (ptk/data-event ::disconnect)) + nil))))) + +(defn update-mcp-connection-status + [value] + (ptk/reify ::update-mcp-plugin-connection + ptk/UpdateEvent + (update [_ state] + (update state :mcp assoc :connection-status value)) + + ptk/WatchEvent + (watch [_ _ _] + (rx/of (manage-mcp-notification) + (mbc/event :mcp/pong {:connection-status value}))))) + +(defn connect-mcp + [] + (ptk/reify ::connect-mcp + ptk/UpdateEvent + (update [_ state] + (update state :mcp assoc :connected-tab (:session-id state))) + + ptk/WatchEvent + (watch [_ _ _] + (rx/of (mbc/event :mcp/force-disconect {}) + (ptk/data-event ::connect))))) + +;; This event will arrive when the user selects disconnect on the menu +;; or there is a broadcast message for disconnection +(defn user-disconnect-mcp + [] + (ptk/reify ::user-disconnect-mcp + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/data-event ::disconnect) + (update-mcp-connection-status "disconnected"))) + + ptk/EffectEvent + (effect [_ _ _] + (stop-reconnect-watcher!)))) + +(defn- manage-mcp-notification + [] + (ptk/reify ::manage-mcp-notification + ptk/WatchEvent + (watch [_ state _] + (let [mcp-state (get state :mcp) + + mcp-enabled? (-> state :profile :props :mcp-enabled) + + current-tab-id (get state :session-id) + connected-tab-id (get mcp-state :connected-tab)] + + (if mcp-enabled? + (if (= connected-tab-id current-tab-id) + (rx/of (ntf/hide)) + (rx/of (ntf/dialog + {:content (tr "notifications.mcp.active-in-another-tab") + :cancel {:label (tr "labels.dismiss") + :callback #(st/emit! (ntf/hide) + (ev/event {::ev/name "confirm-mcp-tab-switch" + ::ev/origin "workspace-notification"}))} + :accept {:label (tr "labels.switch") + :callback #(st/emit! (connect-mcp) + (ev/event {::ev/name "dismiss-mcp-tab-switch" + ::ev/origin "workspace-notification"}))}}))) + (rx/of (ntf/hide))))))) + +(defn init-mcp + [stream] + (->> (rp/cmd! :get-current-mcp-token) + (rx/tap + (fn [{:keys [token]}] + (when token + (dp/start-plugin! + (assoc default-manifest + :url (str (u/join cf/public-uri "plugins/mcp/manifest.json")) + :host (str (u/join cf/public-uri "plugins/mcp/"))) + + ;; API extension for MCP server + #js {:mcp + #js + {:getToken (constantly token) + :getServerUrl #(str cf/mcp-ws-uri) + :setMcpStatus + (fn [status] + (when (= status "connected") + (start-reconnect-watcher!)) + (st/emit! (update-mcp-connection-status status)) + (log/info :hint "MCP STATUS" :status status)) + + :on + (fn [event cb] + (when-let [event + (case event + "disconnect" ::disconnect + "connect" ::connect + nil)] + + (let [stopper (rx/filter finalize-workspace? stream)] + (->> stream + (rx/filter (ptk/type? event)) + (rx/take-until stopper) + (rx/subs! #(cb))))))}})))) + (rx/ignore))) + +(defn init + [] + (ptk/reify ::init + ptk/UpdateEvent + (update [_ state] + (update state :mcp assoc :connected-tab (:session-id state) :active true)) + + ptk/WatchEvent + (watch [_ state stream] + (let [stoper-s (rx/merge + (rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream) + (rx/filter (ptk/type? ::init) stream)) + session-id (get state :session-id) + enabled? (-> state :profile :props :mcp-enabled)] + + (->> (rx/merge + (if enabled? + (rx/merge + (init-mcp stream) + + (rx/of (mbc/event :mcp/ping {})) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/ping)) + (rx/filter (fn [{:keys [id]}] + (not= session-id id))) + (rx/map handle-ping)) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/pong)) + (rx/filter (fn [{:keys [id]}] + (not= session-id id))) + (rx/map handle-pong)) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/force-disconect)) + (rx/filter (fn [{:keys [id]}] + (not= session-id id))) + (rx/map deref) + (rx/map (fn [] (user-disconnect-mcp))))) + (rx/empty)) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/enable)) + (rx/mapcat (fn [_] + ;; NOTE: we don't need an explicit + ;; connect because the plugin has + ;; auto-connect + (rx/of (update-mcp-status true) + (init))))) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/disable)) + (rx/mapcat (fn [_] + (rx/of (update-mcp-status false) + (init) + (user-disconnect-mcp)))))) + + (rx/take-until stoper-s)))))) diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index 9bfc7ac8a2..5e01fd4486 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -214,6 +214,7 @@ (update state :workspace-presence dissoc session-id) (update state :workspace-presence update-presence)))))) + (defn handle-pointer-update [{:keys [page-id session-id position zoom zoom-inverse vbox vport] :as msg}] (ptk/reify ::handle-pointer-update diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index a85d0f117c..10c148fe9f 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -49,14 +49,14 @@ ;; (note that dwsh/update-shapes function returns an event) -(defn update-shape-radius-all - ([value shape-ids attributes] (update-shape-radius-all value shape-ids attributes nil)) - ([value shape-ids _attributes page-id] ; The attributes param is needed to have the same arity that other update functions +(defn update-shape-radius + ([value shape-ids attributes] (update-shape-radius value shape-ids attributes nil)) + ([value shape-ids attributes page-id] (when (number? value) (let [value (max 0 value)] (dwsh/update-shapes shape-ids (fn [shape] - (ctsr/set-radius-to-all-corners shape value)) + (ctsr/set-radius-for-corners shape attributes value)) {:reg-objects? true :ignore-touched true :page-id page-id @@ -531,7 +531,7 @@ (some attributes #{:r1 :r2 :r3 :r4}) (conj #(if (= attributes #{:r1 :r2 :r3 :r4}) - (update-shape-radius-all value shape-ids attributes page-id) + (update-shape-radius value shape-ids attributes page-id) (update-shape-radius-for-corners value shape-ids (set (filter attributes #{:r1 :r2 :r3 :r4})) @@ -607,6 +607,46 @@ :state state})] (apply rx/of (map #(%) actions))))))))) +(def attributes->shape-update + "Maps each attribute-set to the update function that applies it to a shape. + Used both here (to resolve the correct update fn when explicit attrs are + passed to toggle-token) and in propagation.cljs (re-exported from there)." + {ctt/border-radius-keys update-shape-radius-for-corners + ctt/color-keys update-fill-stroke + ctt/stroke-width-keys update-stroke-width + ctt/sizing-keys apply-dimensions-token + ctt/opacity-keys update-opacity + ctt/rotation-keys update-rotation + + ;; Typography + ctt/font-family-keys update-font-family + ctt/font-size-keys update-font-size + ctt/font-weight-keys update-font-weight + ctt/letter-spacing-keys update-letter-spacing + ctt/text-case-keys update-text-case + ctt/text-decoration-keys update-text-decoration + ctt/typography-token-keys update-typography + ctt/shadow-keys update-shadow + ctt/line-height-keys update-line-height + + ;; Layout + #{:x :y} update-shape-position + #{:p1 :p2 :p3 :p4} update-layout-padding + #{:m1 :m2 :m3 :m4} update-layout-item-margin + #{:column-gap :row-gap} update-layout-gap + #{:width :height} apply-dimensions-token + #{:layout-item-min-w :layout-item-min-h + :layout-item-max-w :layout-item-max-h} update-layout-sizing-limits}) + +;; Flattened per-individual-key version of attributes->shape-update. +;; Allows O(1) lookup of the update function for any single attribute. +(def ^:private attr->shape-update + (reduce + (fn [acc [attr-set update-fn]] + (into acc (map (fn [k] [k update-fn]) attr-set))) + {} + attributes->shape-update)) + ;; Events to apply / unapply tokens to shapes ------------------------------------------------------------ (defn apply-token @@ -620,65 +660,73 @@ ptk/WatchEvent (watch [_ state _] ;; We do not allow to apply tokens while text editor is open. - (if (empty? (get state :workspace-editor-state)) - (let [attributes-to-remove - ;; Remove atomic typography tokens when applying composite and vice-verca - (cond - (ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys) - (ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys) - :else attributes-to-remove)] - (when-let [tokens (some-> (dsh/lookup-file-data state) - (get :tokens-lib) - (ctob/get-tokens-in-active-sets))] - (->> (if (contains? cf/flags :tokenscript) - (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) + ;; The classic text editor sets :workspace-editor-state; the WASM text editor + ;; does not, so we also check :workspace-local :edition for text shapes. + (let [edition (get-in state [:workspace-local :edition]) + objects (dsh/lookup-page-objects state) + text-editing? (and (some? edition) + (= :text (:type (get objects edition))))] + (if (and (empty? (get state :workspace-editor-state)) + (not text-editing?)) + (let [attributes-to-remove + ;; Remove atomic typography tokens when applying composite and vice-verca + (cond + (ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys) + (ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys) + :else attributes-to-remove)] + (when-let [tokens (some-> (dsh/lookup-file-data state) + (get :tokens-lib) + (ctob/get-tokens-in-active-sets))] + (->> (if (contains? cf/flags :tokenscript) + (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 - (filter (fn [[_ shape]] - (or - (and (ctsl/any-layout-immediate-child? objects shape) - (some ctt/spacing-margin-keys attributes)) - (and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape)) - (all-attrs-appliable-for-token? attributes (:type token))))))) - shape-ids (d/nilv (keys shapes) []) - any-variant? (->> shapes vals (some ctk/is-variant?) boolean) + shapes (->> selected-shapes + (filter (fn [[_ shape]] + (or + (and (ctsl/any-layout-immediate-child? objects shape) + (some ctt/spacing-margin-keys attributes)) + (and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape)) + (all-attrs-appliable-for-token? attributes (:type token))))))) + shape-ids (d/nilv (keys shapes) []) + any-variant? (->> shapes vals (some ctk/is-variant?) boolean) - resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value]) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) - resolved-value) - tokenized-attributes (cfo/attributes-map attributes token) - type (:type token)] - (rx/concat - (rx/of - (st/emit! (ev/event {::ev/name "apply-tokens" - :type type - :applied-to attributes - :applied-to-variant any-variant?})) - (dwu/start-undo-transaction undo-id) - (dwsh/update-shapes shape-ids (fn [shape] - (cond-> shape - attributes-to-remove - (update :applied-tokens #(apply (partial dissoc %) attributes-to-remove)) - :always - (update :applied-tokens merge tokenized-attributes))))) - (when on-update-shape - (let [res (on-update-shape resolved-value shape-ids attributes)] - ;; Composed updates return observables and need to be executed differently - (if (rx/observable? res) - res - (rx/of res)))) - (rx/of (dwu/commit-undo-transaction undo-id))))))))) - (rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition") - :type :toast - :level :warning - :timeout 3000})))))) + resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value]) + resolved-value (if (contains? cf/flags :tokenscript) + (ts/tokenscript-symbols->penpot-unit resolved-value) + resolved-value) + tokenized-attributes (cfo/attributes-map attributes token) + type (:type token)] + (rx/concat + (rx/of + (st/emit! (ev/event {::ev/name "apply-tokens" + :type type + :applied-to attributes + :applied-to-variant any-variant?})) + (dwu/start-undo-transaction undo-id) + (dwsh/update-shapes shape-ids (fn [shape] + (cond-> shape + attributes-to-remove + (update :applied-tokens #(apply (partial dissoc %) attributes-to-remove)) + :always + (update :applied-tokens merge tokenized-attributes))))) + (when on-update-shape + (let [res (on-update-shape resolved-value shape-ids attributes)] + ;; Composed updates return observables and need to be executed differently + (if (rx/observable? res) + res + (rx/of res)))) + (rx/of (dwu/commit-undo-transaction undo-id))))))))) + + (rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition") + :type :toast + :level :warning + :timeout 3000}))))))) (defn apply-spacing-token-separated "Handles edge-case for spacing token when applying token via toggle button. @@ -744,10 +792,16 @@ {:keys [attributes all-attributes on-update-shape]} (get token-properties (:type token)) + on-update-shape + (if (seq attrs) + (or (get attr->shape-update (first attrs)) on-update-shape) + on-update-shape) + unapply-tokens? (cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes)) - shape-ids (map :id shapes)] + shape-ids + (map :id shapes)] (if unapply-tokens? (rx/of @@ -808,7 +862,7 @@ :border-radius {:title "Border Radius" :attributes ctt/border-radius-keys - :on-update-shape update-shape-radius-all + :on-update-shape update-shape-radius :modal {:key :tokens/border-radius :fields [{:label "Border Radius" :key :border-radius}]}} diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index 28f1a30963..b21afdc8b7 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -613,7 +613,7 @@ vec)) (defn combine-as-variants - [ids {:keys [page-id trigger]}] + [ids {:keys [page-id trigger variant-id]}] (ptk/reify ::combine-as-variants ptk/WatchEvent (watch [_ state stream] @@ -647,7 +647,7 @@ :shapes count inc) - variant-id (uuid/next) + variant-id (or variant-id (uuid/next)) undo-id (js/Symbol)] (rx/concat diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 37177aec7d..58146736e1 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -33,6 +33,16 @@ ;; Will contain last uncaught exception (def last-exception nil) +(defn is-plugin-error? + "This is a placeholder that always return false. It will be + overwritten when plugin system is initialized. This works this way + because we can't import plugins here because plugins requries full + DOM. + + This placeholder is set on app.plugins/initialize event" + [_] + false) + ;; Re-entrancy guard: prevents on-error from calling itself recursively. ;; If an error occurs while we are already handling an error (e.g. the ;; notification emit itself throws), we log it and bail out immediately @@ -206,6 +216,16 @@ (ex/print-throwable cause :prefix "Unexpected Error") (flash :cause cause :type :unhandled)))) +(defmethod ptk/handle-error :wasm-error + [error] + (when-let [cause (::instance error)] + (ex/print-throwable cause) + (let [code (get error :code)] + (if (or (= code :panic) + (= code :webgl-context-lost)) + (st/emit! (rt/assign-exception error)) + (flash :type :handled :cause cause))))) + ;; We receive a explicit authentication error; If the uri is for ;; workspace, dashboard, viewer or settings, then assign the exception ;; for show the error page. Otherwise this explicitly clears all @@ -420,6 +440,15 @@ (and (string? stack) (str/includes? stack "posthog")))) + ;; Check if the error is marked as originating from plugin code. + ;; The plugin runtime tracks plugin errors in a WeakMap, which works + ;; even in SES hardened environments where error objects may be frozen. + (from-plugin? [cause] + (try + (is-plugin-error? cause) + (catch :default _ + false))) + (is-ignorable-exception? [cause] (let [message (ex-message cause)] (or (from-extension? cause) @@ -447,32 +476,55 @@ (on-unhandled-error [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "error")] - (when-not (is-ignorable-exception? cause) - (if (stale-asset-error? cause) - (cf/throttled-reload :reason (ex-message cause)) - (let [data (ex-data cause) - type (get data :type)] - (set! last-exception cause) - (if (= :wasm-error type) - (on-error cause) - (do - (ex/print-throwable cause :prefix "Uncaught Exception") - (ts/asap #(flash :cause cause :type :unhandled))))))))) + (cond + (stale-asset-error? cause) + (cf/throttled-reload :reason (ex-message cause)) + + ;; Plugin errors: log to console and ignore + (from-plugin? cause) + (ex/print-throwable cause :prefix "Plugin Error") + + ;; Other ignorable exceptions: ignore silently + (is-ignorable-exception? cause) + nil + + ;; All other errors: show exception page + :else + + (let [data (ex-data cause) + type (get data :type)] + (set! last-exception cause) + (if (= :wasm-error type) + (on-error cause) + (do + (ex/print-throwable cause :prefix "Uncaught Exception") + (ts/asap #(flash :cause cause :type :unhandled)))))))) (on-unhandled-rejection [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "reason")] - (when-not (is-ignorable-exception? cause) - (if (stale-asset-error? cause) - (cf/throttled-reload :reason (ex-message cause)) - (let [data (ex-data cause) - type (get data :type)] - (set! last-exception cause) - (if (= :wasm-error type) - (on-error cause) - (do - (ex/print-throwable cause :prefix "Uncaught Rejection") - (ts/asap #(flash :cause cause :type :unhandled)))))))))] + (cond + (stale-asset-error? cause) + (cf/throttled-reload :reason (ex-message cause)) + + ;; Plugin errors: log to console and ignore + (from-plugin? cause) + (ex/print-throwable cause :prefix "Plugin Error") + + ;; Other ignorable exceptions: ignore silently + (is-ignorable-exception? cause) + nil + + ;; All other errors: show exception page + :else + (let [data (ex-data cause) + type (get data :type)] + (set! last-exception cause) + (if (= :wasm-error type) + (on-error cause) + (do + (ex/print-throwable cause :prefix "Uncaught Rejection") + (ts/asap #(flash :cause cause :type :unhandled))))))))] (.addEventListener g/window "error" on-unhandled-error) (.addEventListener g/window "unhandledrejection" on-unhandled-rejection) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index c4e0faaecd..30c431ac86 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -150,6 +150,9 @@ (def workspace-global (l/derived :workspace-global st/state)) +(def mcp + (l/derived :mcp st/state)) + (def workspace-drawing (l/derived :workspace-drawing st/state)) diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 1a03943ba4..67795c2ff3 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -190,7 +190,7 @@ :settings-options :settings-feedback :settings-subscription - :settings-access-tokens + :settings-integrations :settings-notifications) (let [params (get params :query) error-report-id (some-> params :error-report-id uuid/parse*)] diff --git a/frontend/src/app/main/ui/confirm.cljs b/frontend/src/app/main/ui/confirm.cljs index ca8a78aea0..d2c068ebf2 100644 --- a/frontend/src/app/main/ui/confirm.cljs +++ b/frontend/src/app/main/ui/confirm.cljs @@ -9,14 +9,17 @@ (:require [app.main.data.modal :as modal] [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as k] [goog.events :as events] [rumext.v2 :as mf]) - (:import goog.events.EventType)) + (:import + goog.events.EventType)) (mf/defc confirm-dialog {::mf/register modal/components @@ -68,8 +71,11 @@ [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} title] - [:button {:class (stl/css :modal-close-btn) - :on-click cancel-fn} deprecated-icon/close]] + [:div {:class (stl/css :modal-close-btn)} + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click cancel-fn + :icon i/close}]]] [:div {:class (stl/css :modal-content)} (when (and (string? message) (not= message "")) @@ -87,24 +93,19 @@ [:ul {:class (stl/css :component-list)} (for [item items] [:li {:class (stl/css :modal-item-element)} - [:span {:class (stl/css :modal-component-icon)} - deprecated-icon/component] + [:> icon* {:icon-id i/component + :class (stl/css :modal-component-icon) + :size "s"}] [:span {:class (stl/css :modal-component-name)} (:name item)]])]])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} (when-not (= cancel-label :omit) - [:input - {:class (stl/css :cancel-button) - :type "button" - :value cancel-label - :on-click cancel-fn}]) - - [:input - {:class (stl/css-case :accept-btn true - :danger (= accept-style :danger) - :primary (= accept-style :primary)) - :type "button" - :value accept-label - :on-click accept-fn}]]]]])) + [:> button* {:variant "secondary" + :on-click cancel-fn} + cancel-label]) + [:> button* {:variant (cond (= accept-style :danger) "destructive" + (= accept-style :primary) "primary") + :on-click accept-fn} + accept-label]]]]])) diff --git a/frontend/src/app/main/ui/confirm.scss b/frontend/src/app/main/ui/confirm.scss index e517a7b685..09b23426f3 100644 --- a/frontend/src/app/main/ui/confirm.scss +++ b/frontend/src/app/main/ui/confirm.scss @@ -15,10 +15,9 @@ .modal-container { @extend .modal-container-base; -} - -.modal-header { - margin-bottom: deprecated.$s-24; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); } .modal-title { @@ -27,12 +26,13 @@ } .modal-close-btn { - @extend .modal-close-btn-base; + position: absolute; + top: var(--sp-m); + right: var(--sp-m); } .modal-content { @include deprecated.bodyLargeTypography; - margin-bottom: deprecated.$s-24; } .modal-item-element { @@ -41,32 +41,18 @@ .modal-component-icon { @include deprecated.flexCenter; - height: deprecated.$s-16; - width: deprecated.$s-16; - svg { - @extend .button-icon-small; - stroke: var(--color); - } + color: var(--color-foreground-secondary); } + .modal-component-name { @include deprecated.bodyLargeTypography; + color: var(--color-foreground-secondary); } .action-buttons { @extend .modal-action-btns; } -.cancel-button { - @extend .modal-cancel-btn; -} - -.accept-btn { - @extend .modal-accept-btn; - &.danger { - @extend .modal-danger-btn; - } -} - .modal-scd-msg, .modal-subtitle, .modal-msg { diff --git a/frontend/src/app/main/ui/ds/_sizes.scss b/frontend/src/app/main/ui/ds/_sizes.scss index 067bd0b416..9daa3a5ec7 100644 --- a/frontend/src/app/main/ui/ds/_sizes.scss +++ b/frontend/src/app/main/ui/ds/_sizes.scss @@ -18,6 +18,7 @@ $sz-32: px2rem(32); $sz-36: px2rem(36); $sz-40: px2rem(40); $sz-48: px2rem(48); +$sz-64: px2rem(64); $sz-88: px2rem(88); $sz-96: px2rem(96); $sz-120: px2rem(120); diff --git a/frontend/src/app/main/ui/ds/controls/input.cljs b/frontend/src/app/main/ui/ds/controls/input.cljs index 29ae0cc804..918e5a446b 100644 --- a/frontend/src/app/main/ui/ds/controls/input.cljs +++ b/frontend/src/app/main/ui/ds/controls/input.cljs @@ -8,7 +8,6 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] - [app.common.data.macros :as dm] [app.main.constants :refer [max-input-length]] [app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]] [app.main.ui.ds.controls.utilities.input-field :refer [input-field*]] @@ -52,10 +51,11 @@ :has-hint has-hint :hint-type hint-type :variant variant})] - [:div {:class (dm/str class " " (stl/css-case :input-wrapper true - :variant-dense (= variant "dense") - :variant-comfortable (= variant "comfortable") - :has-hint has-hint))} + + [:div {:class [class (stl/css-case :input-wrapper true + :variant-dense (= variant "dense") + :variant-comfortable (= variant "comfortable") + :has-hint has-hint)]} (when has-label [:> label* {:for id :is-optional is-optional} label]) [:> input-field* props] @@ -64,4 +64,3 @@ :class hint-class :message hint-message :type hint-type}])])) - diff --git a/frontend/src/app/main/ui/forms.cljs b/frontend/src/app/main/ui/forms.cljs index 7f1244dcad..9aede980cf 100644 --- a/frontend/src/app/main/ui/forms.cljs +++ b/frontend/src/app/main/ui/forms.cljs @@ -8,6 +8,7 @@ (:require [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.controls.input :refer [input*]] + [app.main.ui.ds.controls.select :refer [select*]] [app.util.dom :as dom] [app.util.forms :as fm] [app.util.keyboard :as k] @@ -47,6 +48,23 @@ [:> input* props])) +(mf/defc form-select* + [{:keys [name] :as props}] + (let [select-name name + form (mf/use-ctx context) + value (get-in @form [:data select-name] "") + + handle-change + (fn [event] + (let [value (if (string? event) event (dom/get-target-val event))] + (fm/on-input-change form select-name value))) + + props + (mf/spread-props props {:on-change handle-change + :value value})] + + [:> select* props])) + (mf/defc form-submit* [{:keys [disabled on-submit] :rest props}] (let [form (mf/use-ctx context) @@ -79,4 +97,4 @@ (when (fn? on-submit) (on-submit form event))))] [:> (mf/provider context) {:value form} - [:form {:class class :on-submit on-submit'} children]])) \ No newline at end of file + [:form {:class class :on-submit on-submit'} children]])) diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index e8159d3852..ca45bc5133 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -36,7 +36,7 @@ ["/feedback" :settings-feedback] ["/options" :settings-options] ["/subscriptions" :settings-subscription] - ["/access-tokens" :settings-access-tokens] + ["/integrations" :settings-integrations] ["/notifications" :settings-notifications]] ["/frame-preview" :frame-preview] diff --git a/frontend/src/app/main/ui/settings.cljs b/frontend/src/app/main/ui/settings.cljs index bc40ae20fa..2cb617c939 100644 --- a/frontend/src/app/main/ui/settings.cljs +++ b/frontend/src/app/main/ui/settings.cljs @@ -13,10 +13,10 @@ [app.main.store :as st] [app.main.ui.hooks :as hooks] [app.main.ui.modal :refer [modal-container*]] - [app.main.ui.settings.access-tokens :refer [access-tokens-page]] [app.main.ui.settings.change-email] [app.main.ui.settings.delete-account] [app.main.ui.settings.feedback :refer [feedback-page*]] + [app.main.ui.settings.integrations :refer [integrations-page*]] [app.main.ui.settings.notifications :refer [notifications-page*]] [app.main.ui.settings.options :refer [options-page]] [app.main.ui.settings.password :refer [password-page]] @@ -73,8 +73,8 @@ :settings-subscription [:> subscription-page* {:profile profile}] - :settings-access-tokens - [:& access-tokens-page] + :settings-integrations + [:> integrations-page*] :settings-notifications [:& notifications-page* {:profile profile}])]]]])) diff --git a/frontend/src/app/main/ui/settings/access_tokens.cljs b/frontend/src/app/main/ui/settings/access_tokens.cljs deleted file mode 100644 index 29a09476b0..0000000000 --- a/frontend/src/app/main/ui/settings/access_tokens.cljs +++ /dev/null @@ -1,291 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) KALEIDOS INC - -(ns app.main.ui.settings.access-tokens - (:require-macros [app.main.style :as stl]) - (:require - [app.common.schema :as sm] - [app.common.time :as ct] - [app.main.data.modal :as modal] - [app.main.data.notifications :as ntf] - [app.main.data.profile :as du] - [app.main.store :as st] - [app.main.ui.components.context-menu-a11y :refer [context-menu*]] - [app.main.ui.components.forms :as fm] - [app.main.ui.icons :as deprecated-icon] - [app.util.clipboard :as clipboard] - [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] - [app.util.keyboard :as kbd] - [okulary.core :as l] - [rumext.v2 :as mf])) - -(def ^:private clipboard-icon - (deprecated-icon/icon-xref :clipboard (stl/css :clipboard-icon))) - -(def ^:private close-icon - (deprecated-icon/icon-xref :close (stl/css :close-icon))) - -(def ^:private menu-icon - (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) - -(def tokens-ref - (l/derived :access-tokens st/state)) - -(def token-created-ref - (l/derived :access-token-created st/state)) - -(def ^:private schema:form - [:map {:title "AccessTokenForm"} - [:name [::sm/text {:max 250}]] - [:expiration-date [::sm/text {:max 250}]]]) - -(def initial-data - {:name "" :expiration-date "never"}) - -(mf/defc access-token-modal - {::mf/register modal/components - ::mf/register-as :access-token} - [] - (let [form (fm/use-form - :initial initial-data - :schema schema:form) - - created (mf/deref token-created-ref) - created? (mf/use-state false) - - on-success - (mf/use-fn - (mf/deps created) - (fn [_] - (let [message (tr "dashboard.access-tokens.create.success")] - (st/emit! (du/fetch-access-tokens) - (ntf/success message) - (reset! created? true))))) - - on-close - (mf/use-fn - (mf/deps created) - (fn [_] - (reset! created? false) - (st/emit! (modal/hide)))) - - on-error - (mf/use-fn - (fn [_] - (st/emit! (ntf/error (tr "errors.generic")) - (modal/hide)))) - - on-submit - (mf/use-fn - (fn [form] - (let [cdata (:clean-data @form) - mdata {:on-success (partial on-success form) - :on-error (partial on-error form)} - expiration (:expiration-date cdata) - params (cond-> {:name (:name cdata) - :perms (:perms cdata)} - (not= "never" expiration) (assoc :expiration expiration))] - (st/emit! (du/create-access-token - (with-meta params mdata)))))) - - copy-token - (mf/use-fn - (mf/deps created) - (fn [event] - (dom/prevent-default event) - (clipboard/to-clipboard (:token created)) - (st/emit! (ntf/show {:level :info - :type :toast - :content (tr "dashboard.access-tokens.copied-success") - :timeout 7000}))))] - - [:div {:class (stl/css :modal-overlay)} - [:div {:class (stl/css :modal-container)} - [:& fm/form {:form form :on-submit on-submit} - - [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} (tr "modals.create-access-token.title")] - - [:button {:class (stl/css :modal-close-btn) - :on-click on-close} - close-icon]] - - [:div {:class (stl/css :modal-content)} - [:div {:class (stl/css :fields-row)} - [:& fm/input {:type "text" - :auto-focus? true - :form form - :name :name - :disabled @created? - :label (tr "modals.create-access-token.name.label") - :show-success? true - :placeholder (tr "modals.create-access-token.name.placeholder")}]] - - [:div {:class (stl/css :fields-row)} - [:div {:class (stl/css :select-title)} - (tr "modals.create-access-token.expiration-date.label")] - [:& fm/select {:options [{:label (tr "dashboard.access-tokens.expiration-never") :value "never" :key "never"} - {:label (tr "dashboard.access-tokens.expiration-30-days") :value "720h" :key "720h"} - {:label (tr "dashboard.access-tokens.expiration-60-days") :value "1440h" :key "1440h"} - {:label (tr "dashboard.access-tokens.expiration-90-days") :value "2160h" :key "2160h"} - {:label (tr "dashboard.access-tokens.expiration-180-days") :value "4320h" :key "4320h"}] - :default "never" - :disabled @created? - :name :expiration-date}] - (when @created? - [:span {:class (stl/css :token-created-info)} - (if (:expires-at created) - (tr "dashboard.access-tokens.token-will-expire" (ct/format-inst (:expires-at created) "PPP")) - (tr "dashboard.access-tokens.token-will-not-expire"))])] - - [:div {:class (stl/css :fields-row)} - (when @created? - [:div {:class (stl/css :custon-input-wrapper)} - [:input {:type "text" - :value (:token created "") - :class (stl/css :custom-input-token) - :read-only true}] - [:button {:title (tr "modals.create-access-token.copy-token") - :class (stl/css :copy-btn) - :on-click copy-token} - clipboard-icon]]) - #_(when @created? - [:button {:class (stl/css :copy-btn) - :title (tr "modals.create-access-token.copy-token") - :on-click copy-token} - [:span {:class (stl/css :token-value)} (:token created "")] - [:span {:class (stl/css :icon)} - i/clipboard]])]] - - [:div {:class (stl/css :modal-footer)} - [:div {:class (stl/css :action-buttons)} - - (if @created? - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.close") - :on-click modal/hide!}] - [:* - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click modal/hide!}] - [:> fm/submit-button* - {:large? false :label (tr "modals.create-access-token.submit-label")}]])]]]]])) - -(mf/defc access-tokens-hero - [] - (let [on-click (mf/use-fn #(st/emit! (modal/show :access-token {})))] - [:div {:class (stl/css :access-tokens-hero)} - [:h2 {:class (stl/css :hero-title)} (tr "dashboard.access-tokens.personal")] - [:p {:class (stl/css :hero-desc)} (tr "dashboard.access-tokens.personal.description")] - - [:button {:class (stl/css :hero-btn) - :on-click on-click} - (tr "dashboard.access-tokens.create")]])) - -(mf/defc access-token-actions - [{:keys [on-delete]}] - (let [local (mf/use-state {:menu-open false}) - show? (:menu-open @local) - options (mf/with-memo [on-delete] - [{:name (tr "labels.delete") - :id "access-token-delete" - :handler on-delete}]) - - menu-ref (mf/use-ref) - - on-menu-close - (mf/use-fn #(swap! local assoc :menu-open false)) - - on-menu-click - (mf/use-fn - (fn [event] - (dom/prevent-default event) - (swap! local assoc :menu-open true))) - - on-keydown - (mf/use-fn - (mf/deps on-menu-click) - (fn [event] - (when (kbd/enter? event) - (dom/stop-propagation event) - (on-menu-click event))))] - - [:button {:class (stl/css :menu-btn) - :tab-index "0" - :ref menu-ref - :on-click on-menu-click - :on-key-down on-keydown} - menu-icon - [:> context-menu* - {:on-close on-menu-close - :show show? - :fixed true - :min-width true - :top "auto" - :left "auto" - :options options}]])) - -(mf/defc access-token-item - {::mf/wrap [mf/memo]} - [{:keys [token] :as props}] - (let [expires-at (:expires-at token) - expires-txt (some-> expires-at (ct/format-inst "PPP")) - expired? (and (some? expires-at) (> (ct/now) expires-at)) - - delete-fn - (mf/use-fn - (mf/deps token) - (fn [] - (let [params {:id (:id token)} - mdata {:on-success #(st/emit! (du/fetch-access-tokens))}] - (st/emit! (du/delete-access-token (with-meta params mdata)))))) - - on-delete - (mf/use-fn - (mf/deps delete-fn) - (fn [] - (st/emit! (modal/show - {:type :confirm - :title (tr "modals.delete-acces-token.title") - :message (tr "modals.delete-acces-token.message") - :accept-label (tr "modals.delete-acces-token.accept") - :on-accept delete-fn}))))] - - [:div {:class (stl/css :table-row)} - [:div {:class (stl/css :table-field :field-name)} - (str (:name token))] - - [:div {:class (stl/css-case :expiration-date true - :expired expired?)} - (cond - (nil? expires-at) (tr "dashboard.access-tokens.no-expiration") - expired? (tr "dashboard.access-tokens.expired-on" expires-txt) - :else (tr "dashboard.access-tokens.expires-on" expires-txt))] - [:div {:class (stl/css :table-field :actions)} - [:& access-token-actions - {:on-delete on-delete}]]])) - -(mf/defc access-tokens-page - [] - (let [tokens (mf/deref tokens-ref)] - (mf/with-effect [] - (dom/set-html-title (tr "title.settings.access-tokens")) - (st/emit! (du/fetch-access-tokens))) - - [:div {:class (stl/css :dashboard-access-tokens)} - [:& access-tokens-hero] - (if (empty? tokens) - [:div {:class (stl/css :access-tokens-empty)} - [:div (tr "dashboard.access-tokens.empty.no-access-tokens")] - [:div (tr "dashboard.access-tokens.empty.add-one")]] - [:div {:class (stl/css :dashboard-table)} - [:div {:class (stl/css :table-rows)} - (for [token tokens] - [:& access-token-item {:token token :key (:id token)}])]])])) - diff --git a/frontend/src/app/main/ui/settings/access_tokens.scss b/frontend/src/app/main/ui/settings/access_tokens.scss deleted file mode 100644 index 5e9f139765..0000000000 --- a/frontend/src/app/main/ui/settings/access_tokens.scss +++ /dev/null @@ -1,202 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. -// -// Copyright (c) KALEIDOS INC - -@use "refactor/common-refactor.scss" as deprecated; - -// ACCESS TOKENS PAGE -.dashboard-access-tokens { - display: grid; - grid-template-rows: auto 1fr; - margin: deprecated.$s-80 auto deprecated.$s-120 auto; - gap: deprecated.$s-32; - width: deprecated.$s-800; -} - -// hero -.access-tokens-hero { - display: grid; - grid-template-rows: auto auto 1fr; - gap: deprecated.$s-32; - width: deprecated.$s-500; - font-size: deprecated.$fs-14; - margin: deprecated.$s-16 auto 0 auto; -} - -.hero-title { - @include deprecated.bigTitleTipography; - color: var(--title-foreground-color-hover); -} - -.hero-desc { - color: var(--title-foreground-color); - margin-bottom: 0; - font-size: deprecated.$fs-14; -} - -.hero-btn { - @extend .button-primary; -} - -// table empty -.access-tokens-empty { - display: grid; - place-items: center; - align-content: center; - height: deprecated.$s-156; - max-width: deprecated.$s-1000; - width: 100%; - padding: deprecated.$s-32; - border: deprecated.$s-1 solid var(--panel-border-color); - border-radius: deprecated.$br-8; - color: var(--dashboard-list-text-foreground-color); -} - -// Access tokens table -.dashboard-table { - height: fit-content; -} - -.table-rows { - display: grid; - grid-auto-rows: deprecated.$s-64; - gap: deprecated.$s-16; - width: 100%; - height: 100%; - max-width: deprecated.$s-1000; - margin-top: deprecated.$s-16; - color: var(--title-foreground-color); -} - -.table-row { - display: grid; - grid-template-columns: 43% 1fr auto; - align-items: center; - height: deprecated.$s-64; - width: 100%; - padding: 0 deprecated.$s-16; - border-radius: deprecated.$br-8; - background-color: var(--dashboard-list-background-color); - color: var(--dashboard-list-foreground-color); -} - -.field-name { - @include deprecated.textEllipsis; - display: grid; - width: 43%; - min-width: deprecated.$s-300; -} - -.expiration-date { - @include deprecated.flexCenter; - min-width: deprecated.$s-76; - width: fit-content; - height: deprecated.$s-24; - border-radius: deprecated.$br-8; - color: var(--dashboard-list-text-foreground-color); -} - -.expired { - @include deprecated.headlineSmallTypography; - padding: 0 deprecated.$s-6; - color: var(--pill-foreground-color); - background-color: var(--status-widget-background-color-warning); -} - -.actions { - position: relative; -} -.menu-icon { - @extend .button-icon; - stroke: var(--icon-foreground); -} - -.menu-btn { - @include deprecated.buttonStyle; -} - -// Create access token modal -.modal-overlay { - @extend .modal-overlay-base; -} - -.modal-container { - @extend .modal-container-base; - min-width: deprecated.$s-408; -} - -.modal-header { - margin-bottom: deprecated.$s-24; -} - -.modal-title { - @include deprecated.uppercaseTitleTipography; - color: var(--modal-title-foreground-color); -} -.modal-close-btn { - @extend .modal-close-btn-base; -} - -.modal-content { - @include deprecated.flexColumn; - gap: deprecated.$s-24; - @include deprecated.bodySmallTypography; - margin-bottom: deprecated.$s-24; -} - -.select-title { - @include deprecated.bodySmallTypography; - color: var(--modal-title-foreground-color); -} - -.custon-input-wrapper { - @include deprecated.flexRow; - border-radius: deprecated.$br-8; - height: deprecated.$s-32; - background-color: var(--input-background-color); -} - -.custom-input-token { - @extend .input-element; - @include deprecated.bodySmallTypography; - margin: 0; - flex-grow: 1; - &:focus { - outline: none; - border: deprecated.$s-1 solid var(--input-border-color-active); - } -} - -.token-value { - @include deprecated.textEllipsis; - @include deprecated.bodySmallTypography; - flex-grow: 1; -} - -.copy-btn { - @include deprecated.flexCenter; - @extend .button-secondary; - height: deprecated.$s-28; - width: deprecated.$s-28; -} - -.clipboard-icon { - @extend .button-icon-small; -} - -.token-created-info { - color: var(--modal-text-foreground-color); -} - -.action-buttons { - @extend .modal-action-btns; - button { - @extend .modal-accept-btn; - } -} - -.cancel-button { - @extend .modal-cancel-btn; -} diff --git a/frontend/src/app/main/ui/settings/integrations.cljs b/frontend/src/app/main/ui/settings/integrations.cljs new file mode 100644 index 0000000000..780f9918e3 --- /dev/null +++ b/frontend/src/app/main/ui/settings/integrations.cljs @@ -0,0 +1,635 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.settings.integrations + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data.macros :as dm] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.config :as cf] + [app.main.broadcast :as mbc] + [app.main.data.event :as ev] + [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] + [app.main.data.profile :as du] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.components.context-menu-a11y :refer [context-menu*]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.input :refer [input*]] + [app.main.ui.ds.controls.switch :refer [switch*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.ds.notifications.shared.notification-pill :refer [notification-pill*]] + [app.main.ui.ds.tooltip :refer [tooltip*]] + [app.main.ui.forms :as fc] + [app.util.clipboard :as clipboard] + [app.util.dom :as dom] + [app.util.forms :as fm] + [app.util.i18n :as i18n :refer [tr]] + [okulary.core :as l] + [rumext.v2 :as mf])) + +(def tokens-ref + (l/derived :access-tokens st/state)) + +(def token-created-ref + (l/derived :access-token-created st/state)) + +(def notification-timeout 7000) + +(def ^:private schema:form-access-token + [:map + [:name [::sm/text {:max 250}]] + [:expiration-date [::sm/text {:max 250}]]]) + +(def ^:private schema:form-mcp-key + [:map + [:expiration-date [::sm/text {:max 250}]]]) + +(def form-initial-data-access-token + {:name "" + :expiration-date "never"}) + +(def form-initial-data-mcp-key + {:expiration-date "never"}) + +(mf/defc input-copy* + {::mf/private true} + [{:keys [value on-copy-to-clipboard]}] + [:div {:class (stl/css :input-copy)} + [:> input* {:type "text" + :default-value value + :read-only true}] + [:div {:class (stl/css :input-copy-button-wrapper)} + [:> icon-button* {:variant "secondary" + :class (stl/css :input-copy-button) + :aria-label (tr "integrations.copy-to-clipboard") + :on-click on-copy-to-clipboard + :icon i/clipboard}]]]) + +(mf/defc token-created* + {::mf/private true} + [{:keys [title mcp-key?]}] + (let [token-created (mf/deref token-created-ref) + + on-copy-to-clipboard + (mf/use-fn + (mf/deps token-created) + (fn [event] + (dom/prevent-default event) + (clipboard/to-clipboard (:token token-created)) + (st/emit! (ntf/show {:level :info + :type :toast + :content (if mcp-key? + (tr "integrations.notification.success.mcp-key-copied") + (tr "integrations.notification.success.token-copied")) + :timeout notification-timeout}))))] + + [:div {:class (stl/css :modal-form)} + [:> text* {:as "h2" + :typography t/headline-large + :class (stl/css :color-primary)} + title] + + [:> notification-pill* {:level :info + :type :context} + [:> text* {:as "div" + :typography t/body-small + :class (stl/css :color-primary)} + (if mcp-key? + (tr "integrations.mcp-key.info.non-recuperable") + (tr "integrations.token.info.non-recuperable"))]] + + [:div {:class (stl/css :modal-content)} + [:> input-copy* {:value (:token token-created "") + :on-copy-to-clipboard on-copy-to-clipboard}] + + [:> text* {:as "div" + :typography t/body-small + :class (stl/css :color-secondary)} + (if (:expires-at token-created) + (if mcp-key? + (tr "integrations.mcp-key.will-expire" (ct/format-inst (:expires-at token-created) "PPP")) + (tr "integrations.token.will-expire" (ct/format-inst (:expires-at token-created) "PPP"))) + (if mcp-key? + (tr "integrations.mcp-key.will-not-expire") + (tr "integrations.token.will-not-expire")))]] + + (when mcp-key? + [:div {:class (stl/css :modal-content)} + [:> text* {:as "div" + :typography t/body-small + :class (stl/css :color-primary)} + (tr "integrations.info.mcp-client-config")] + [:textarea {:class (stl/css :textarea) + :wrap "off" + :rows 7 + :read-only true} + (dm/str + "{\n" + " \"mcpServers\": {\n" + " \"penpot\": {\n" + " \"url\": \"" cf/mcp-server-url "?userToken=" (:token token-created "") "\"\n" + " }\n" + " }" + "\n}")]]) + + [:div {:class (stl/css :modal-footer)} + [:> button* {:variant "secondary" + :on-click modal/hide!} + (tr "labels.close")]]])) + +(mf/defc create-token* + {::mf/private true} + [{:keys [title info mcp-key? on-created]}] + (let [form (fm/use-form + :initial (if mcp-key? + form-initial-data-mcp-key + form-initial-data-access-token) + :schema (if mcp-key? + schema:form-mcp-key + schema:form-access-token)) + + on-error + (mf/use-fn + #(st/emit! (ntf/error (tr "errors.generic")) + (modal/hide))) + + on-success + (mf/use-fn + #(st/emit! (du/fetch-access-tokens) + (ntf/success (tr "integrations.notification.success.created")) + (on-created))) + + on-submit + (mf/use-fn + (fn [form] + (let [cdata (:clean-data @form) + mdata {:on-success (partial on-success form) + :on-error (partial on-error form)} + expiration (:expiration-date cdata) + params (cond-> {:name (:name cdata) + :perms (:perms cdata)} + (not= "never" expiration) (assoc :expiration expiration) + (true? mcp-key?) (assoc :type "mcp" + :name "MCP key"))] + (st/emit! (du/create-access-token (with-meta params mdata))))))] + + [:> fc/form* {:form form + :class (stl/css :modal-form) + :on-submit on-submit} + + [:> text* {:as "h2" + :typography t/headline-large + :class (stl/css :color-primary)} + title] + + (when (some? info) + [:> notification-pill* {:level :info + :type :context} + [:> text* {:as "div" + :typography t/body-small + :class (stl/css :color-primary)} + info]]) + + (if mcp-key? + [:div {:class (stl/css :modal-content)} + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + (tr "integrations.info.mcp-server")]] + + [:div {:class (stl/css :modal-content)} + [:> fc/form-input* {:type "text" + :auto-focus? true + :form form + :name :name + :label (tr "integrations.name.label") + :placeholder (tr "integrations.name.placeholder")}]]) + + [:div {:class (stl/css :modal-content)} + [:> text* {:as "label" + :typography t/body-small + :for :expiration-date + :class (stl/css :color-primary)} + (tr "integrations.expiration-date.label")] + [:> fc/form-select* {:options [{:label (tr "integrations.expiration-never") :value "never" :id "never"} + {:label (tr "integrations.expiration-30-days") :value "720h" :id "720h"} + {:label (tr "integrations.expiration-60-days") :value "1440h" :id "1440h"} + {:label (tr "integrations.expiration-90-days") :value "2160h" :id "2160h"} + {:label (tr "integrations.expiration-180-days") :value "4320h" :id "4320h"}] + :default-selected "never" + :name :expiration-date}]] + + [:div {:class (stl/css :modal-footer)} + [:> button* {:variant "secondary" + :on-click modal/hide!} + (tr "labels.cancel")] + [:> fc/form-submit* {:variant "primary"} + title]]])) + +(mf/defc create-access-token-modal + {::mf/register modal/components + ::mf/register-as :create-access-token} + [] + (let [created? (mf/use-state false) + + on-close + (mf/use-fn + (fn [] + (reset! created? false) + (st/emit! (modal/hide)))) + + on-created + (mf/use-fn + #(reset! created? true))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-close-button)} + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close}]] + + (if @created? + [:> token-created* {:title (tr "integrations.create-access-token.title.created")}] + [:> create-token* {:title (tr "integrations.create-access-token.title") + :on-created on-created}])]])) + +(mf/defc generate-mcp-key-modal + {::mf/register modal/components + ::mf/register-as :generate-mcp-key} + [] + (let [created? (mf/use-state false) + + on-close + (mf/use-fn + (fn [] + (reset! created? false) + (st/emit! (modal/hide)))) + + on-created + (mf/use-fn + (fn [] + (st/emit! (du/update-profile-props {:mcp-enabled true}) + (ev/event {::ev/name "generate-mcp-key" + ::ev/origin "integrations"}) + (ev/event {::ev/name "enable-mcp" + ::ev/origin "integrations" + :source "key-creation"}) + (mbc/event :mcp/enable {})) + (reset! created? true)))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-close-button)} + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close}]] + + (if @created? + [:> token-created* {:title (tr "integrations.generate-mcp-key.title.created") + :mcp-key? true}] + [:> create-token* {:title (tr "integrations.generate-mcp-key.title") + :mcp-key? true + :on-created on-created}])]])) + +(mf/defc regenerate-mcp-key-modal + {::mf/register modal/components + ::mf/register-as :regenerate-mcp-key} + [] + (let [created? (mf/use-state false) + + tokens (mf/deref tokens-ref) + mcp-key (some #(when (= (:type %) "mcp") %) tokens) + mcp-key-id (:id mcp-key) + + on-close + (mf/use-fn + (fn [] + (reset! created? false) + (st/emit! (modal/hide)))) + + on-created + (mf/use-fn + (fn [] + (st/emit! (du/delete-access-token {:id mcp-key-id}) + (du/update-profile-props {:mcp-enabled true}) + (ev/event {::ev/name "regenerate-mcp-key" + ::ev/origin "integrations"}) + (mbc/event :mcp/enable {})) + (reset! created? true)))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-close-button)} + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close}]] + + (if @created? + [:> token-created* {:title (tr "integrations.regenerate-mcp-key.title.created") + :mcp-key? true}] + [:> create-token* {:title (tr "integrations.regenerate-mcp-key.title") + :info (tr "integrations.regenerate-mcp-key.info") + :mcp-key? true + :on-created on-created}])]])) + +(mf/defc token-item* + {::mf/private true + ::mf/wrap [mf/memo]} + [{:keys [name expires-at on-delete]}] + (let [expires-txt (some-> expires-at (ct/format-inst "PPP")) + expired? (and (some? expires-at) (> (ct/now) expires-at)) + + menu-open* (mf/use-state false) + menu-open? (deref menu-open*) + + handle-menu-close + (mf/use-fn + #(reset! menu-open* false)) + + handle-menu-click + (mf/use-fn + #(reset! menu-open* (not menu-open?))) + + handle-open-confirm-modal + (mf/use-fn + (mf/deps on-delete) + (fn [] + (st/emit! (modal/show {:type :confirm + :title (tr "integrations.delete-token.title") + :message (tr "integrations.delete-token.message") + :accept-label (tr "integrations.delete-token.accept") + :on-accept on-delete})))) + + options + (mf/with-memo [on-delete] + [{:name (tr "labels.delete") + :id "token-delete" + :handler handle-open-confirm-modal}])] + + [:div {:class (stl/css :item)} + [:> text* {:as "div" + :typography t/body-medium + :title name + :class (stl/css :item-title)} + name] + + [:> text* {:as "div" + :typography t/body-small + :class (stl/css-case :item-subtitle true + :warning expired?)} + (cond + (nil? expires-at) (tr "integrations.no-expiration") + expired? (tr "integrations.expired-on" expires-txt) + :else (tr "integrations.expires-on" expires-txt))] + + [:div {:class (stl/css :item-actions)} + [:> icon-button* {:variant "ghost" + :class (stl/css :item-button) + :aria-pressed menu-open? + :aria-label (tr "labels.options") + :on-click handle-menu-click + :icon i/menu}] + [:> context-menu* {:on-close handle-menu-close + :show menu-open? + :min-width true + :top -10 + :left -138 + :options options}]]])) + +(mf/defc mcp-server-section* + {::mf/private true} + [] + (let [tokens (mf/deref tokens-ref) + profile (mf/deref refs/profile) + + mcp-key (some #(when (= (:type %) "mcp") %) tokens) + mcp-enabled? (true? (-> profile :props :mcp-enabled)) + + expires-at (:expires-at mcp-key) + expired? (and (some? expires-at) (> (ct/now) expires-at)) + + tooltip-id + (mf/use-id) + + handle-mcp-change + (mf/use-fn + (fn [value] + (st/emit! (du/update-profile-props {:mcp-enabled value}) + (ntf/show {:level :info + :type :toast + :content (if (true? value) + (tr "integrations.notification.success.mcp-server-enabled") + (tr "integrations.notification.success.mcp-server-disabled")) + :timeout notification-timeout}) + (ev/event {::ev/name (if (true? value) "enable-mcp" "disable-mcp") + ::ev/origin "integrations" + :source "toggle"}) + (if value + (mbc/event :mcp/enable {}) + (mbc/event :mcp/disable {}))))) + + handle-generate-mcp-key + (mf/use-fn + #(st/emit! (modal/show {:type :generate-mcp-key}))) + + handle-regenerate-mcp-key + (mf/use-fn + #(st/emit! (modal/show {:type :regenerate-mcp-key}))) + + handle-delete + (mf/use-fn + (mf/deps mcp-key) + (fn [] + (let [params {:id (:id mcp-key)} + mdata {:on-success #(st/emit! (du/fetch-access-tokens))}] + (st/emit! (du/delete-access-token (with-meta params mdata)) + (du/update-profile-props {:mcp-enabled false}) + (mbc/event :mcp/disable {}))))) + + on-copy-to-clipboard + (mf/use-fn + (fn [event] + (dom/prevent-default event) + (clipboard/to-clipboard cf/mcp-server-url) + (st/emit! (ntf/show {:level :info + :type :toast + :content (tr "integrations.notification.success.copied-link") + :timeout notification-timeout}) + (ev/event {::ev/name "copy-mcp-url" + ::ev/origin "integrations"}))))] + + [:section {:class (stl/css :mcp-server-section)} + [:div + [:div {:class (stl/css :title)} + [:> heading* {:level 2 + :typography t/title-medium + :class (stl/css :color-primary :mcp-server-title)} + (tr "integrations.mcp-server.title")] + [:> text* {:as "span" + :typography t/body-small + :class (stl/css :beta)} + (tr "integrations.mcp-server.title.beta")]] + + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + (tr "integrations.mcp-server.description")]] + + [:div + [:> text* {:as "h3" + :typography t/headline-small + :class (stl/css :color-primary)} + (tr "integrations.mcp-server.status")] + + [:div {:class (stl/css :mcp-server-block)} + (when expired? + [:> notification-pill* {:level :error + :type :context} + [:div {:class (stl/css :mcp-server-notification)} + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-primary)} + (tr "integrations.mcp-server.status.expired.0")] + + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-primary)} + (tr "integrations.mcp-server.status.expired.1")]]]) + + [:div {:class (stl/css :mcp-server-switch)} + [:> switch* {:label (if mcp-enabled? + (tr "integrations.mcp-server.status.enabled") + (tr "integrations.mcp-server.status.disabled")) + :default-checked mcp-enabled? + :on-change handle-mcp-change}] + (when (and (false? mcp-enabled?) (nil? mcp-key)) + [:div {:class (stl/css :mcp-server-switch-cover) + :on-click handle-generate-mcp-key}])]]] + + (when (some? mcp-key) + [:div {:class (stl/css :mcp-server-key)} + [:> text* {:as "h3" + :typography t/headline-small + :class (stl/css :color-primary)} + (tr "integrations.mcp-server.mcp-keys.title")] + + [:div {:class (stl/css :mcp-server-block)} + [:div {:class (stl/css :mcp-server-regenerate)} + [:> button* {:variant "primary" + :class (stl/css :fit-content) + :on-click handle-regenerate-mcp-key} + (tr "integrations.mcp-server.mcp-keys.regenerate")] + [:> tooltip* {:content (tr "integrations.mcp-server.mcp-keys.tootip") + :id tooltip-id} + [:> icon* {:icon-id i/info + :class (stl/css :color-secondary)}]]] + + [:div {:class (stl/css :list)} + [:> token-item* {:key (:id mcp-key) + :name (:name mcp-key) + :expires-at (:expires-at mcp-key) + :on-delete handle-delete}]]]]) + + [:> notification-pill* {:level :default + :type :context} + [:div {:class (stl/css :mcp-server-notification)} + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + (tr "integrations.mcp-server.mcp-keys.info")] + + [:> input-copy* {:value (dm/str cf/mcp-server-url "?userToken=") + :on-copy-to-clipboard on-copy-to-clipboard}] + + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + [:a {:href cf/mcp-help-center-uri + :target "_blank" + :rel "noopener noreferrer" + :class (stl/css :mcp-server-notification-link)} + (tr "integrations.mcp-server.mcp-keys.help") [:> icon* {:icon-id i/open-link}]]]]]])) + +(mf/defc access-tokens-section* + {::mf/private true} + [] + (let [tokens (mf/deref tokens-ref) + + handle-click + (mf/use-fn + #(st/emit! (modal/show {:type :create-access-token}))) + + handle-delete + (mf/use-fn + (fn [token-id] + (let [params {:id token-id} + mdata {:on-success #(st/emit! (du/fetch-access-tokens))}] + (st/emit! (du/delete-access-token (with-meta params mdata))))))] + + [:section {:class (stl/css :access-tokens-section)} + [:> heading* {:level 2 + :typography t/title-medium + :class (stl/css :color-primary)} + (tr "integrations.access-tokens.personal")] + + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + (tr "integrations.access-tokens.personal.description")] + + [:> button* {:variant "primary" + :class (stl/css :fit-content) + :on-click handle-click} + (tr "integrations.access-tokens.create")] + + (if (empty? tokens) + [:div {:class (stl/css :frame)} + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary :text-center)} + [:div (tr "integrations.access-tokens.empty.no-access-tokens")] + [:div (tr "integrations.access-tokens.empty.add-one")]]] + + [:div {:class (stl/css :list)} + (for [token tokens] + (when (nil? (:type token)) + [:> token-item* {:key (:id token) + :name (:name token) + :expires-at (:expires-at token) + :on-delete (partial handle-delete (:id token))}]))])])) + +(mf/defc integrations-page* + [] + (mf/with-effect [] + (dom/set-html-title (tr "title.settings.integrations")) + (st/emit! (du/fetch-access-tokens))) + + [:div {:class (stl/css :integrations)} + [:> heading* {:level 1 + :typography t/title-large + :class (stl/css :color-primary)} + (tr "integrations.title")] + + (when (contains? cf/flags :mcp) + [:> mcp-server-section*]) + + (when (and (contains? cf/flags :mcp) + (contains? cf/flags :access-tokens)) + [:hr {:class (stl/css :separator)}]) + + (when (contains? cf/flags :access-tokens) + [:> access-tokens-section*])]) diff --git a/frontend/src/app/main/ui/settings/integrations.scss b/frontend/src/app/main/ui/settings/integrations.scss new file mode 100644 index 0000000000..d7be475bb4 --- /dev/null +++ b/frontend/src/app/main/ui/settings/integrations.scss @@ -0,0 +1,239 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "refactor/common-refactor.scss" as deprecated; + +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/typography.scss" as t; + +.color-primary { + color: var(--color-foreground-primary); +} + +.color-secondary { + color: var(--color-foreground-secondary); +} + +.text-center { + text-align: center; +} + +.fit-content { + inline-size: fit-content; +} + +.beta { + color: var(--color-accent-primary); + border: $b-1 solid var(--color-accent-primary); + inline-size: fit-content; + padding: var(--sp-xxs) var(--sp-s); + border-radius: $br-4; +} + +.title { + display: flex; + flex-direction: row; + align-items: baseline; + gap: var(--sp-s); +} + +.modal-overlay { + @extend .modal-overlay-base; +} + +.modal-container { + @extend .modal-container-base; + inline-size: $sz-400; + max-block-size: fit-content; + position: relative; +} + +.modal-content { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.modal-form { + display: flex; + flex-direction: column; + gap: var(--sp-xxxl); +} + +.modal-close-button { + position: absolute; + top: var(--sp-s); + right: var(--sp-s); +} + +.modal-footer { + display: flex; + justify-content: right; + gap: var(--sp-s); +} + +.input-copy { + position: relative; +} + +.input-copy-button-wrapper { + position: absolute; + top: 0; + right: 0; + border-start-start-radius: 0; + border-end-start-radius: 0; +} + +.input-copy-button { + border-radius: 0 $br-8 $br-8 0; +} + +.integrations { + display: grid; + grid-template-rows: auto 1fr; + margin: $sz-88 auto $sz-120 auto; + gap: $sz-32; + inline-size: $sz-500; +} + +.access-tokens-section { + display: grid; + grid-template-rows: auto auto 1fr; + gap: var(--sp-m); +} + +.mcp-server-section { + display: flex; + flex-direction: column; + gap: var(--sp-l); +} + +.mcp-server-key { + display: flex; + flex-direction: column; +} + +.mcp-server-notification { + display: flex; + flex-direction: column; + gap: var(--sp-m); + padding-right: var(--sp-xxl); +} + +.mcp-server-notification-link { + cursor: pointer; + color: var(--color-accent-primary); + display: flex; + flex-direction: row; + align-items: center; + gap: var(--sp-xs); +} + +.mcp-server-title { + margin: var(--sp-s) 0; +} + +.mcp-server-block { + display: flex; + flex-direction: column; + gap: var(--sp-l); +} + +.mcp-server-regenerate { + display: flex; + align-items: center; + gap: var(--sp-s); +} + +.mcp-server-switch { + position: relative; +} + +.mcp-server-switch-cover { + position: absolute; + inset-block: 0; + inset-inline: 0; +} + +.separator { + border: $b-1 solid var(--color-background-quaternary); + margin: var(--sp-s) 0; +} + +.frame { + border: $b-1 solid var(--color-background-quaternary); + padding: var(--sp-m); + border-radius: $br-8; +} + +.list { + display: grid; + grid-auto-rows: $sz-64; + gap: var(--sp-m); +} + +.item { + display: grid; + grid-template-columns: 45% 1fr auto; + align-items: center; + background-color: var(--color-background-tertiary); + border-radius: $br-8; +} + +.item-title { + @include textEllipsis; + align-content: center; + block-size: $sz-64; + padding: 0 var(--sp-l); + color: var(--color-foreground-primary); +} + +.item-subtitle { + align-content: center; + block-size: $sz-64; + color: var(--color-foreground-secondary); + + &.warning { + padding: var(--sp-s) var(--sp-m); + block-size: fit-content; + inline-size: fit-content; + color: var(--color-foreground-primary); + background-color: var(--color-background-warning); + border: $b-1 solid var(--color-accent-warning); + border-radius: $br-8; + } +} + +.item-actions { + position: relative; +} + +.item-button { + block-size: $sz-64; + inline-size: $sz-48; + border-radius: 0 var(--sp-s) var(--sp-s) 0; +} + +.textarea { + @include t.use-typography("body-small"); + border-radius: $br-8; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-secondary); + padding: var(--sp-xs) var(--sp-s); + border: 0; + resize: none; + + &:hover { + background-color: var(--color-background-quaternary); + } + + &:focus-visible { + outline: $b-1 solid var(--color-accent-primary); + } +} diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs index 0808e2299d..49ffcb6d19 100644 --- a/frontend/src/app/main/ui/settings/sidebar.cljs +++ b/frontend/src/app/main/ui/settings/sidebar.cljs @@ -43,8 +43,8 @@ (def ^:private go-settings-subscription #(st/emit! (rt/nav :settings-subscription))) -(def ^:private go-settings-access-tokens - #(st/emit! (rt/nav :settings-access-tokens))) +(def ^:private go-settings-integrations + #(st/emit! (rt/nav :settings-integrations))) (def ^:private go-settings-notifications #(st/emit! (rt/nav :settings-notifications))) @@ -66,7 +66,7 @@ options? (= section :settings-options) feedback? (= section :settings-feedback) subscription? (= section :settings-subscription) - access-tokens? (= section :settings-access-tokens) + integrations? (= section :settings-integrations) notifications? (= section :settings-notifications) team-id (or (dtm/get-last-team-id) (:default-team-id profile)) @@ -115,12 +115,13 @@ :data-testid "settings-subscription"} [:span {:class (stl/css :element-title)} (tr "subscription.labels")]]) - (when (contains? cf/flags :access-tokens) - [:li {:class (stl/css-case :current access-tokens? + (when (or (contains? cf/flags :access-tokens) + (contains? cf/flags :mcp)) + [:li {:class (stl/css-case :current integrations? :settings-item true) - :on-click go-settings-access-tokens - :data-testid "settings-access-tokens"} - [:span {:class (stl/css :element-title)} (tr "labels.access-tokens")]]) + :on-click go-settings-integrations + :data-testid "settings-integrations"} + [:span {:class (stl/css :element-title)} (tr "labels.integrations")]]) [:hr {:class (stl/css :sidebar-separator)}] diff --git a/frontend/src/app/main/ui/workspace/left_header.cljs b/frontend/src/app/main/ui/workspace/left_header.cljs index 08f1488d9f..09f34268a5 100644 --- a/frontend/src/app/main/ui/workspace/left_header.cljs +++ b/frontend/src/app/main/ui/workspace/left_header.cljs @@ -15,7 +15,6 @@ [app.main.refs :as refs] [app.main.router :as rt] [app.main.store :as st] - [app.main.ui.context :as ctx] [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.main-menu :as main-menu] [app.util.dom :as dom] @@ -27,12 +26,10 @@ ;; --- Header Component (mf/defc left-header* - [{:keys [file layout project page-id class]}] - (let [profile (mf/deref refs/profile) - file-id (:id file) + [{:keys [file layout project class]}] + (let [file-id (:id file) file-name (:name file) project-id (:id project) - team-id (:team-id project) shared? (:is-shared file) persistence (mf/deref refs/persistence) @@ -40,8 +37,6 @@ persistence-status (get persistence :status) - read-only? (mf/use-ctx ctx/workspace-read-only?) - editing* (mf/use-state false) editing? (deref editing*) input-ref (mf/use-ref nil) @@ -137,10 +132,5 @@ (when ^boolean shared? [:span {:class (stl/css :shared-badge)} deprecated-icon/library]) [:div {:class (stl/css :menu-section)} - [:& main-menu/menu - {:layout layout - :file file - :profile profile - :read-only? read-only? - :team-id team-id - :page-id page-id}]]])) + [:> main-menu/menu* {:layout layout + :file file}]]])) diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index a964d27475..4d16c79646 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.common :as dcm] @@ -22,6 +23,7 @@ [app.main.data.shortcuts :as scd] [app.main.data.workspace :as dw] [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.shortcuts :as sc] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.versions :as dwv] @@ -34,22 +36,31 @@ [app.main.ui.dashboard.subscription :refer [get-subscription-type main-menu-power-up*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.hooks.resize :as r] - [app.main.ui.icons :as deprecated-icon] [app.plugins.register :as preg] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [beicon.v2.core :as rx] + [okulary.core :as l] [potok.v2.core :as ptk] [rumext.v2 :as mf])) -;; --- Header menu and submenus +(def tokens-ref + (l/derived :access-tokens st/state)) + +(mf/defc shortcuts* + {::mf/private true} + [{:keys [id]}] + [:span {:class (stl/css :shortcut)} + (for [sc (scd/split-sc (sc/get-tooltip id))] + [:span {:class (stl/css :shortcut-key) + :key sc} + sc])]) (mf/defc help-info-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [layout on-close]}] (let [nav-to-helpc-center @@ -100,6 +111,9 @@ plugins? (features/active-feature? @st/state "plugins/runtime") + mcp? + (contains? cf/flags :mcp) + show-shortcuts (mf/use-fn (mf/deps layout) @@ -115,213 +129,206 @@ (mf/use-fn (fn [event] (let [version (:main cf/version)] - (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version})) + (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" + :version version})) (println version) (if (and (kbd/alt? event) (kbd/mod? event)) (st/emit! (modal/show {:type :onboarding})) - (st/emit! (modal/show {:type :release-notes :version version}))))))] + (st/emit! (modal/show {:type :release-notes + :version version}))))))] [:> dropdown-menu* {:show true - ;; :id "workspace-help-menu" :on-close on-close - :class (stl/css-case :sub-menu true - :help-info plugins? - :help-info-old (not plugins?))} - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + :class (stl/css-case :base-menu true + :sub-menu true + :pos-final-5 (not (or plugins? mcp?)) + :pos-final-6 (not= plugins? mcp?) + :pos-final-7 (and plugins? mcp?))} + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-helpc-center :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-helpc-center event))) :id "file-menu-help-center"} - [:span {:class (stl/css :item-name)} (tr "labels.help-center")]] + [:span {:class (stl/css :item-name)} + (tr "labels.help-center")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-community :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-community event))) :id "file-menu-community"} - [:span {:class (stl/css :item-name)} (tr "labels.community")]] + [:span {:class (stl/css :item-name)} + (tr "labels.community")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-youtube :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-youtube event))) :id "file-menu-youtube"} - [:span {:class (stl/css :item-name)} (tr "labels.tutorials")]] + [:span {:class (stl/css :item-name)} + (tr "labels.tutorials")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click show-release-notes :on-key-down (fn [event] (when (kbd/enter? event) (show-release-notes event))) :id "file-menu-release-notes"} - [:span {:class (stl/css :item-name)} (tr "labels.release-notes")]] + [:span {:class (stl/css :item-name)} + (tr "labels.release-notes")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-templates :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-templates event))) :id "file-menu-templates"} - [:span {:class (stl/css :item-name)} (tr "labels.libraries-and-templates")]] + [:span {:class (stl/css :item-name)} + (tr "labels.libraries-and-templates")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-github :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-github event))) :id "file-menu-github"} - [:span {:class (stl/css :item-name)} (tr "labels.github-repo")]] + [:span {:class (stl/css :item-name)} + (tr "labels.github-repo")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-terms :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-terms event))) :id "file-menu-terms"} - [:span {:class (stl/css :item-name)} (tr "auth.terms-of-service")]] + [:span {:class (stl/css :item-name)} + (tr "auth.terms-of-service")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click show-shortcuts :on-key-down (fn [event] (when (kbd/enter? event) (show-shortcuts event))) :id "file-menu-shortcuts"} - [:span {:class (stl/css :item-name)} (tr "label.shortcuts")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :show-shortcuts))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:span {:class (stl/css :item-name)} + (tr "label.shortcuts")] + [:> shortcuts* {:id :show-shortcuts}]] (when (contains? cf/flags :user-feedback) - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-feedback :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-feedback event))) :id "file-menu-feedback"} - [:span {:class (stl/css-case :feedback true - :item-name true)} (tr "labels.give-feedback")]])])) + [:span {:class (stl/css :feedback :item-name)} + (tr "labels.give-feedback")]])])) (mf/defc preferences-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [layout profile toggle-flag on-close toggle-theme]}] - (let [show-nudge-options (mf/use-fn #(modal/show! {:type :nudge-option}))] + (let [show-nudge-options + (mf/use-fn + #(modal/show! {:type :nudge-option}))] [:> dropdown-menu* {:show true - ;; :id "workspace-preferences-menu" - :class (stl/css-case :sub-menu true - :preferences true) + :class (stl/css :base-menu :sub-menu :pos-4) :on-close on-close} [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "scale-text" + :data-testid "scale-text" :id "file-menu-scale-text"} [:span {:class (stl/css :item-name)} (if (contains? layout :scale-text) (tr "workspace.header.menu.disable-scale-content") (tr "workspace.header.menu.enable-scale-content"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :scale))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :scale}]] [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "snap-ruler-guides" + :data-testid "snap-ruler-guides" :id "file-menu-snap-ruler-guides"} [:span {:class (stl/css :item-name)} (if (contains? layout :snap-ruler-guides) (tr "workspace.header.menu.disable-snap-ruler-guides") (tr "workspace.header.menu.enable-snap-ruler-guides"))] - [:span {:class (stl/css :shortcut)} - - (for [sc (scd/split-sc (sc/get-tooltip :toggle-snap-ruler-guide))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-snap-ruler-guide}]] [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "snap-guides" + :data-testid "snap-guides" :id "file-menu-snap-guides"} [:span {:class (stl/css :item-name)} (if (contains? layout :snap-guides) (tr "workspace.header.menu.disable-snap-guides") (tr "workspace.header.menu.enable-snap-guides"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-snap-guides))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-snap-guides}]] [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "dynamic-alignment" + :data-testid "dynamic-alignment" :id "file-menu-dynamic-alignment"} [:span {:class (stl/css :item-name)} (if (contains? layout :dynamic-alignment) (tr "workspace.header.menu.disable-dynamic-alignment") (tr "workspace.header.menu.enable-dynamic-alignment"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-alignment))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-alignment}]] [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "snap-pixel-grid" + :data-testid "snap-pixel-grid" :id "file-menu-pixel-grid"} [:span {:class (stl/css :item-name)} (if (contains? layout :snap-pixel-grid) (tr "workspace.header.menu.disable-snap-pixel-grid") (tr "workspace.header.menu.enable-snap-pixel-grid"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :snap-pixel-grid))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :snap-pixel-grid}]] [:> dropdown-menu-item* {:on-click show-nudge-options - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (show-nudge-options event))) - :data-testid "snap-pixel-grid" + :data-testid "snap-pixel-grid" :id "file-menu-nudge"} [:span {:class (stl/css :item-name)} (tr "modals.nudge-title")]] - [:> dropdown-menu-item* {:on-click toggle-theme - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-theme event))) - :data-testid "toggle-theme" + :data-testid "toggle-theme" :id "file-menu-toggle-theme"} [:span {:class (stl/css :item-name)} (case (:theme profile) ;; dark -> light -> system -> dark and so on "dark" (tr "workspace.header.menu.toggle-light-theme") - "light" (tr "workspace.header.menu.toggle-system-theme") + "light" (tr "workspace.header.menu.toggle-system-theme") "system" (tr "workspace.header.menu.toggle-dark-theme") (tr "workspace.header.menu.toggle-light-theme"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-theme))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]]])) + [:> shortcuts* {:id :toggle-theme}]]])) (mf/defc view-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [layout toggle-flag on-close]}] (let [read-only? (mf/use-ctx ctx/workspace-read-only?) @@ -343,46 +350,40 @@ (vary-meta assoc ::ev/origin "workspace-menu")))))] [:> dropdown-menu* {:show true - ;; :id "workspace-view-menu" - :class (stl/css-case :sub-menu true - :view true) + :class (stl/css :base-menu :sub-menu :pos-3) :on-close on-close} - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "rulers" + :data-testid "rulers" :id "file-menu-rulers"} [:span {:class (stl/css :item-name)} (if (contains? layout :rulers) (tr "workspace.header.menu.hide-rules") (tr "workspace.header.menu.show-rules"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-rulers))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-rulers}]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "display-guides" + :data-testid "display-guides" :id "file-menu-guides"} [:span {:class (stl/css :item-name)} (if (contains? layout :display-guides) (tr "workspace.header.menu.hide-guides") (tr "workspace.header.menu.show-guides"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-guides))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-guides}]] (when-not ^boolean read-only? [:* - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-color-palette :on-key-down (fn [event] (when (kbd/enter? event) @@ -392,11 +393,9 @@ (if (contains? layout :colorpalette) (tr "workspace.header.menu.hide-palette") (tr "workspace.header.menu.show-palette"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-colorpalette))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-colorpalette}]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-text-palette :on-key-down (fn [event] (when (kbd/enter? event) @@ -406,68 +405,68 @@ (if (contains? layout :textpalette) (tr "workspace.header.menu.hide-textpalette") (tr "workspace.header.menu.show-textpalette"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-textpalette))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]]]) + [:> shortcuts* {:id :toggle-textpalette}]]]) - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "display-artboard-names" + :data-testid "display-artboard-names" :id "file-menu-artboards"} [:span {:class (stl/css :item-name)} (if (contains? layout :display-artboard-names) (tr "workspace.header.menu.hide-artboard-names") (tr "workspace.header.menu.show-artboard-names"))]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "show-pixel-grid" + :data-testid "show-pixel-grid" :id "file-menu-pixel-grid"} [:span {:class (stl/css :item-name)} (if (contains? layout :show-pixel-grid) (tr "workspace.header.menu.hide-pixel-grid") (tr "workspace.header.menu.show-pixel-grid"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :show-pixel-grid))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :show-pixel-grid}]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "hide-ui" + :data-testid "hide-ui" :id "file-menu-hide-ui"} [:span {:class (stl/css :item-name)} (tr "workspace.shape.menu.hide-ui")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :hide-ui))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]]])) + [:> shortcuts* {:id :hide-ui}]]])) (mf/defc edit-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [on-close]}] - (let [select-all (mf/use-fn #(st/emit! (dw/select-all))) - undo (mf/use-fn #(st/emit! dwu/undo)) - redo (mf/use-fn #(st/emit! dwu/redo)) - perms (mf/use-ctx ctx/permissions) - can-edit (:can-edit perms)] + (let [perms (mf/use-ctx ctx/permissions) + can-edit (:can-edit perms) + + select-all + (mf/use-fn + #(st/emit! (dw/select-all))) + + undo + (mf/use-fn + #(st/emit! dwu/undo)) + + redo + (mf/use-fn + #(st/emit! dwu/redo))] [:> dropdown-menu* {:show true - ;; :id "workspace-edit-menu" - :class (stl/css-case :sub-menu true - :edit true) + :class (stl/css :base-menu :sub-menu :pos-2) :on-close on-close} - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click select-all :on-key-down (fn [event] (when (kbd/enter? event) @@ -475,45 +474,32 @@ :id "file-menu-select-all"} [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.select-all")] - [:span {:class (stl/css :shortcut)} - - (for [sc (scd/split-sc (sc/get-tooltip :select-all))] - [:span {:class (stl/css :shortcut-key) - :key sc} - sc])]] + [:> shortcuts* {:id :select-all}]] (when can-edit - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click undo :on-key-down (fn [event] (when (kbd/enter? event) (undo event))) :id "file-menu-undo"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.undo")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :undo))] - [:span {:class (stl/css :shortcut-key) - :key sc} - sc])]]) + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.undo")] + [:> shortcuts* {:id :undo}]]) (when can-edit - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click redo :on-key-down (fn [event] (when (kbd/enter? event) (redo event))) :id "file-menu-redo"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.redo")] - [:span {:class (stl/css :shortcut)} - - (for [sc (scd/split-sc (sc/get-tooltip :redo))] - [:span {:class (stl/css :shortcut-key) - :key sc} - sc])]])])) + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.redo")] + [:> shortcuts* {:id :redo}]])])) (mf/defc file-menu* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [on-close file]}] (let [file-id (:id file) shared? (:is-shared file) @@ -536,12 +522,11 @@ (fn [event] (dom/prevent-default event) (dom/stop-propagation event) - (modal/show! - {:type :delete-shared-libraries - :origin :unpublish - :ids #{file-id} - :on-accept #(st/emit! (dwl/set-file-shared file-id false)) - :count-libraries 1}))) + (modal/show! {:type :delete-shared-libraries + :origin :unpublish + :ids #{file-id} + :on-accept #(st/emit! (dwl/set-file-shared file-id false)) + :count-libraries 1}))) on-remove-shared-key-down (mf/use-fn @@ -590,7 +575,8 @@ (on-pin-version event)))) on-export-shapes - (mf/use-fn #(st/emit! (de/show-workspace-export-dialog {:origin "workspace:menu"}))) + (mf/use-fn + #(st/emit! (de/show-workspace-export-dialog {:origin "workspace:menu"}))) on-export-shapes-key-down (mf/use-fn @@ -627,14 +613,12 @@ (on-export-frames event))))] [:> dropdown-menu* {:show true - ;; :id "workspace-file-menu" - :class (stl/css-case :sub-menu true - :file true) + :class (stl/css :base-menu :sub-menu :pos-1) :on-close on-close} (if ^boolean shared? (when can-edit - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-remove-shared :on-key-down on-remove-shared-key-down :id "file-menu-remove-shared"} @@ -642,7 +626,7 @@ (tr "dashboard.unpublish-shared")]]) (when can-edit - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-add-shared :on-key-down on-add-shared-key-down :id "file-menu-add-shared"} @@ -653,35 +637,32 @@ [:* [:div {:class (stl/css :separator)}] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-pin-version :on-key-down on-pin-version-key-down :id "file-menu-create-version"} [:span {:class (stl/css :item-name)} (tr "dashboard.create-version-menu")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-show-version-history :on-key-down on-show-version-history-key-down :id "file-menu-show-version-history"} [:span {:class (stl/css :item-name)} (tr "dashboard.show-version-history")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-history))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-history}]] [:div {:class (stl/css :separator)}]]) - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-export-shapes :on-key-down on-export-shapes-key-down :id "file-menu-export-shapes"} - [:span {:class (stl/css :item-name)} (tr "dashboard.export-shapes")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :export-shapes))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:span {:class (stl/css :item-name)} + (tr "dashboard.export-shapes")] + [:> shortcuts* {:id :export-shapes}]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-export-file :on-key-down on-export-file-key-down :data-format "binfile-v3" @@ -690,7 +671,7 @@ (tr "dashboard.download-binary-file")]] (when (seq frames) - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-export-frames :on-key-down on-export-frames-key-down :id "file-menu-export-frames"} @@ -698,30 +679,26 @@ (tr "dashboard.export-frames")]])])) (mf/defc plugins-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [open-plugins on-close]}] (when (features/active-feature? @st/state "plugins/runtime") - (let [plugins (preg/plugins-list) - user-can-edit? (:can-edit (deref refs/permissions)) - permissions-peek (deref refs/plugins-permissions-peek)] + (let [plugins (preg/plugins-list) + user-can-edit? (:can-edit (deref refs/permissions)) + permissions-peek (deref refs/plugins-permissions-peek)] [:> dropdown-menu* {:show true - ;; :id "workspace-plugins-menu" - :class (stl/css-case :sub-menu true :plugins true) + :class (stl/css :base-menu :sub-menu :pos-5 :plugins) :on-close on-close} [:> dropdown-menu-item* {:on-click open-plugins - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (open-plugins event))) - :data-testid "open-plugins" + :data-testid "open-plugins" :id "file-menu-open-plugins"} [:span {:class (stl/css :item-name)} (tr "workspace.plugins.menu.plugins-manager")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :plugins))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :plugins}]] (when (d/not-empty? plugins) @@ -756,28 +733,103 @@ :name name :host host})) (dp/open-plugin! manifest user-can-edit?)))))] + [:> dropdown-menu-item* {:key (dm/str "plugins-menu-" idx) :on-click on-click - :class (stl/css-case :submenu-item true :menu-disabled (not can-open?)) + :class (stl/css-case :base-menu-item true + :submenu-item true + :disabled (not can-open?)) :on-key-down on-key-down} [:span {:class (stl/css :item-name)} name] (when-not can-open? - [:span {:class (stl/css :item-icon) - :title (tr "workspace.plugins.error.need-editor")} deprecated-icon/help])]))]))) + [:span {:title (tr "workspace.plugins.error.need-editor")} + [:> icon* {:icon-id i/help + :class (stl/css :item-icon)}]])]))]))) -(mf/defc menu - {::mf/props :obj} - [{:keys [layout file profile]}] - (let [show-menu* (mf/use-state false) - show-menu? (deref show-menu*) - sub-menu* (mf/use-state false) - sub-menu (deref sub-menu*) +(mf/defc mcp-menu* + {::mf/private true} + [{:keys [on-close]}] + (let [plugins? (features/active-feature? @st/state "plugins/runtime") - open-menu + profile (mf/deref refs/profile) + mcp (mf/deref refs/mcp) + + mcp-enabled? (true? (-> profile :props :mcp-enabled)) + mcp-connected? (= "connected" (get mcp :connection-status)) + + on-nav-to-integrations + (mf/use-fn + (fn [] + (st/emit! (ptk/event ::ev/event {::ev/name "manage-mpc-option" + ::ev/origin "workspace-menu"})) + (dom/open-new-window "/#/settings/integrations"))) + + on-nav-to-integrations-key-down + (mf/use-fn + (fn [event] + (when (kbd/enter? event) + (on-nav-to-integrations)))) + + on-toggle-mcp-plugin + (mf/use-fn + (fn [] + (if mcp-connected? + (st/emit! (mcp/user-disconnect-mcp) + (ptk/event ::ev/event {::ev/name "disconnect-mcp-plugin" + ::ev/origin "workspace-menu"})) + (st/emit! (mcp/connect-mcp) + (ptk/event ::ev/event {::ev/name "connect-mcp-plugin" + ::ev/origin "workspace-menu"}))))) + + on-toggle-mcp-plugin-key-down + (mf/use-fn + (fn [event] + (when (kbd/enter? event) + (on-toggle-mcp-plugin))))] + + [:> dropdown-menu* {:show true + :class (stl/css-case :base-menu true + :sub-menu true + :pos-5 (not plugins?) + :pos-6 plugins?) + :on-close on-close} + + (when mcp-enabled? + [:> dropdown-menu-item* {:id "mcp-menu-toggle-mcp-plugin" + :class (stl/css :base-menu-item :submenu-item) + :on-click on-toggle-mcp-plugin + :on-key-down on-toggle-mcp-plugin-key-down} + [:span {:class (stl/css :item-name)} + (if mcp-connected? + (tr "workspace.header.menu.mcp.plugin.status.disconnect") + (tr "workspace.header.menu.mcp.plugin.status.connect"))]]) + + [:> dropdown-menu-item* {:id "mcp-menu-nav-to-integrations" + :class (stl/css :base-menu-item :submenu-item) + :on-click on-nav-to-integrations + :on-key-down on-nav-to-integrations-key-down} + [:span {:class (stl/css :item-name)} + (if mcp-enabled? + (tr "workspace.header.menu.mcp.server.status.enabled") + (tr "workspace.header.menu.mcp.server.status.disabled"))]]])) + +(mf/defc menu* + [{:keys [layout file]}] + (let [profile (mf/deref refs/profile) + mcp (mf/deref refs/mcp) + + show-menu* (mf/use-state false) + show-menu? (deref show-menu*) + selected-sub-menu* (mf/use-state nil) + selected-sub-menu (deref selected-sub-menu*) + + toggle-menu (mf/use-fn (fn [event] (dom/stop-propagation event) - (reset! show-menu* true))) + (swap! show-menu* not) + (when (not show-menu?) + (reset! selected-sub-menu* nil)))) close-menu (mf/use-fn @@ -789,13 +841,13 @@ (mf/use-fn (fn [event] (dom/stop-propagation event) - (reset! sub-menu* nil))) + (reset! selected-sub-menu* nil))) close-all-menus (mf/use-fn (fn [] (reset! show-menu* false) - (reset! sub-menu* nil))) + (reset! selected-sub-menu* nil))) on-menu-click (mf/use-fn @@ -804,12 +856,13 @@ (let [menu (-> (dom/get-current-target event) (dom/get-data "testid") (keyword))] - (reset! sub-menu* menu)))) + (reset! selected-sub-menu* menu)))) on-power-up-click (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-pricing-click" ::ev/origin "workspace-menu"})) + (st/emit! (ptk/event ::ev/event {::ev/name "explore-pricing-click" + ::ev/origin "workspace-menu"})) (dom/open-new-window "https://penpot.app/pricing"))) toggle-flag @@ -823,7 +876,7 @@ (-> (dw/toggle-layout-flag flag) (vary-meta assoc ::ev/origin "workspace-menu"))) (reset! show-menu* false) - (reset! sub-menu* nil)))) + (reset! selected-sub-menu* nil)))) toggle-theme (mf/use-fn @@ -836,9 +889,10 @@ (fn [event] (dom/stop-propagation event) (reset! show-menu* false) - (reset! sub-menu* nil) + (reset! selected-sub-menu* nil) (st/emit! - (ptk/event ::ev/event {::ev/name "open-plugins-manager" ::ev/origin "workspace:menu"}) + (ptk/event ::ev/event {::ev/name "open-plugins-manager" + ::ev/origin "workspace:menu"}) (modal/show :plugin-management {})))) subscription (:subscription (:props profile)) @@ -853,15 +907,16 @@ [:* [:> icon-button* {:variant "ghost" + :aria-pressed show-menu? :aria-label (tr "shortcut-subsection.main-menu") - :on-click open-menu + :on-click toggle-menu :icon i/menu}] [:> dropdown-menu* {:show show-menu? :id "workspace-menu" :on-close close-menu - :class (stl/css :menu)} - [:> dropdown-menu-item* {:class (stl/css :menu-item) + :class (stl/css :base-menu :menu)} + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) @@ -869,111 +924,156 @@ :on-pointer-enter on-menu-click :data-testid "file" :id "file-menu-file"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.file")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.file")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] - [:> dropdown-menu-item* {:class (stl/css :menu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "edit" + :data-testid "edit" :id "file-menu-edit"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.edit")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.edit")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] - [:> dropdown-menu-item* {:class (stl/css :menu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "view" + :data-testid "view" :id "file-menu-view"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.view")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.view")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] - [:> dropdown-menu-item* {:class (stl/css :menu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "preferences" + :data-testid "preferences" :id "file-menu-preferences"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.preferences")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.preferences")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] (when (features/active-feature? @st/state "plugins/runtime") - [:> dropdown-menu-item* {:class (stl/css :menu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "plugins" + :data-testid "plugins" :id "file-menu-plugins"} - [:span {:class (stl/css :item-name)} (tr "workspace.plugins.menu.title")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]]) + [:span {:class (stl/css :item-name)} + (tr "workspace.plugins.menu.title")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]]) + + (when (contains? cf/flags :mcp) + (let [tokens (mf/deref tokens-ref) + expired? (some->> tokens + (some #(when (= (:type %) "mcp") %)) + :expires-at + (> (ct/now))) + + mcp-enabled? (true? (-> profile :props :mcp-enabled)) + mcp-connection (get mcp :connection-status) + mcp-connected? (= mcp-connection "connected") + mcp-error? (= mcp-connection "error") + + active? (and mcp-enabled? mcp-connected?) + failed? (or (and mcp-enabled? mcp-error?) + (true? expired?))] + + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) + :on-click on-menu-click + :on-key-down (fn [event] + (when (kbd/enter? event) + (on-menu-click event))) + :on-pointer-enter on-menu-click + :data-testid "mcp" + :id "file-menu-mcp"} + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.mcp")] + [:span {:class (stl/css-case :item-indicator true + :active active? + :failed failed?)}] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]])) [:div {:class (stl/css :separator)}] - [:> dropdown-menu-item* {:class (stl/css-case :menu-item true) + + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "help-info" + :data-testid "help-info" :id "file-menu-help-info"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.help-info")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.help-info")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] - (when (and (contains? cf/flags :subscriptions) (not= "enterprise" subscription-type)) + (when (and (contains? cf/flags :subscriptions) + (not= "enterprise" subscription-type)) [:> main-menu-power-up* {:close-sub-menu close-sub-menu}]) ;; TODO remove this block when subscriptions is full implemented (when (contains? cf/flags :subscriptions-old) - [:> dropdown-menu-item* {:class (stl/css-case :menu-item true) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-power-up-click :on-key-down (fn [event] (when (kbd/enter? event) (on-power-up-click))) :on-pointer-enter close-sub-menu :id "file-menu-power-up"} - [:span {:class (stl/css :item-name)} (tr "subscription.workspace.header.menu.option.power-up")]])] + [:span {:class (stl/css :item-name)} + (tr "subscription.workspace.header.menu.option.power-up")]])] - (case sub-menu + (case selected-sub-menu :file [:> file-menu* {:file file :on-close close-sub-menu}] :edit - [:> edit-menu* - {:on-close close-sub-menu}] + [:> edit-menu* {:on-close close-sub-menu}] :view - [:> view-menu* - {:layout layout - :toggle-flag toggle-flag - :on-close close-sub-menu}] + [:> view-menu* {:layout layout + :toggle-flag toggle-flag + :on-close close-sub-menu}] :preferences - [:> preferences-menu* - {:layout layout - :profile profile - :toggle-flag toggle-flag - :toggle-theme toggle-theme - :on-close close-sub-menu}] + [:> preferences-menu* {:layout layout + :profile profile + :toggle-flag toggle-flag + :toggle-theme toggle-theme + :on-close close-sub-menu}] :plugins - [:> plugins-menu* - {:open-plugins open-plugins-manager - :on-close close-sub-menu}] + [:> plugins-menu* {:open-plugins open-plugins-manager + :on-close close-sub-menu}] + + :mcp + [:> mcp-menu* {:on-close close-sub-menu}] :help-info - [:> help-info-menu* - {:layout layout - :on-close close-sub-menu}] + [:> help-info-menu* {:layout layout + :on-close close-sub-menu}] nil)])) diff --git a/frontend/src/app/main/ui/workspace/main_menu.scss b/frontend/src/app/main/ui/workspace/main_menu.scss index 7deccc70ed..1b12e2cdbf 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.scss +++ b/frontend/src/app/main/ui/workspace/main_menu.scss @@ -4,125 +4,178 @@ // // Copyright (c) KALEIDOS INC -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as t; +@use "ds/z-index.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; + +.base-menu { + position: absolute; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + padding: var(--sp-xs); + border-radius: $br-8; + z-index: var(--z-index-dropdown); + background-color: var(--menu-background-color); + border: $b-2 solid var(--panel-border-color); + box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color); +} .menu { - @extend .menu-dropdown; - top: deprecated.$s-48; - left: calc(var(--right-sidebar-width, deprecated.$s-256) - deprecated.$s-16); - width: deprecated.$s-192; - margin: 0; -} - -.menu-item { - @extend .menu-item-base; - cursor: pointer; - - .open-arrow { - @include deprecated.flexCenter; - - svg { - @extend .button-icon; - stroke: var(--icon-foreground); - } - } - - &:hover { - color: var(--menu-foreground-color-hover); - - .open-arrow { - svg { - stroke: var(--menu-foreground-color-hover); - } - } - - .shortcut-key { - color: var(--menu-shortcut-foreground-color-hover); - } - } -} - -.separator { - border-top: deprecated.$s-1 solid var(--color-background-quaternary); - height: deprecated.$s-4; - left: calc(-1 * deprecated.$s-4); - margin-top: deprecated.$s-8; - position: relative; - width: calc(100% + deprecated.$s-8); -} - -.shortcut { - @extend .shortcut-base; -} - -.shortcut-key { - @extend .shortcut-key-base; + top: $sz-48; + left: calc(var(--right-sidebar-width) - $sz-40); + inline-size: $sz-192; } .sub-menu { - @extend .menu-dropdown; - left: calc(var(--right-sidebar-width, deprecated.$s-256) + deprecated.$s-180); - width: deprecated.$s-192; - min-width: calc(deprecated.$s-272 - deprecated.$s-2); - width: 110%; + left: calc(var(--right-sidebar-width) + $sz-154); + min-width: $sz-284; + width: 115%; - .submenu-item { - @extend .menu-item-base; - - &:hover { - color: var(--menu-foreground-color-hover); - - .shortcut-key { - color: var(--menu-shortcut-foreground-color-hover); - } - } + &.pos-1 { + top: calc($sz-16 + $sz-32); } - .menu-disabled { - color: var(--color-foreground-secondary); - - &:hover { - cursor: default; - color: var(--color-foreground-secondary); - background-color: var(--menu-background-color); - } + &.pos-2 { + top: calc($sz-16 + (2 * $sz-32)); } - &.file { - top: deprecated.$s-48; + &.pos-3 { + top: calc($sz-16 + (3 * $sz-32)); } - &.edit { - top: deprecated.$s-76; + &.pos-4 { + top: calc($sz-16 + (4 * $sz-32)); } - &.view { - top: deprecated.$s-116; + &.pos-5 { + top: calc($sz-16 + (5 * $sz-32)); } - &.preferences { - top: deprecated.$s-148; + &.pos-6 { + top: calc($sz-16 + (6 * $sz-32)); + } + + &.pos-final-5 { + top: calc($sz-32 + (5 * $sz-32)); + } + + &.pos-final-6 { + top: calc($sz-32 + (6 * $sz-32)); + } + + &.pos-final-7 { + top: calc($sz-32 + (7 * $sz-32)); } &.plugins { - top: deprecated.$s-180; - max-height: calc(100vh - deprecated.$s-180); + max-height: calc(100vh - $sz-200); overflow-x: hidden; overflow-y: auto; } +} - &.help-info { - top: deprecated.$s-232; +.base-menu-item { + @include t.use-typography("body-small"); + display: grid; + align-items: center; + grid-template-columns: auto $sz-16 $sz-16; + grid-template-areas: "name indicator arrow"; + block-size: $sz-28; + inline-size: 100%; + padding: $sz-6; + border-radius: $br-8; + color: var(--menu-foreground-color); + background-color: var(--menu-background-color); + + &:hover { + --menu-foreground-color: var(--menu-foreground-color-hover); + --menu-background-color: var(--menu-background-color-hover); + --menu-shortcut-foreground-color: var(--menu-shortcut-foreground-color-hover); + --menu-icon-foreground-color: var(--menu-foreground-color-hover); } - &.help-info-old { - top: deprecated.$s-192; + &.disabled { + --menu-foreground-color: var(--color-foreground-secondary); + pointer-events: none; } } +.menu-item { + display: grid; + align-items: center; + grid-template-columns: auto $sz-16 $sz-16; + grid-template-areas: "name indicator arrow"; +} + +.submenu-item { + display: flex; + align-items: center; + justify-content: space-between; +} + +.item-name { + grid-area: name; +} + +.item-indicator { + --menu-indicator-color: var(--color-foreground-secondary); + grid-area: indicator; + display: flex; + align-items: center; + justify-content: center; + inline-size: px2rem(8); + block-size: px2rem(8); + border-radius: $br-circle; + background-color: var(--menu-indicator-color); + + &.active { + --menu-indicator-color: var(--color-accent-primary); + } + + &.failed { + --menu-indicator-color: var(--color-foreground-error); + } +} + +.item-arrow { + grid-area: arrow; + color: var(--menu-icon-foreground-color); +} + .item-icon { - svg { - @extend .button-icon; - stroke: var(--icon-foreground); - } + color: var(--menu-icon-foreground-color); + display: flex; + align-items: center; + justify-content: center; +} + +.separator { + position: relative; + block-size: var(--sp-xs); + inline-size: calc(100% + var(--sp-s)); + border-top: $b-1 solid var(--color-background-quaternary); + left: calc(-1 * var(--sp-xs)); + margin-top: var(--sp-s); +} + +.shortcut { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-xxs); + color: var(--menu-shortcut-foreground-color); +} + +.shortcut-key { + @include t.use-typography("body-small"); + display: flex; + align-items: center; + justify-content: center; + height: px2rem(20); + padding: var(--sp-xxs) px2rem(6); + border-radius: $br-6; + background-color: var(--menu-shortcut-background-color); } diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs index f7278fd650..ac219faa2f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar.cljs @@ -119,7 +119,7 @@ (mf/defc left-sidebar* {::mf/memo true} - [{:keys [layout file page-id tokens-lib active-tokens resolved-active-tokens]}] + [{:keys [layout file tokens-lib active-tokens resolved-active-tokens]}] (let [options-mode (mf/deref refs/options-mode-global) project (mf/deref refs/project) file-id (get file :id) @@ -185,12 +185,10 @@ :class aside-class :style {:--left-sidebar-width (dm/str width "px")}} - [:> left-header* - {:file file - :layout layout - :project project - :page-id page-id - :class (stl/css :left-header)}] + [:> left-header* {:file file + :layout layout + :project project + :class (stl/css :left-header)}] [:div {:on-pointer-down on-pointer-down :on-lost-pointer-capture on-lost-pointer-capture diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index c870baf9fb..8efa0ba66c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -291,7 +291,7 @@ :r4 "Bottom Left" :r3 "Bottom Right"} :hint (tr "workspace.tokens.radius") - :on-update-shape-all dwta/update-shape-radius-all + :on-update-shape-all dwta/update-shape-radius :on-update-shape update-shape-radius-for-corners}) shadow (partial generic-attribute-actions #{:shadow} "Shadow")] {:border-radius border-radius diff --git a/frontend/src/app/plugins.cljs b/frontend/src/app/plugins.cljs index 36f31ff110..3ce022753e 100644 --- a/frontend/src/app/plugins.cljs +++ b/frontend/src/app/plugins.cljs @@ -8,6 +8,7 @@ "RPC for plugins runtime." (:require ["@penpot/plugins-runtime" :as runtime] + [app.main.errors :as errors] [app.main.features :as features] [app.main.store :as st] [app.plugins.api :as api] @@ -30,6 +31,8 @@ (ptk/reify ::initialize ptk/WatchEvent (watch [_ _ stream] + (set! errors/is-plugin-error? runtime/isPluginError) + (->> stream (rx/filter (ptk/type? ::features/initialize)) (rx/observe-on :async) diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index febb24e4ba..68526ae8a4 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -14,9 +14,11 @@ [app.common.geom.point :as gpt] [app.common.schema :as sm] [app.common.types.color :as ctc] + [app.common.types.component :as ctk] [app.common.types.shape :as cts] [app.common.types.text :as txt] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.changes :as ch] [app.main.data.common :as dcm] [app.main.data.helpers :as dsh] @@ -26,6 +28,7 @@ [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.variants :as dwv] [app.main.data.workspace.wasm-text :as dwwt] [app.main.features :as features] [app.main.fonts :refer [fetch-font-css]] @@ -82,6 +85,10 @@ :$plugin {:enumerable false :get (fn [] plugin-id)} ;; Public properties + :version + {:this true + :get (constantly (:base cf/version))} + :root {:this true :get #(.getRoot ^js %)} @@ -110,7 +117,7 @@ (fn [_ shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :selection shapes) + (u/not-valid plugin-id :selection shapes) :else (let [ids (into (d/ordered-set) (map #(obj/get % "$id")) shapes)] @@ -175,7 +182,7 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :shapesColors-shapes shapes) + (u/not-valid plugin-id :shapesColors-shapes shapes) :else (let [objects (u/locate-objects) @@ -195,13 +202,13 @@ new-color (parser/parse-color-data new-color)] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :replaceColor-shapes shapes) + (u/not-valid plugin-id :replaceColor-shapes shapes) (not (sm/validate ctc/schema:color old-color)) - (u/display-not-valid :replaceColor-oldColor old-color) + (u/not-valid plugin-id :replaceColor-oldColor old-color) (not (sm/validate ctc/schema:color new-color)) - (u/display-not-valid :replaceColor-newColor new-color) + (u/not-valid plugin-id :replaceColor-newColor new-color) :else (let [file-id (:current-file-id @st/state) @@ -254,10 +261,10 @@ (fn [name url] (cond (not (string? name)) - (u/display-not-valid :uploadMedia-name name) + (u/not-valid plugin-id :uploadMedia-name name) (not (string? url)) - (u/display-not-valid :uploadMedia-url url) + (u/not-valid plugin-id :uploadMedia-url url) :else (let [file-id (:current-file-id @st/state)] @@ -288,7 +295,7 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :group-shapes shapes) + (u/not-valid plugin-id :group-shapes shapes) :else (let [file-id (:current-file-id @st/state) @@ -303,10 +310,10 @@ (fn [group & rest] (cond (not (shape/shape-proxy? group)) - (u/display-not-valid :ungroup group) + (u/not-valid plugin-id :ungroup group) (and (some? rest) (not (every? shape/shape-proxy? rest))) - (u/display-not-valid :ungroup rest) + (u/not-valid plugin-id :ungroup rest) :else (let [shapes (concat [group] rest) @@ -346,7 +353,7 @@ (fn [text] (cond (or (not (string? text)) (empty? text)) - (u/display-not-valid :createText text) + (u/not-valid plugin-id :createText text) :else (let [page (dsh/lookup-page @st/state) @@ -377,7 +384,7 @@ (fn [svg-string] (cond (or (not (string? svg-string)) (empty? svg-string)) - (u/display-not-valid :createShapeFromSvg svg-string) + (u/not-valid plugin-id :createShapeFromSvg svg-string) :else (let [id (uuid/next) @@ -394,7 +401,7 @@ (cond (or (not (string? svg-string)) (empty? svg-string)) (do - (u/display-not-valid :createShapeFromSvg "Svg not valid") + (u/not-valid plugin-id :createShapeFromSvg "Svg not valid") (reject "Svg not valid")) :else @@ -412,10 +419,10 @@ (let [bool-type (keyword bool-type)] (cond (not (contains? cts/bool-types bool-type)) - (u/display-not-valid :createBoolean-boolType bool-type) + (u/not-valid plugin-id :createBoolean-boolType bool-type) (or (not (array? shapes)) (empty? shapes) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :createBoolean-shapes shapes) + (u/not-valid plugin-id :createBoolean-shapes shapes) :else (let [ids (into #{} (map #(obj/get % "$id")) shapes) @@ -429,10 +436,10 @@ (let [type (d/nilv (obj/get options "type") "html")] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :generateMarkup-shapes shapes) + (u/not-valid plugin-id :generateMarkup-shapes shapes) (and (some? type) (not (contains? #{"html" "svg"} type))) - (u/display-not-valid :generateMarkup-type type) + (u/not-valid plugin-id :generateMarkup-type type) :else (let [resolved-code @@ -464,16 +471,16 @@ children? (d/nilv (obj/get options "includeChildren") true)] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :generateStyle-shapes shapes) + (u/not-valid plugin-id :generateStyle-shapes shapes) (and (some? type) (not (contains? #{"css"} type))) - (u/display-not-valid :generateStyle-type type) + (u/not-valid plugin-id :generateStyle-type type) (and (some? prelude?) (not (boolean? prelude?))) - (u/display-not-valid :generateStyle-withPrelude prelude?) + (u/not-valid plugin-id :generateStyle-withPrelude prelude?) (and (some? children?) (not (boolean? children?))) - (u/display-not-valid :generateStyle-includeChildren children?) + (u/not-valid plugin-id :generateStyle-includeChildren children?) :else (let [resolved-styles @@ -546,7 +553,7 @@ :else nil) new-window (if (boolean? new-window) new-window false)] (if (nil? id) - (u/display-not-valid :openPage "Expected a Page object or a page UUID string") + (u/not-valid plugin-id :openPage "Expected a Page object or a page UUID string") (st/emit! (dcm/go-to-workspace :page-id id ::rt/new-window new-window))))) :alignHorizontal @@ -558,10 +565,10 @@ nil)] (cond (nil? dir) - (u/display-not-valid :alignHorizontal-direction "Direction not valid") + (u/not-valid plugin-id :alignHorizontal-direction "Direction not valid") (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :alignHorizontal-shapes "Not valid shapes") + (u/not-valid plugin-id :alignHorizontal-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] @@ -576,10 +583,10 @@ nil)] (cond (nil? dir) - (u/display-not-valid :alignVertical-direction "Direction not valid") + (u/not-valid plugin-id :alignVertical-direction "Direction not valid") (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :alignVertical-shapes "Not valid shapes") + (u/not-valid plugin-id :alignVertical-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] @@ -589,7 +596,7 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :distributeHorizontal-shapes "Not valid shapes") + (u/not-valid plugin-id :distributeHorizontal-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] @@ -599,7 +606,7 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :distributeVertical-shapes "Not valid shapes") + (u/not-valid plugin-id :distributeVertical-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] @@ -609,8 +616,41 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :flatten-shapes "Not valid shapes") + (u/not-valid plugin-id :flatten-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] - (st/emit! (dw/convert-selected-to-path ids))))))) + (st/emit! (dw/convert-selected-to-path ids))))) + + :createVariantFromComponents + (fn [shapes] + (cond + (or (not (seq shapes)) + (not (every? u/is-main-component-proxy? shapes))) + (u/not-valid plugin-id :shapes shapes) + + :else + (let [file-id (obj/get (first shapes) "$file") + page-id (obj/get (first shapes) "$page") + ids (->> shapes + (map #(obj/get % "$id")) + (into #{})) + + ;; Check that every component is: + ;; - in the same page + ;; - not already a variant + valid? + (every? + (fn [id] + (let [shape (u/locate-shape file-id page-id id) + component (u/locate-library-component file-id (:component-id shape))] + (not (ctk/is-variant? component)))) + ids)] + (if valid? + (let [variant-id (uuid/next)] + (st/emit! (dwv/combine-as-variants + ids + {:trigger "plugin:combine-as-variants" :variant-id variant-id})) + (shape/shape-proxy plugin-id variant-id)) + + (u/not-valid plugin-id :shapes "One of the components is not on the same page or is already a variant"))))))) diff --git a/frontend/src/app/plugins/comments.cljs b/frontend/src/app/plugins/comments.cljs index f3cfdcf954..236074142d 100644 --- a/frontend/src/app/plugins/comments.cljs +++ b/frontend/src/app/plugins/comments.cljs @@ -60,13 +60,13 @@ (let [profile (:profile @st/state)] (cond (or (not (string? content)) (empty? content)) - (u/display-not-valid :content "Not valid") + (u/not-valid plugin-id :content "Not valid") (not= (:id profile) (:owner-id data)) - (u/display-not-valid :content "Cannot change content from another user's comments") + (u/not-valid plugin-id :content "Cannot change content from another user's comments") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :content "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :content "Plugin doesn't have 'comment:write' permission") :else (->> (rp/cmd! :update-comment {:id (:id data) :content content}) @@ -81,7 +81,7 @@ (cond (not (r/check-permission plugin-id "comment:write")) (do - (u/display-not-valid :remove "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'comment:write' permission") (reject "Plugin doesn't have 'comment:write' permission")) :else @@ -120,10 +120,10 @@ (cond (or (not (sm/valid-safe-number? (:x position))) (not (sm/valid-safe-number? (:y position)))) - (u/display-not-valid :position "Not valid point") + (u/not-valid plugin-id :position "Not valid point") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :position "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :position "Plugin doesn't have 'comment:write' permission") :else (do (st/emit! (dwc/update-comment-thread-position @data* [(:x position) (:y position)])) @@ -137,10 +137,10 @@ (fn [is-resolved] (cond (not (boolean? is-resolved)) - (u/display-not-valid :resolved "Not a boolean type") + (u/not-valid plugin-id :resolved "Not a boolean type") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :resolved "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :resolved "Plugin doesn't have 'comment:write' permission") :else (do (st/emit! (dc/update-comment-thread (assoc @data* :is-resolved is-resolved))) @@ -153,7 +153,7 @@ (cond (not (r/check-permission plugin-id "comment:read")) (do - (u/display-not-valid :findComments "Plugin doesn't have 'comment:read' permission") + (u/not-valid plugin-id :findComments "Plugin doesn't have 'comment:read' permission") (reject "Plugin doesn't have 'comment:read' permission")) :else @@ -169,10 +169,10 @@ (fn [content] (cond (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :reply "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :reply "Plugin doesn't have 'comment:write' permission") (or (not (string? content)) (empty? content)) - (u/display-not-valid :reply "Not valid") + (u/not-valid plugin-id :reply "Not valid") :else (js/Promise. @@ -186,10 +186,10 @@ owner (dsh/lookup-profile @st/state (:owner-id data))] (cond (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :remove "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'comment:write' permission") (not= (:id profile) owner) - (u/display-not-valid :remove "Cannot change content from another user's comments") + (u/not-valid plugin-id :remove "Cannot change content from another user's comments") :else (js/Promise. diff --git a/frontend/src/app/plugins/file.cljs b/frontend/src/app/plugins/file.cljs index 54fcb2f8c3..15c0bf7188 100644 --- a/frontend/src/app/plugins/file.cljs +++ b/frontend/src/app/plugins/file.cljs @@ -45,10 +45,10 @@ (fn [value] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :label "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :label "Plugin doesn't have 'content:write' permission") (or (not (string? value)) (empty? value)) - (u/display-not-valid :label value) + (u/not-valid plugin-id :label value) :else (do (swap! data assoc :label value :created-by "user") @@ -145,7 +145,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :getPluginData-key key) + (u/not-valid plugin-id :getPluginData-key key) :else (let [file (u/locate-file id)] @@ -155,13 +155,13 @@ (fn [key value] (cond (or (not (string? key)) (empty? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (not (string? value)) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data id :file (keyword "plugin" (str plugin-id)) key value)))) @@ -175,10 +175,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginData-namespace namespace) + (u/not-valid plugin-id :getSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :getSharedPluginData-key key) + (u/not-valid plugin-id :getSharedPluginData-key key) :else (let [file (u/locate-file id)] @@ -188,16 +188,16 @@ (fn [namespace key value] (cond (or (not (string? namespace)) (empty? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (or (not (string? key)) (empty? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (not (string? value)) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data id :file (keyword "shared" namespace) key value)))) @@ -206,7 +206,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginDataKeys namespace) + (u/not-valid plugin-id :getSharedPluginDataKeys namespace) :else (let [file (u/locate-file id)] @@ -216,7 +216,7 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :createPage "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :createPage "Plugin doesn't have 'content:write' permission") :else (let [page-id (uuid/next)] diff --git a/frontend/src/app/plugins/flags.cljs b/frontend/src/app/plugins/flags.cljs index a9f1a6dce7..c28623ac62 100644 --- a/frontend/src/app/plugins/flags.cljs +++ b/frontend/src/app/plugins/flags.cljs @@ -6,17 +6,11 @@ (ns app.plugins.flags (:require - [app.common.data.macros :as dm] [app.main.store :as st] [app.plugins.utils :as u] [app.util.object :as obj] [potok.v2.core :as ptk])) -(defn natural-child-ordering? - [plugin-id] - (boolean - (dm/get-in @st/state [:plugins :flags plugin-id :natural-child-ordering]))) - (defn clear [id] (ptk/reify ::reset @@ -37,13 +31,27 @@ :naturalChildOrdering {:this false :get - (fn [] (natural-child-ordering? plugin-id)) + (fn [] (u/natural-child-ordering? plugin-id)) :set (fn [value] (cond (not (boolean? value)) - (u/display-not-valid :naturalChildOrdering value) + (u/not-valid plugin-id :naturalChildOrdering value) :else - (st/emit! (set-flag plugin-id :natural-child-ordering value))))})) + (st/emit! (set-flag plugin-id :natural-child-ordering value))))} + + :throwValidationErrors + {:this false + :get + (fn [] (u/throw-validation-errors? plugin-id)) + + :set + (fn [value] + (cond + (not (boolean? value)) + (u/not-valid plugin-id :throwValidationErrors value) + + :else + (st/emit! (set-flag plugin-id :throw-validation-errors value))))})) diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs index a1c7ef754c..ff6de68488 100644 --- a/frontend/src/app/plugins/flex.cljs +++ b/frontend/src/app/plugins/flex.cljs @@ -12,7 +12,6 @@ [app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shapes :as dwsh] [app.main.store :as st] - [app.plugins.flags :refer [natural-child-ordering?]] [app.plugins.register :as r] [app.plugins.utils :as u] [app.util.object :as obj])) @@ -39,10 +38,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/flex-direction-types value)) - (u/display-not-valid :dir value) + (u/not-valid plugin-id :dir value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :dir "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :dir "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-flex-dir value})))))} @@ -55,10 +54,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/wrap-types value)) - (u/display-not-valid :wrap value) + (u/not-valid plugin-id :wrap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :wrap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :wrap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-wrap-type value})))))} @@ -71,10 +70,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/align-items-types value)) - (u/display-not-valid :alignItems value) + (u/not-valid plugin-id :alignItems value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignItems "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignItems "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-align-items value})))))} @@ -87,10 +86,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/align-content-types value)) - (u/display-not-valid :alignContent value) + (u/not-valid plugin-id :alignContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-align-content value})))))} @@ -103,10 +102,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/justify-items-types value)) - (u/display-not-valid :justifyItems value) + (u/not-valid plugin-id :justifyItems value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifyItems "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifyItems "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-justify-items value})))))} @@ -119,10 +118,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/justify-content-types value)) - (u/display-not-valid :justifyContent value) + (u/not-valid plugin-id :justifyContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifyContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifyContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-justify-content value})))))} @@ -134,10 +133,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rowGap value) + (u/not-valid plugin-id :rowGap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rowGap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rowGap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-gap {:row-gap value}}))))} @@ -149,10 +148,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :columnGap value) + (u/not-valid plugin-id :columnGap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :columnGap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :columnGap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-gap {:column-gap value}}))))} @@ -164,10 +163,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :verticalPadding value) + (u/not-valid plugin-id :verticalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))} @@ -179,10 +178,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :horizontalPadding value) + (u/not-valid plugin-id :horizontalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))} @@ -195,10 +194,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :topPadding value) + (u/not-valid plugin-id :topPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :topPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :topPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))} @@ -210,10 +209,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rightPadding value) + (u/not-valid plugin-id :rightPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rightPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rightPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))} @@ -225,10 +224,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :bottomPadding value) + (u/not-valid plugin-id :bottomPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :bottomPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :bottomPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))} @@ -240,10 +239,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :leftPadding value) + (u/not-valid plugin-id :leftPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :leftPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :leftPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))} @@ -256,13 +255,13 @@ (fn [child] (cond (not (shape-proxy? child)) - (u/display-not-valid :appendChild child) + (u/not-valid plugin-id :appendChild child) :else (let [child-id (obj/get child "$id") shape (u/locate-shape file-id page-id id) index - (if (and (natural-child-ordering? plugin-id) (not (ctl/reverse? shape))) + (if (and (u/natural-child-ordering? plugin-id) (not (ctl/reverse? shape))) 0 (count (:shapes shape)))] (st/emit! (dwsh/relocate-shapes #{child-id} id index))))) @@ -275,10 +274,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-h-sizing-types value)) - (u/display-not-valid :horizontalSizing value) + (u/not-valid plugin-id :horizontalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))} @@ -291,10 +290,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-v-sizing-types value)) - (u/display-not-valid :verticalSizing value) + (u/not-valid plugin-id :verticalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))})) @@ -317,10 +316,10 @@ (fn [_ value] (cond (not (boolean? value)) - (u/display-not-valid :absolute value) + (u/not-valid plugin-id :absolute value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :absolute "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :absolute "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-absolute value}))))} @@ -332,10 +331,10 @@ (fn [_ value] (cond (sm/valid-safe-int? value) - (u/display-not-valid :zIndex value) + (u/not-valid plugin-id :zIndex value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :zIndex "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :zIndex "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-z-index value}))))} @@ -348,10 +347,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-h-sizing-types value)) - (u/display-not-valid :horizontalPadding value) + (u/not-valid plugin-id :horizontalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-h-sizing value})))))} @@ -364,10 +363,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-v-sizing-types value)) - (u/display-not-valid :verticalSizing value) + (u/not-valid plugin-id :verticalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-v-sizing value})))))} @@ -380,10 +379,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-align-self-types value)) - (u/display-not-valid :alignSelf value) + (u/not-valid plugin-id :alignSelf value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignSelf "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignSelf "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-align-self value})))))} @@ -395,10 +394,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :verticalMargin value) + (u/not-valid plugin-id :verticalMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value :m3 value}}))))} @@ -410,10 +409,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :horizontalMargin value) + (u/not-valid plugin-id :horizontalMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value :m4 value}}))))} @@ -425,10 +424,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :topMargin value) + (u/not-valid plugin-id :topMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :topMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :topMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value}}))))} @@ -440,10 +439,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :rightMargin value) + (u/not-valid plugin-id :rightMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rightMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rightMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value}}))))} @@ -455,10 +454,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :bottomMargin value) + (u/not-valid plugin-id :bottomMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :bottomMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :bottomMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m3 value}}))))} @@ -470,10 +469,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :leftMargin value) + (u/not-valid plugin-id :leftMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :leftMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :leftMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m4 value}}))))} @@ -485,10 +484,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :maxWidth value) + (u/not-valid plugin-id :maxWidth value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :maxWidth "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :maxWidth "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-max-w value}))))} @@ -500,10 +499,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :minWidth value) + (u/not-valid plugin-id :minWidth value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :minWidth "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :minWidth "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-min-w value}))))} @@ -515,10 +514,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :maxHeight value) + (u/not-valid plugin-id :maxHeight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :maxHeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :maxHeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-max-h value}))))} @@ -530,10 +529,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :minHeight value) + (u/not-valid plugin-id :minHeight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :minHeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :minHeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-min-h value}))))})) diff --git a/frontend/src/app/plugins/fonts.cljs b/frontend/src/app/plugins/fonts.cljs index 77602816f6..13996fabc2 100644 --- a/frontend/src/app/plugins/fonts.cljs +++ b/frontend/src/app/plugins/fonts.cljs @@ -32,7 +32,7 @@ (obj/type-of? p "FontProxy")) (defn font-proxy - [{:keys [id family name variants] :as font}] + [plugin-id {:keys [id family name variants] :as font}] (when (some? font) (let [default-variant (fonts/get-default-variant font)] (obj/reify {:name "FontProxy"} @@ -55,10 +55,10 @@ (fn [text variant] (cond (not (shape/shape-proxy? text)) - (u/display-not-valid :applyToText text) + (u/not-valid plugin-id :applyToText text) (not (r/check-permission (obj/get text "$plugin") "content:write")) - (u/display-not-valid :applyToText "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyToText "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get text "$id") @@ -73,10 +73,10 @@ (fn [range variant] (cond (not (text/text-range-proxy? range)) - (u/display-not-valid :applyToRange range) + (u/not-valid plugin-id :applyToRange range) (not (r/check-permission (obj/get range "$plugin") "content:write")) - (u/display-not-valid :applyToRange "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyToRange "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get range "$id") @@ -98,53 +98,53 @@ {:get (fn [] (format/format-array - font-proxy + (partial font-proxy plugin-id) (vals @fonts/fontsdb)))} :findById (fn [id] (cond (not (string? id)) - (u/display-not-valid :findbyId id) + (u/not-valid plugin-id :findbyId id) :else (->> (vals @fonts/fontsdb) (d/seek #(str/includes? (str/lower (:id %)) (str/lower id))) - (font-proxy)))) + (font-proxy plugin-id)))) :findByName (fn [name] (cond (not (string? name)) - (u/display-not-valid :findByName name) + (u/not-valid plugin-id :findByName name) :else (->> (vals @fonts/fontsdb) (d/seek #(str/includes? (str/lower (:name %)) (str/lower name))) - (font-proxy)))) + (font-proxy plugin-id)))) :findAllById (fn [id] (cond (not (string? id)) - (u/display-not-valid :findAllById name) + (u/not-valid plugin-id :findAllById name) :else (format/format-array (fn [font] (when (str/includes? (str/lower (:id font)) (str/lower id)) - (font-proxy font))) + (font-proxy plugin-id font))) (vals @fonts/fontsdb)))) :findAllByName (fn [name] (cond (not (string? name)) - (u/display-not-valid :findAllByName name) + (u/not-valid plugin-id :findAllByName name) :else (format/format-array (fn [font] (when (str/includes? (str/lower (:name font)) (str/lower name)) - (font-proxy font))) + (font-proxy plugin-id font))) (vals @fonts/fontsdb)))))) diff --git a/frontend/src/app/plugins/format.cljs b/frontend/src/app/plugins/format.cljs index 9af11c2dde..f0ff928fc8 100644 --- a/frontend/src/app/plugins/format.cljs +++ b/frontend/src/app/plugins/format.cljs @@ -598,3 +598,10 @@ (case axis :y "horizontal" :x "vertical")) + +(defn format-geom-rect + [{:keys [x y width height]}] + #js {:x x + :y y + :width width + :height height}) diff --git a/frontend/src/app/plugins/grid.cljs b/frontend/src/app/plugins/grid.cljs index f57873ec31..351b673baf 100644 --- a/frontend/src/app/plugins/grid.cljs +++ b/frontend/src/app/plugins/grid.cljs @@ -40,10 +40,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/grid-direction-types value)) - (u/display-not-valid :dir value) + (u/not-valid plugin-id :dir value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :dir "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :dir "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-grid-dir value})))))} @@ -64,10 +64,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/align-items-types value)) - (u/display-not-valid :alignItems value) + (u/not-valid plugin-id :alignItems value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignItems "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignItems "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-align-items value})))))} @@ -80,10 +80,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/align-content-types value)) - (u/display-not-valid :alignContent value) + (u/not-valid plugin-id :alignContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-align-content value})))))} @@ -96,10 +96,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/justify-items-types value)) - (u/display-not-valid :justifyItems value) + (u/not-valid plugin-id :justifyItems value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifyItems "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifyItems "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-justify-items value})))))} @@ -112,10 +112,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/justify-content-types value)) - (u/display-not-valid :justifyContent value) + (u/not-valid plugin-id :justifyContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifyContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifyContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-justify-content value})))))} @@ -127,10 +127,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rowGap value) + (u/not-valid plugin-id :rowGap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rowGap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rowGap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-gap {:row-gap value}}))))} @@ -142,10 +142,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :columnGap value) + (u/not-valid plugin-id :columnGap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :columnGap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :columnGap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-gap {:column-gap value}}))))} @@ -157,10 +157,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :verticalPadding value) + (u/not-valid plugin-id :verticalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))} @@ -172,10 +172,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :horizontalPadding value) + (u/not-valid plugin-id :horizontalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))} @@ -187,10 +187,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :topPadding value) + (u/not-valid plugin-id :topPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :topPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :topPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))} @@ -202,10 +202,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rightPadding value) + (u/not-valid plugin-id :rightPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :righPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :righPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))} @@ -217,10 +217,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :bottomPadding value) + (u/not-valid plugin-id :bottomPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :bottomPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :bottomPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))} @@ -232,10 +232,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :leftPadding value) + (u/not-valid plugin-id :leftPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :leftPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :leftPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))} @@ -245,14 +245,14 @@ (let [type (keyword type)] (cond (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :addRow-type type) + (u/not-valid plugin-id :addRow-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :addRow-value value) + (u/not-valid plugin-id :addRow-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addRow "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addRow "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value}))))) @@ -262,17 +262,17 @@ (let [type (keyword type)] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :addRowAtIndex-index index) + (u/not-valid plugin-id :addRowAtIndex-index index) (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :addRowAtIndex-type type) + (u/not-valid plugin-id :addRowAtIndex-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :addRowAtIndex-value value) + (u/not-valid plugin-id :addRowAtIndex-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addRowAtIndex "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addRowAtIndex "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value} index))))) @@ -282,14 +282,14 @@ (let [type (keyword type)] (cond (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :addColumn-type type) + (u/not-valid plugin-id :addColumn-type type) (and (or (= :percent type) (= :flex type) (= :lex type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :addColumn-value value) + (u/not-valid plugin-id :addColumn-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addColumn "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addColumn "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/add-layout-track #{id} :column {:type type :value value}))))) @@ -298,17 +298,17 @@ (fn [index type value] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :addColumnAtIndex-index index) + (u/not-valid plugin-id :addColumnAtIndex-index index) (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :addColumnAtIndex-type type) + (u/not-valid plugin-id :addColumnAtIndex-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :addColumnAtIndex-value value) + (u/not-valid plugin-id :addColumnAtIndex-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addColumnAtIndex "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addColumnAtIndex "Plugin doesn't have 'content:write' permission") :else (let [type (keyword type)] @@ -318,10 +318,10 @@ (fn [index] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :removeRow index) + (u/not-valid plugin-id :removeRow index) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeRow "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeRow "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/remove-layout-track #{id} :row index)))) @@ -330,10 +330,10 @@ (fn [index] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :removeColumn index) + (u/not-valid plugin-id :removeColumn index) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeColumn "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeColumn "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/remove-layout-track #{id} :column index)))) @@ -343,17 +343,17 @@ (let [type (keyword type)] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :setColumn-index index) + (u/not-valid plugin-id :setColumn-index index) (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :setColumn-type type) + (u/not-valid plugin-id :setColumn-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :setColumn-value value) + (u/not-valid plugin-id :setColumn-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setColumn "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setColumn "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/change-layout-track #{id} :column index (d/without-nils {:type type :value value})))))) @@ -363,17 +363,17 @@ (let [type (keyword type)] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :setRow-index index) + (u/not-valid plugin-id :setRow-index index) (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :setRow-type type) + (u/not-valid plugin-id :setRow-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :setRow-value value) + (u/not-valid plugin-id :setRow-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setRow "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setRow "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/change-layout-track #{id} :row index (d/without-nils {:type type :value value})))))) @@ -382,7 +382,7 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :remove "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/remove-layout #{id})))) @@ -391,16 +391,16 @@ (fn [child row column] (cond (not (shape-proxy? child)) - (u/display-not-valid :appendChild-child child) + (u/not-valid plugin-id :appendChild-child child) (or (< row 0) (not (sm/valid-safe-int? row))) - (u/display-not-valid :appendChild-row row) + (u/not-valid plugin-id :appendChild-row row) (or (< column 0) (not (sm/valid-safe-int? column))) - (u/display-not-valid :appendChild-column column) + (u/not-valid plugin-id :appendChild-column column) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :appendChild "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission") :else (let [child-id (obj/get child "$id")] @@ -432,13 +432,13 @@ shape (u/proxy->shape self)] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :row-value value) + (u/not-valid plugin-id :row-value value) (nil? cell) - (u/display-not-valid :row-cell "cell not found") + (u/not-valid plugin-id :row-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :row "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :row "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row value})))))} @@ -452,13 +452,13 @@ cell (locate-cell self)] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rowSpan-value value) + (u/not-valid plugin-id :rowSpan-value value) (nil? cell) - (u/display-not-valid :rowSpan-cell "cell not found") + (u/not-valid plugin-id :rowSpan-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rowSpan "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rowSpan "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row-span value})))))} @@ -472,13 +472,13 @@ cell (locate-cell self)] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :column-value value) + (u/not-valid plugin-id :column-value value) (nil? cell) - (u/display-not-valid :column-cell "cell not found") + (u/not-valid plugin-id :column-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :column "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :column "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column value})))))} @@ -492,13 +492,13 @@ cell (locate-cell self)] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :columnSpan-value value) + (u/not-valid plugin-id :columnSpan-value value) (nil? cell) - (u/display-not-valid :columnSpan-cell "cell not found") + (u/not-valid plugin-id :columnSpan-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :columnSpan "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :columnSpan "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column-span value})))))} @@ -512,13 +512,13 @@ cell (locate-cell self)] (cond (not (string? value)) - (u/display-not-valid :areaName-value value) + (u/not-valid plugin-id :areaName-value value) (nil? cell) - (u/display-not-valid :areaName-cell "cell not found") + (u/not-valid plugin-id :areaName-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :areaName "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :areaName "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:area-name value})))))} @@ -533,13 +533,13 @@ value (keyword value)] (cond (not (contains? ctl/grid-position-types value)) - (u/display-not-valid :position-value value) + (u/not-valid plugin-id :position-value value) (nil? cell) - (u/display-not-valid :position-cell "cell not found") + (u/not-valid plugin-id :position-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :position "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :position "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/change-cells-mode (:parent-id shape) #{(:id cell)} value)))))} @@ -554,13 +554,13 @@ cell (locate-cell self)] (cond (not (contains? ctl/grid-cell-align-self-types value)) - (u/display-not-valid :alignSelf-value value) + (u/not-valid plugin-id :alignSelf-value value) (nil? cell) - (u/display-not-valid :alignSelf-cell "cell not found") + (u/not-valid plugin-id :alignSelf-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignSelf "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignSelf "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:align-self value})))))} @@ -575,13 +575,13 @@ cell (locate-cell self)] (cond (not (contains? ctl/grid-cell-justify-self-types value)) - (u/display-not-valid :justifySelf-value value) + (u/not-valid plugin-id :justifySelf-value value) (nil? cell) - (u/display-not-valid :justifySelf-cell "cell not found") + (u/not-valid plugin-id :justifySelf-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifySelf "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifySelf "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:justify-self value})))))}))) diff --git a/frontend/src/app/plugins/history.cljs b/frontend/src/app/plugins/history.cljs index 191dcc0d7e..25756c47da 100644 --- a/frontend/src/app/plugins/history.cljs +++ b/frontend/src/app/plugins/history.cljs @@ -24,7 +24,7 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :resize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :resize "Plugin doesn't have 'content:write' permission") :else (let [id (js/Symbol)] @@ -35,10 +35,10 @@ (fn [block-id] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :resize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :resize "Plugin doesn't have 'content:write' permission") (not block-id) - (u/display-not-valid :undoBlockFinish block-id) + (u/not-valid plugin-id :undoBlockFinish block-id) :else (st/emit! (dwu/commit-undo-transaction block-id)))))) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index c3dcd9ef28..1022a1a65c 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -60,10 +60,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :name "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'library:write' permission") :else (let [color (u/proxy->library-color self) @@ -77,10 +77,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :path value) + (u/not-valid plugin-id :path value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :path "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :path "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -94,10 +94,10 @@ (fn [self value] (cond (or (not (string? value)) (not (clr/valid-hex-color? value))) - (u/display-not-valid :color value) + (u/not-valid plugin-id :color value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :color "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :color "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -111,10 +111,10 @@ (fn [self value] (cond (or (not (number? value)) (< value 0) (> value 1)) - (u/display-not-valid :opacity value) + (u/not-valid plugin-id :opacity value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :opacity "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :opacity "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -129,10 +129,10 @@ (let [value (parser/parse-gradient value)] (cond (not (sm/validate clr/schema:gradient value)) - (u/display-not-valid :gradient value) + (u/not-valid plugin-id :gradient value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :gradient "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :gradient "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -147,10 +147,10 @@ (let [value (parser/parse-image-data value)] (cond (not (sm/validate clr/schema:image value)) - (u/display-not-valid :image value) + (u/not-valid plugin-id :image value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :image "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :image "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -161,7 +161,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :remove "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") :else (st/emit! (dwl/delete-color {:id id})))) @@ -170,7 +170,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :clone "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :clone "Plugin doesn't have 'library:write' permission") :else (let [color-id (uuid/next) @@ -207,7 +207,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :getPluginData-key key) + (u/not-valid plugin-id :getPluginData-key key) :else (let [color (u/locate-library-color file-id id)] @@ -217,16 +217,16 @@ (fn [key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setPluginData-non-local-library file-id) + (u/not-valid plugin-id :setPluginData-non-local-library file-id) (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :color id (keyword "plugin" (str plugin-id)) key value)))) @@ -240,10 +240,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginData-namespace namespace) + (u/not-valid plugin-id :getSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :getSharedPluginData-key key) + (u/not-valid plugin-id :getSharedPluginData-key key) :else (let [color (u/locate-library-color file-id id)] @@ -253,19 +253,19 @@ (fn [namespace key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setSharedPluginData-non-local-library file-id) + (u/not-valid plugin-id :setSharedPluginData-non-local-library file-id) (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :color id (keyword "shared" namespace) key value)))) @@ -274,7 +274,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginDataKeys-namespace namespace) + (u/not-valid plugin-id :getSharedPluginDataKeys-namespace namespace) :else (let [color (u/locate-library-color file-id id)] @@ -301,10 +301,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :name "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'library:write' permission") :else (let [typo (u/proxy->library-typography self) @@ -318,10 +318,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :path value) + (u/not-valid plugin-id :path value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :path "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :path "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -335,10 +335,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontId value) + (u/not-valid plugin-id :fontId value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontId "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontId "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -352,10 +352,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontFamily value) + (u/not-valid plugin-id :fontFamily value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontFamily "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontFamily "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -369,10 +369,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontVariantId value) + (u/not-valid plugin-id :fontVariantId value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontVariantId "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontVariantId "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -386,10 +386,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontSize value) + (u/not-valid plugin-id :fontSize value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontSize "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontSize "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -403,10 +403,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontWeight value) + (u/not-valid plugin-id :fontWeight value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontWeight "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontWeight "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -420,10 +420,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontStyle value) + (u/not-valid plugin-id :fontStyle value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontStyle "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontStyle "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -437,10 +437,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :lineHeight value) + (u/not-valid plugin-id :lineHeight value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :lineHeight "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :lineHeight "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -454,10 +454,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :letterSpacing value) + (u/not-valid plugin-id :letterSpacing value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :letterSpacing "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :letterSpacing "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -471,10 +471,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :textTransform value) + (u/not-valid plugin-id :textTransform value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :textTransform "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :textTransform "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -485,7 +485,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :remove "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") :else (st/emit! (dwl/delete-typography {:id id})))) @@ -494,7 +494,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :clone "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :clone "Plugin doesn't have 'library:write' permission") :else (let [typo-id (uuid/next) @@ -507,10 +507,10 @@ (fn [shape] (cond (not (shape/shape-proxy? shape)) - (u/display-not-valid :applyToText shape) + (u/not-valid plugin-id :applyToText shape) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :applyToText "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyToText "Plugin doesn't have 'content:write' permission") :else (let [shape-id (obj/get shape "$id") @@ -521,10 +521,10 @@ (fn [range] (cond (not (text/text-range-proxy? range)) - (u/display-not-valid :applyToText range) + (u/not-valid plugin-id :applyToText range) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :applyToText "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyToText "Plugin doesn't have 'content:write' permission") :else (let [shape-id (obj/get range "$id") @@ -542,7 +542,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :typography-plugin-data-key key) + (u/not-valid plugin-id :typography-plugin-data-key key) :else (let [typography (u/locate-library-typography file-id id)] @@ -552,16 +552,16 @@ (fn [key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setPluginData-non-local-library file-id) + (u/not-valid plugin-id :setPluginData-non-local-library file-id) (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :typography id (keyword "plugin" (str plugin-id)) key value)))) @@ -575,10 +575,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginData-namespace namespace) + (u/not-valid plugin-id :getSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :getSharedPluginData-key key) + (u/not-valid plugin-id :getSharedPluginData-key key) :else (let [typography (u/locate-library-typography file-id id)] @@ -588,19 +588,19 @@ (fn [namespace key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setSharedPluginData-non-local-library file-id) + (u/not-valid plugin-id :setSharedPluginData-non-local-library file-id) (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :typography id (keyword "shared" namespace) key value)))) @@ -609,7 +609,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginDataKeys-namespace namespace) + (u/not-valid plugin-id :getSharedPluginDataKeys-namespace namespace) :else (let [typography (u/locate-library-typography file-id id)] @@ -674,7 +674,7 @@ :removeProperty (fn [pos] (if (not (nat-int? pos)) - (u/display-not-valid :pos pos) + (u/not-valid plugin-id :pos pos) (st/emit! (ev/event {::ev/name "remove-property" ::ev/origin "plugin:remove-property"}) (dwv/remove-property id pos)))) @@ -683,10 +683,10 @@ (fn [pos name] (cond (not (nat-int? pos)) - (u/display-not-valid :pos pos) + (u/not-valid plugin-id :pos pos) (not (string? name)) - (u/display-not-valid :name name) + (u/not-valid plugin-id :name name) :else (st/emit! @@ -715,10 +715,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :name "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'library:write' permission") :else (let [component (u/proxy->library-component self) @@ -732,10 +732,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :path value) + (u/not-valid plugin-id :path value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :path "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :path "Plugin doesn't have 'library:write' permission") :else (let [component (u/proxy->library-component self) @@ -746,7 +746,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :remove "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") :else (st/emit! (dwl/delete-component {:id id})))) @@ -755,7 +755,7 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :instance "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :instance "Plugin doesn't have 'content:write' permission") :else (let [id-ref (atom nil)] @@ -766,7 +766,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :component-plugin-data-key key) + (u/not-valid plugin-id :component-plugin-data-key key) :else (let [component (u/locate-library-component file-id id)] @@ -776,16 +776,16 @@ (fn [key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setPluginData-non-local-library file-id) + (u/not-valid plugin-id :setPluginData-non-local-library file-id) (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :component id (keyword "plugin" (str plugin-id)) key value)))) @@ -799,10 +799,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :component-plugin-data-namespace namespace) + (u/not-valid plugin-id :component-plugin-data-namespace namespace) (not (string? key)) - (u/display-not-valid :component-plugin-data-key key) + (u/not-valid plugin-id :component-plugin-data-key key) :else (let [component (u/locate-library-component file-id id)] @@ -812,19 +812,19 @@ (fn [namespace key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setSharedPluginData-non-local-library file-id) + (u/not-valid plugin-id :setSharedPluginData-non-local-library file-id) (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :component id (keyword "shared" namespace) key value)))) @@ -833,7 +833,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :component-plugin-data-namespace namespace) + (u/not-valid plugin-id :component-plugin-data-namespace namespace) :else (let [component (u/locate-library-component file-id id)] @@ -901,10 +901,10 @@ (fn [pos value] (cond (not (nat-int? pos)) - (u/display-not-valid :pos (str pos)) + (u/not-valid plugin-id :pos (str pos)) (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) :else (st/emit! @@ -970,7 +970,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :createColor "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :createColor "Plugin doesn't have 'library:write' permission") :else (let [color-id (uuid/next)] @@ -981,7 +981,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :createTypography "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :createTypography "Plugin doesn't have 'library:write' permission") :else (let [typography-id (uuid/next)] @@ -992,7 +992,7 @@ (fn [shapes] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :createComponent "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :createComponent "Plugin doesn't have 'library:write' permission") :else (let [id-ref (atom nil) @@ -1005,7 +1005,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :file-plugin-data-key key) + (u/not-valid plugin-id :file-plugin-data-key key) :else (let [file (u/locate-file file-id)] @@ -1015,13 +1015,13 @@ (fn [key value] (cond (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :file (keyword "plugin" (str plugin-id)) key value)))) @@ -1035,10 +1035,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :file-plugin-data-namespace namespace) + (u/not-valid plugin-id :file-plugin-data-namespace namespace) (not (string? key)) - (u/display-not-valid :file-plugin-data-key key) + (u/not-valid plugin-id :file-plugin-data-key key) :else (let [file (u/locate-file file-id)] @@ -1048,16 +1048,16 @@ (fn [namespace key value] (cond (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :file (keyword "shared" namespace) key value)))) @@ -1066,7 +1066,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :namespace namespace) + (u/not-valid plugin-id :namespace namespace) :else (let [file (u/locate-file file-id)] @@ -1110,14 +1110,14 @@ (fn [library-id] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :connectLibrary "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :connectLibrary "Plugin doesn't have 'library:write' permission") :else (js/Promise. (fn [resolve reject] (cond (not (string? library-id)) - (do (u/display-not-valid :connectLibrary library-id) + (do (u/not-valid plugin-id :connectLibrary library-id) (reject nil)) :else diff --git a/frontend/src/app/plugins/local_storage.cljs b/frontend/src/app/plugins/local_storage.cljs index cac6529be1..80d9b66b6b 100644 --- a/frontend/src/app/plugins/local_storage.cljs +++ b/frontend/src/app/plugins/local_storage.cljs @@ -30,10 +30,10 @@ (fn [key] (cond (not (r/check-permission plugin-id "allow:localstorage")) - (u/display-not-valid :getItem "Plugin doesn't have 'allow:localstorage' permission") + (u/not-valid plugin-id :getItem "Plugin doesn't have 'allow:localstorage' permission") (not (string? key)) - (u/display-not-valid :getItem "The key must be a string") + (u/not-valid plugin-id :getItem "The key must be a string") :else (.getItem ^js local-storage (prefix-key plugin-id key)))) @@ -42,10 +42,10 @@ (fn [key value] (cond (not (r/check-permission plugin-id "allow:localstorage")) - (u/display-not-valid :setItem "Plugin doesn't have 'allow:localstorage' permission") + (u/not-valid plugin-id :setItem "Plugin doesn't have 'allow:localstorage' permission") (not (string? key)) - (u/display-not-valid :setItem "The key must be a string") + (u/not-valid plugin-id :setItem "The key must be a string") :else (.setItem ^js local-storage (prefix-key plugin-id key) value))) @@ -54,10 +54,10 @@ (fn [key] (cond (not (r/check-permission plugin-id "allow:localstorage")) - (u/display-not-valid :removeItem "Plugin doesn't have 'allow:localstorage' permission") + (u/not-valid plugin-id :removeItem "Plugin doesn't have 'allow:localstorage' permission") (not (string? key)) - (u/display-not-valid :removeItem "The key must be a string") + (u/not-valid plugin-id :removeItem "The key must be a string") :else (.getItem ^js local-storage (prefix-key plugin-id key)))) diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index b0302a1939..7bc5726a17 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -59,7 +59,7 @@ (fn [_ value] (cond (or (not (string? value)) (empty? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) :else (st/emit! (dwi/update-flow page-id id #(assoc % :name value)))))} @@ -74,7 +74,7 @@ (fn [_ value] (cond (not (shape/shape-proxy? value)) - (u/display-not-valid :startingBoard value) + (u/not-valid plugin-id :startingBoard value) :else (st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id"))))))} @@ -103,10 +103,10 @@ (fn [_ value] (cond (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :name "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/rename-page id value))))} @@ -127,10 +127,10 @@ (fn [_ value] (cond (or (not (string? value)) (not (cc/valid-hex-color? value))) - (u/display-not-valid :background value) + (u/not-valid plugin-id :background value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :background "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :background "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/change-canvas-color id {:color value}))))} @@ -158,7 +158,7 @@ (fn [shape-id] (cond (not (string? shape-id)) - (u/display-not-valid :getShapeById shape-id) + (u/not-valid plugin-id :getShapeById shape-id) :else (let [shape-id (uuid/parse shape-id) @@ -195,7 +195,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :page-plugin-data-key key) + (u/not-valid plugin-id :page-plugin-data-key key) :else (let [page (u/locate-page file-id id)] @@ -205,13 +205,13 @@ (fn [key value] (cond (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data file-id :page id (keyword "plugin" (str plugin-id)) key value)))) @@ -225,10 +225,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :page-plugin-data-namespace namespace) + (u/not-valid plugin-id :page-plugin-data-namespace namespace) (not (string? key)) - (u/display-not-valid :page-plugin-data-key key) + (u/not-valid plugin-id :page-plugin-data-key key) :else (let [page (u/locate-page file-id id)] @@ -238,16 +238,16 @@ (fn [namespace key value] (cond (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value)))) @@ -256,7 +256,7 @@ (fn [self namespace] (cond (not (string? namespace)) - (u/display-not-valid :page-plugin-data-namespace namespace) + (u/not-valid plugin-id :page-plugin-data-namespace namespace) :else (let [page (u/proxy->page self)] @@ -266,7 +266,7 @@ (fn [new-window] (cond (not (r/check-permission plugin-id "content:read")) - (u/display-not-valid :openPage "Plugin doesn't have 'content:read' permission") + (u/not-valid plugin-id :openPage "Plugin doesn't have 'content:read' permission") :else (let [new-window (if (boolean? new-window) new-window false)] @@ -276,10 +276,10 @@ (fn [name frame] (cond (or (not (string? name)) (empty? name)) - (u/display-not-valid :createFlow-name name) + (u/not-valid plugin-id :createFlow-name name) (not (shape/shape-proxy? frame)) - (u/display-not-valid :createFlow-frame frame) + (u/not-valid plugin-id :createFlow-frame frame) :else (let [flow-id (uuid/next)] @@ -290,7 +290,7 @@ (fn [flow] (cond (not (flow-proxy? flow)) - (u/display-not-valid :removeFlow-flow flow) + (u/not-valid plugin-id :removeFlow-flow flow) :else (st/emit! (dwi/remove-flow id (obj/get flow "$id"))))) @@ -300,18 +300,18 @@ (let [shape (u/proxy->shape board)] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :addRulerGuide "Value not a safe number") + (u/not-valid plugin-id :addRulerGuide "Value not a safe number") (not (contains? #{"vertical" "horizontal"} orientation)) - (u/display-not-valid :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'") + (u/not-valid plugin-id :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'") (and (some? shape) (or (not (shape/shape-proxy? board)) (not (cfh/frame-shape? shape)))) - (u/display-not-valid :addRulerGuide "The shape is not a board") + (u/not-valid plugin-id :addRulerGuide "The shape is not a board") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addRulerGuide "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addRulerGuide "Plugin doesn't have 'content:write' permission") :else (let [ruler-id (uuid/next)] @@ -328,10 +328,10 @@ (fn [value] (cond (not (rg/ruler-guide-proxy? value)) - (u/display-not-valid :removeRulerGuide "Guide not provided") + (u/not-valid plugin-id :removeRulerGuide "Guide not provided") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeRulerGuide "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :removeRulerGuide "Plugin doesn't have 'comment:write' permission") :else (let [guide (u/proxy->ruler-guide value)] @@ -343,17 +343,17 @@ position (parser/parse-point position)] (cond (or (not (string? content)) (empty? content)) - (u/display-not-valid :addCommentThread "Content not valid") + (u/not-valid plugin-id :addCommentThread "Content not valid") (or (not (sm/valid-safe-number? (:x position))) (not (sm/valid-safe-number? (:y position)))) - (u/display-not-valid :addCommentThread "Position not valid") + (u/not-valid plugin-id :addCommentThread "Position not valid") (and (some? board) (or (not (shape/shape-proxy? board)) (not (cfh/frame-shape? shape)))) - (u/display-not-valid :addCommentThread "Board not valid") + (u/not-valid plugin-id :addCommentThread "Board not valid") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :addCommentThread "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :addCommentThread "Plugin doesn't have 'comment:write' permission") :else (let [position @@ -378,10 +378,10 @@ (fn [thread] (cond (not (pc/comment-thread-proxy? thread)) - (u/display-not-valid :removeCommentThread "Comment thread not valid") + (u/not-valid plugin-id :removeCommentThread "Comment thread not valid") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :removeCommentThread "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeCommentThread "Plugin doesn't have 'content:write' permission") :else (js/Promise. @@ -400,7 +400,7 @@ (cond (not (r/check-permission plugin-id "comment:read")) (do - (u/display-not-valid :findCommentThreads "Plugin doesn't have 'comment:read' permission") + (u/not-valid plugin-id :findCommentThreads "Plugin doesn't have 'comment:read' permission") (reject "Plugin doesn't have 'comment:read' permission")) :else diff --git a/frontend/src/app/plugins/public_utils.cljs b/frontend/src/app/plugins/public_utils.cljs index d3ed6a46e2..0bfe911ed5 100644 --- a/frontend/src/app/plugins/public_utils.cljs +++ b/frontend/src/app/plugins/public_utils.cljs @@ -14,10 +14,10 @@ [app.plugins.utils :as u])) (defn ^:export centerShapes - [shapes] + [plugin-id shapes] (cond (not (every? shape/shape-proxy? shapes)) - (u/display-not-valid :centerShapes shapes) + (u/not-valid plugin-id :centerShapes shapes) :else (let [shapes (->> shapes (map u/proxy->shape))] diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index ebdee92254..e3792f3fc9 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -17,6 +17,10 @@ [app.util.object :as obj] [beicon.v2.core :as rx])) +;; Needs to be here because moving it to `app.main.data.workspace.mcp` will +;; cause a circular dependency +(def mcp-plugin-id "96dfa740-005d-8020-8007-55ede24a2bae") + ;; Stores the installed plugins information (defonce ^:private registry (atom {})) @@ -78,6 +82,7 @@ (d/without-nils {:plugin-id plugin-id :url (str plugin-url) + :version vers :name name :description desc :host origin @@ -127,5 +132,6 @@ (defn check-permission [plugin-id permission] (or (= plugin-id "00000000-0000-0000-0000-000000000000") + (= plugin-id mcp-plugin-id) (let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])] (contains? permissions permission)))) diff --git a/frontend/src/app/plugins/ruler_guides.cljs b/frontend/src/app/plugins/ruler_guides.cljs index d9c8e7c6c4..696df23001 100644 --- a/frontend/src/app/plugins/ruler_guides.cljs +++ b/frontend/src/app/plugins/ruler_guides.cljs @@ -44,13 +44,13 @@ (let [shape (u/locate-shape file-id page-id (obj/get value "$id"))] (cond (not (shape-proxy? value)) - (u/display-not-valid :board "The board is not a shape proxy") + (u/not-valid plugin-id :board "The board is not a shape proxy") (not (cfh/frame-shape? shape)) - (u/display-not-valid :board "The shape is not a board") + (u/not-valid plugin-id :board "The shape is not a board") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :board "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :board "Plugin doesn't have 'content:write' permission") :else (let [board-id (when value (obj/get value "$id")) @@ -78,10 +78,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :position "Not valid position") + (u/not-valid plugin-id :position "Not valid position") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :position "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :position "Plugin doesn't have 'content:write' permission") :else (let [guide (u/proxy->ruler-guide self) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 5a0c8f6634..722822df79 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -31,8 +31,8 @@ [app.common.types.shape.radius :as ctsr] [app.common.types.shape.shadow :as ctss] [app.common.types.text :as txt] - [app.common.types.token :as cto] [app.common.uuid :as uuid] + [app.main.data.plugins :as dp] [app.main.data.workspace :as dw] [app.main.data.workspace.groups :as dwg] @@ -47,7 +47,6 @@ [app.main.data.workspace.variants :as dwv] [app.main.repo :as rp] [app.main.store :as st] - [app.plugins.flags :refer [natural-child-ordering?]] [app.plugins.flex :as flex] [app.plugins.format :as format] [app.plugins.grid :as grid] @@ -55,6 +54,7 @@ [app.plugins.register :as r] [app.plugins.ruler-guides :as rg] [app.plugins.text :as text] + [app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens token-attr-plugin->token-attr token-attr?]] [app.plugins.utils :as u] [app.util.http :as http] [app.util.object :as obj] @@ -91,7 +91,7 @@ (let [value (parser/parse-keyword value)] (cond (not (contains? ctsi/event-types value)) - (u/display-not-valid :trigger value) + (u/not-valid plugin-id :trigger value) :else (st/emit! (dwi/update-interaction @@ -107,7 +107,7 @@ (fn [_ value] (cond (or (not (number? value)) (not (pos? value))) - (u/display-not-valid :delay value) + (u/not-valid plugin-id :delay value) :else (st/emit! (dwi/update-interaction @@ -127,7 +127,7 @@ (d/patch-object params))] (cond (not (sm/validate ctsi/schema:interaction interaction)) - (u/display-not-valid :action interaction) + (u/not-valid plugin-id :action interaction) :else (st/emit! (dwi/update-interaction @@ -192,7 +192,8 @@ (assert (uuid? id)) (let [data (u/locate-shape file-id page-id id)] - (-> (obj/reify {:name "ShapeProxy"} + (-> (obj/reify {:name "ShapeProxy" + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (fn [] plugin-id)} :$id {:enumerable false :get (fn [] id)} :$file {:enumerable false :get (fn [] file-id)} @@ -218,10 +219,10 @@ (not (str/blank? value)))] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :name "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") (not valid?) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) :else (st/emit! (dw/rename-shape-or-variant file-id page-id id value)))))} @@ -233,10 +234,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :blocked value) + (u/not-valid plugin-id :blocked value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :blocked "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :blocked "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -249,10 +250,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :hidden value) + (u/not-valid plugin-id :hidden value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :hidden "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :hidden "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -265,10 +266,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :visible value) + (u/not-valid plugin-id :visible value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :visible "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :visible "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -281,10 +282,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :proportionLock value) + (u/not-valid plugin-id :proportionLock value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :proportionLock "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :proportionLock "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -299,10 +300,10 @@ value (keyword value)] (cond (not (contains? cts/horizontal-constraint-types value)) - (u/display-not-valid :constraintsHorizontal value) + (u/not-valid plugin-id :constraintsHorizontal value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :constraintsHorizontal "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :constraintsHorizontal "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :constraints-h value))))))} @@ -316,10 +317,10 @@ value (keyword value)] (cond (not (contains? cts/vertical-constraint-types value)) - (u/display-not-valid :constraintsVertical value) + (u/not-valid plugin-id :constraintsVertical value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :constraintsVertical "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :constraintsVertical "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :constraints-v value))))))} @@ -332,10 +333,10 @@ (let [id (obj/get self "$id")] (cond (or (not (sm/valid-safe-int? value)) (< value 0)) - (u/display-not-valid :borderRadius value) + (u/not-valid plugin-id :borderRadius value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadius "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadius "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-all-corners % value))))))} @@ -348,10 +349,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :borderRadiusTopLeft value) + (u/not-valid plugin-id :borderRadiusTopLeft value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadiusTopLeft "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadiusTopLeft "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r1 value))))))} @@ -364,10 +365,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :borderRadiusTopRight value) + (u/not-valid plugin-id :borderRadiusTopRight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadiusTopRight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadiusTopRight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r2 value))))))} @@ -380,10 +381,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :borderRadiusBottomRight value) + (u/not-valid plugin-id :borderRadiusBottomRight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadiusBottomRight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadiusBottomRight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r3 value))))))} @@ -396,10 +397,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :borderRadiusBottomLeft value) + (u/not-valid plugin-id :borderRadiusBottomLeft value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadiusBottomLeft "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadiusBottomLeft "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r4 value))))))} @@ -412,10 +413,10 @@ (let [id (obj/get self "$id")] (cond (or (not (sm/valid-safe-number? value)) (< value 0) (> value 1)) - (u/display-not-valid :opacity value) + (u/not-valid plugin-id :opacity value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :opacity "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :opacity "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :opacity value))))))} @@ -429,10 +430,10 @@ value (keyword value)] (cond (not (contains? cts/blend-modes value)) - (u/display-not-valid :blendMode value) + (u/not-valid plugin-id :blendMode value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :blendMode "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :blendMode "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :blend-mode value))))))} @@ -446,10 +447,10 @@ value (mapv #(shadow-defaults (parser/parse-shadow %)) value)] (cond (not (sm/validate [:vector ctss/schema:shadow] value)) - (u/display-not-valid :shadows value) + (u/not-valid plugin-id :shadows value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :shadows "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :shadows "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :shadow value))))))} @@ -465,10 +466,10 @@ value (blur-defaults (parser/parse-blur value))] (cond (not (sm/validate ctsb/schema:blur value)) - (u/display-not-valid :blur value) + (u/not-valid plugin-id :blur value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :blur "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :blur "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :blur value)))))))} @@ -482,10 +483,10 @@ value (parser/parse-exports value)] (cond (not (sm/validate [:vector ctse/schema:export] value)) - (u/display-not-valid :exports value) + (u/not-valid plugin-id :exports value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :exports "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :exports "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :exports value))))))} @@ -499,10 +500,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :x value) + (u/not-valid plugin-id :x value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :x "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :x "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/update-position id @@ -517,10 +518,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :y value) + (u/not-valid plugin-id :y value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :y "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :y "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/update-position id @@ -562,10 +563,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :parentX value) + (u/not-valid plugin-id :parentX value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :parentX "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :parentX "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id") @@ -589,10 +590,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :parentY value) + (u/not-valid plugin-id :parentY value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :parentY "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :parentY "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id") @@ -616,10 +617,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :frameX value) + (u/not-valid plugin-id :frameX value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :frameX "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :frameX "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id") @@ -643,10 +644,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :frameY value) + (u/not-valid plugin-id :frameY value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :frameY "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :frameY "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id") @@ -680,10 +681,10 @@ (fn [self value] (cond (not (number? value)) - (u/display-not-valid :rotation value) + (u/not-valid plugin-id :rotation value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rotation "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rotation "Plugin doesn't have 'content:write' permission") :else (let [shape (u/proxy->shape self)] @@ -696,10 +697,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :flipX value) + (u/not-valid plugin-id :flipX value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :flipX "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :flipX "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -712,10 +713,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :flipY value) + (u/not-valid plugin-id :flipY value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :flipY "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :flipY "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -734,13 +735,13 @@ value (parser/parse-fills value)] (cond (not (sm/validate [:vector types.fills/schema:fill] value)) - (u/display-not-valid :fills value) + (u/not-valid plugin-id :fills value) (cfh/text-shape? shape) (st/emit! (dwt/update-attrs id {:fills value})) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fills "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :fills value))))))} @@ -754,10 +755,10 @@ value (parser/parse-strokes value)] (cond (not (sm/validate [:vector cts/schema:stroke] value)) - (u/display-not-valid :strokes value) + (u/not-valid plugin-id :strokes value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :strokes "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :strokes "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value))))))} @@ -802,13 +803,13 @@ (fn [width height] (cond (or (not (sm/valid-safe-number? width)) (<= width 0)) - (u/display-not-valid :resize width) + (u/not-valid plugin-id :resize width) (or (not (sm/valid-safe-number? height)) (<= height 0)) - (u/display-not-valid :resize height) + (u/not-valid plugin-id :resize height) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :resize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :resize "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/update-dimensions [id] :width width) @@ -819,13 +820,13 @@ (let [center (when center {:x (obj/get center "x") :y (obj/get center "y")})] (cond (not (number? angle)) - (u/display-not-valid :rotate-angle angle) + (u/not-valid plugin-id :rotate-angle angle) (and (some? center) (or (not (number? (:x center))) (not (number? (:y center))))) - (u/display-not-valid :rotate-center center) + (u/not-valid plugin-id :rotate-center center) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rotate "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rotate "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/increase-rotation [id] angle {:center center :delta? true}))))) @@ -835,7 +836,7 @@ (let [ret-v (atom nil)] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :clone "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :clone "Plugin doesn't have 'content:write' permission") :else (do (st/emit! (dws/duplicate-shapes #{id} :change-selection? false :return-ref ret-v)) @@ -845,7 +846,7 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :remove "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/delete-shapes #{id})))) @@ -855,7 +856,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :getPluginData key) + (u/not-valid plugin-id :getPluginData key) :else (let [shape (u/locate-shape file-id page-id id)] @@ -865,13 +866,13 @@ (fn [key value] (cond (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data file-id :shape id page-id (keyword "plugin" (str plugin-id)) key value)))) @@ -885,10 +886,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginData-namespace namespace) + (u/not-valid plugin-id :getSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :getSharedPluginData-key key) + (u/not-valid plugin-id :getSharedPluginData-key key) :else (let [shape (u/locate-shape file-id page-id id)] @@ -898,16 +899,16 @@ (fn [namespace key value] (cond (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data file-id :shape id page-id (keyword "shared" namespace) key value)))) @@ -916,7 +917,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginDataKeys namespace) + (u/not-valid plugin-id :getSharedPluginDataKeys namespace) :else (let [shape (u/locate-shape file-id page-id id)] @@ -931,12 +932,12 @@ (not (cfh/group-shape? shape)) (not (cfh/svg-raw-shape? shape)) (not (cfh/bool-shape? shape))) - (u/display-not-valid :getChildren (:type shape)) + (u/not-valid plugin-id :getChildren (:type shape)) :else (let [is-reversed? (ctl/flex-layout? shape) reverse-fn - (if (and (natural-child-ordering? plugin-id) is-reversed?) + (if (and (u/natural-child-ordering? plugin-id) is-reversed?) reverse identity)] (->> (u/locate-shape file-id page-id id) (:shapes) @@ -951,19 +952,19 @@ (not (cfh/group-shape? shape)) (not (cfh/svg-raw-shape? shape)) (not (cfh/bool-shape? shape))) - (u/display-not-valid :appendChild (:type shape)) + (u/not-valid plugin-id :appendChild (:type shape)) (not (shape-proxy? child)) - (u/display-not-valid :appendChild-child child) + (u/not-valid plugin-id :appendChild-child child) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :appendChild "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission") :else (let [child-id (obj/get child "$id") is-reversed? (ctl/flex-layout? shape) index - (if (or (not (natural-child-ordering? plugin-id)) is-reversed?) + (if (or (not (u/natural-child-ordering? plugin-id)) is-reversed?) 0 (count (:shapes shape)))] (st/emit! (dwsh/relocate-shapes #{child-id} id index)))))) @@ -976,19 +977,19 @@ (not (cfh/group-shape? shape)) (not (cfh/svg-raw-shape? shape)) (not (cfh/bool-shape? shape))) - (u/display-not-valid :insertChild (:type shape)) + (u/not-valid plugin-id :insertChild (:type shape)) (not (shape-proxy? child)) - (u/display-not-valid :insertChild-child child) + (u/not-valid plugin-id :insertChild-child child) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :insertChild "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :insertChild "Plugin doesn't have 'content:write' permission") :else (let [child-id (obj/get child "$id") is-reversed? (ctl/flex-layout? shape) index - (if (or (not (natural-child-ordering? plugin-id)) is-reversed?) + (if (or (not (u/natural-child-ordering? plugin-id)) is-reversed?) (- (count (:shapes shape)) index) index)] (st/emit! (dwsh/relocate-shapes #{child-id} id index)))))) @@ -999,10 +1000,10 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/frame-shape? shape)) - (u/display-not-valid :addFlexLayout (:type shape)) + (u/not-valid plugin-id :addFlexLayout (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addFlexLayout "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addFlexLayout "Plugin doesn't have 'content:write' permission") :else (do (st/emit! (dwsl/create-layout-from-id id :flex :from-frame? true :calculate-params? false)) @@ -1013,10 +1014,10 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/frame-shape? shape)) - (u/display-not-valid :addGridLayout (:type shape)) + (u/not-valid plugin-id :addGridLayout (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addGridLayout "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addGridLayout "Plugin doesn't have 'content:write' permission") :else (do (st/emit! (dwsl/create-layout-from-id id :grid :from-frame? true :calculate-params? false)) @@ -1028,10 +1029,10 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/group-shape? shape)) - (u/display-not-valid :makeMask (:type shape)) + (u/not-valid plugin-id :makeMask (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :makeMask "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :makeMask "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwg/mask-group #{id}))))) @@ -1041,10 +1042,10 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/mask-shape? shape)) - (u/display-not-valid :removeMask (:type shape)) + (u/not-valid plugin-id :removeMask (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeMask "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeMask "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwg/unmask-group #{id}))))) @@ -1055,7 +1056,7 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (and (not (cfh/path-shape? shape)) (not (cfh/bool-shape? shape))) - (u/display-not-valid :toD (:type shape)) + (u/not-valid plugin-id :toD (:type shape)) :else (.toString (:content shape))))) @@ -1066,13 +1067,13 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/text-shape? shape)) - (u/display-not-valid :getRange-shape "shape is not text") + (u/not-valid plugin-id :getRange-shape "shape is not text") (or (not (sm/valid-safe-int? start)) (< start 0) (> start end)) - (u/display-not-valid :getRange-start start) + (u/not-valid plugin-id :getRange-start start) (not (sm/valid-safe-int? end)) - (u/display-not-valid :getRange-end end) + (u/not-valid plugin-id :getRange-end end) :else (text/text-range-proxy plugin-id file-id page-id id start end)))) @@ -1082,13 +1083,13 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (lib-typography-proxy? typography)) - (u/display-not-valid :applyTypography-typography typography) + (u/not-valid plugin-id :applyTypography-typography typography) (not (cfh/text-shape? shape)) - (u/display-not-valid :applyTypography-shape (:type shape)) + (u/not-valid plugin-id :applyTypography-shape (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :applyTypography "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyTypography "Plugin doesn't have 'content:write' permission") :else (let [typography (u/proxy->library-typography typography)] @@ -1099,10 +1100,10 @@ (fn [index] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :setParentIndex index) + (u/not-valid plugin-id :setParentIndex index) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setParentIndex "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setParentIndex "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/set-shape-index file-id page-id id index)))) @@ -1197,7 +1198,7 @@ (let [value (parser/parse-export value)] (cond (not (sm/validate ctse/schema:export value)) - (u/display-not-valid :export value) + (u/not-valid plugin-id :export value) :else (let [shape (u/locate-shape file-id page-id id) @@ -1233,7 +1234,7 @@ (d/patch-object (parser/parse-interaction trigger action delay)))] (cond (not (sm/validate ctsi/schema:interaction interaction)) - (u/display-not-valid :addInteraction interaction) + (u/not-valid plugin-id :addInteraction interaction) :else (let [index (-> (u/locate-shape file-id page-id id) (:interactions []) count)] @@ -1244,7 +1245,7 @@ (fn [interaction] (cond (not (interaction-proxy? interaction)) - (u/display-not-valid :removeInteraction interaction) + (u/not-valid plugin-id :removeInteraction interaction) :else (st/emit! (dwi/remove-interaction {:id id} (obj/get interaction "$index"))))) @@ -1255,16 +1256,16 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :addRulerGuide "Value not a safe number") + (u/not-valid plugin-id :addRulerGuide "Value not a safe number") (not (contains? #{"vertical" "horizontal"} orientation)) - (u/display-not-valid :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'") + (u/not-valid plugin-id :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'") (not (cfh/frame-shape? shape)) - (u/display-not-valid :addRulerGuide "The shape is not a board") + (u/not-valid plugin-id :addRulerGuide "The shape is not a board") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addRulerGuide "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addRulerGuide "Plugin doesn't have 'content:write' permission") :else (let [id (uuid/next) @@ -1285,10 +1286,10 @@ (fn [_ value] (cond (not (rg/ruler-guide-proxy? value)) - (u/display-not-valid :removeRulerGuide "Guide not provided") + (u/not-valid plugin-id :removeRulerGuide "Guide not provided") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeRulerGuide "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeRulerGuide "Plugin doesn't have 'content:write' permission") :else (let [guide (u/proxy->ruler-guide value)] @@ -1298,25 +1299,26 @@ {:this true :get (fn [_] - (let [tokens + (let [applied-tokens (-> (u/locate-shape file-id page-id id) - (get :applied-tokens))] + (get :applied-tokens) + (applied-tokens-plugin->applied-tokens))] (reduce (fn [acc [prop name]] (obj/set! acc (json/write-camel-key prop) name)) #js {} - tokens)))} + applied-tokens)))} :applyToken {:enumerable false :schema [:tuple [:fn token-proxy?] - [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]] + [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [token attrs] (let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id")) - kw-attrs (into #{} (map keyword attrs))] - (if (some #(not (cto/token-attr? %)) kw-attrs) - (u/display-not-valid :applyToken attrs) + kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))] + (if (some #(not (token-attr? %)) kw-attrs) + (u/not-valid plugin-id :applyToken attrs) (st/emit! (dwta/toggle-token {:token token :attrs kw-attrs @@ -1338,10 +1340,10 @@ (fn [pos value] (cond (not (nat-int? pos)) - (u/display-not-valid :pos pos) + (u/not-valid plugin-id :pos pos) (not (string? value)) - (u/display-not-valid :value value) + (u/not-valid plugin-id :value value) :else (let [shape (u/locate-shape file-id page-id id) @@ -1351,16 +1353,30 @@ :combineAsVariants (fn [ids] - (if (or (not (seq ids)) (not (every? uuid/parse* ids))) - (u/display-not-valid :ids ids) - (let [shape (u/locate-shape file-id page-id id) - component (u/locate-library-component file-id (:component-id shape)) - ids (->> ids + (cond + (or (not (seq ids)) (not (every? uuid/parse* ids))) + (u/not-valid plugin-id :ids ids) + + :else + (let [ids (->> ids (map uuid/uuid) - (into #{id}))] - (when (and component (not (ctk/is-variant? component))) - (st/emit! - (dwv/combine-as-variants ids {:trigger "plugin:combine-as-variants"}))))))) + (into #{id})) + valid? + (every? + (fn [id] + (let [shape (u/locate-shape file-id page-id id) + component (u/locate-library-component file-id (:component-id shape))] + (not (ctk/is-variant? component)))) + ids)] + + (if valid? + (let [variant-id (uuid/next)] + (st/emit! (dwv/combine-as-variants + ids + {:trigger "plugin:combine-as-variants" :variant-id variant-id})) + (shape-proxy plugin-id variant-id)) + + (u/not-valid plugin-id :ids "One of the components is not on the same page or is already a variant")))))) (cond-> (or (cfh/frame-shape? data) (cfh/group-shape? data) (cfh/svg-raw-shape? data) (cfh/bool-shape? data)) (crc/add-properties! @@ -1375,21 +1391,21 @@ (fn [^js self children] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :children "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :children "Plugin doesn't have 'content:write' permission") (not (every? shape-proxy? children)) - (u/display-not-valid :children "Every children needs to be shape proxies") + (u/not-valid plugin-id :children "Every children needs to be shape proxies") :else (let [shape (u/proxy->shape self) file-id (obj/get self "$file") page-id (obj/get self "$page") - reverse-fn (if (natural-child-ordering? plugin-id) reverse identity) + reverse-fn (if (u/natural-child-ordering? plugin-id) reverse identity) ids (->> children reverse-fn (map #(obj/get % "$id")))] (cond (not= (set ids) (set (:shapes shape))) - (u/display-not-valid :children "Not all children are present in the input") + (u/not-valid plugin-id :children "Not all children are present in the input") :else (st/emit! (dw/reorder-children file-id page-id (:id shape) ids))))))})) @@ -1405,10 +1421,10 @@ (fn [_ value] (cond (not (boolean? value)) - (u/display-not-valid :clipContent value) + (u/not-valid plugin-id :clipContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :clipContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :clipContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :show-content (not value))))))} @@ -1421,10 +1437,10 @@ (fn [_ value] (cond (not (boolean? value)) - (u/display-not-valid :showInViewMode value) + (u/not-valid plugin-id :showInViewMode value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :showInViewMode "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :showInViewMode "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :hide-in-viewer (not value))))))} @@ -1456,10 +1472,10 @@ value (parser/parse-frame-guides value)] (cond (not (sm/validate [:vector ::ctg/grid] value)) - (u/display-not-valid :guides value) + (u/not-valid plugin-id :guides value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :guides "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :guides "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :grids value))))))} @@ -1481,10 +1497,10 @@ value (keyword value)] (cond (not (contains? #{:fix :auto} value)) - (u/display-not-valid :horizontalSizing value) + (u/not-valid plugin-id :horizontalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))} @@ -1497,10 +1513,10 @@ value (keyword value)] (cond (not (contains? #{:fix :auto} value)) - (u/display-not-valid :verticalSizing value) + (u/not-valid plugin-id :verticalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))} @@ -1524,10 +1540,10 @@ (let [segments (parser/parse-commands value)] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :content "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :content "Plugin doesn't have 'content:write' permission") (not (sm/validate path/schema:segments segments)) - (u/display-not-valid :content segments) + (u/not-valid plugin-id :content segments) :else (let [selrect (path/calc-selrect segments) @@ -1550,13 +1566,13 @@ value)] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :content "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :content "Plugin doesn't have 'content:write' permission") (not (cfh/path-shape? data)) - (u/display-not-valid :content-type type) + (u/not-valid plugin-id :content-type type) (not (sm/validate path/schema:segments segments)) - (u/display-not-valid :content segments) + (u/not-valid plugin-id :content segments) :else (let [selrect (path/calc-selrect segments) diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 1a1e83ac20..1154af2366 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -8,9 +8,10 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.geom.shapes.text :as gst] [app.common.record :as crc] [app.common.schema :as sm] - [app.common.types.shape :as cts] + [app.common.types.fills :as types.fills] [app.common.types.text :as txt] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwt] @@ -118,10 +119,10 @@ variant (fonts/get-default-variant font)] (cond (not font) - (u/display-not-valid :fontId value) + (u/not-valid plugin-id :fontId value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontId "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontId "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (font-data font variant))))))} @@ -140,10 +141,10 @@ variant (fonts/get-default-variant font)] (cond (not (string? value)) - (u/display-not-valid :fontFamily value) + (u/not-valid plugin-id :fontFamily value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontFamily "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontFamily "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (font-data font variant))))))} @@ -161,10 +162,10 @@ variant (fonts/get-variant font value)] (cond (not (string? value)) - (u/display-not-valid :fontVariantId value) + (u/not-valid plugin-id :fontVariantId value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontVariantId "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontVariantId "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (variant-data variant))))))} @@ -181,10 +182,10 @@ (let [value (str/trim (dm/str value))] (cond (or (empty? value) (not (re-matches font-size-re value))) - (u/display-not-valid :fontSize value) + (u/not-valid plugin-id :fontSize value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontSize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontSize "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:font-size value})))))} @@ -208,10 +209,10 @@ (fonts/find-variant font {:weight weight}))] (cond (nil? variant) - (u/display-not-valid :fontWeight (dm/str "Font weight '" value "' not supported for the current font")) + (u/not-valid plugin-id :fontWeight (dm/str "Font weight '" value "' not supported for the current font")) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontWeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontWeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (variant-data variant))))))} @@ -234,10 +235,10 @@ (fonts/find-variant font {:style style}))] (cond (nil? variant) - (u/display-not-valid :fontStyle (dm/str "Font style '" value "' not supported for the current font")) + (u/not-valid plugin-id :fontStyle (dm/str "Font style '" value "' not supported for the current font")) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontStyle "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontStyle "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (variant-data variant))))))} @@ -254,10 +255,10 @@ (let [value (str/trim (dm/str value))] (cond (or (empty? value) (not (re-matches line-height-re value))) - (u/display-not-valid :lineHeight value) + (u/not-valid plugin-id :lineHeight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :lineHeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :lineHeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:line-height value})))))} @@ -274,10 +275,10 @@ (let [value (str/trim (dm/str value))] (cond (or (empty? value) (re-matches letter-spacing-re value)) - (u/display-not-valid :letterSpacing value) + (u/not-valid plugin-id :letterSpacing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :letterSpacing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :letterSpacing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:letter-spacing value})))))} @@ -293,10 +294,10 @@ (fn [_ value] (cond (and (string? value) (not (re-matches text-transform-re value))) - (u/display-not-valid :textTransform value) + (u/not-valid plugin-id :textTransform value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textTransform "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textTransform "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:text-transform value}))))} @@ -312,10 +313,10 @@ (fn [_ value] (cond (and (string? value) (re-matches text-decoration-re value)) - (u/display-not-valid :textDecoration value) + (u/not-valid plugin-id :textDecoration value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textDecoration "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textDecoration "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:text-decoration value}))))} @@ -331,10 +332,10 @@ (fn [_ value] (cond (and (string? value) (re-matches text-direction-re value)) - (u/display-not-valid :direction value) + (u/not-valid plugin-id :direction value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :direction "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :direction "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:direction value}))))} @@ -350,10 +351,10 @@ (fn [_ value] (cond (and (string? value) (re-matches text-align-re value)) - (u/display-not-valid :align value) + (u/not-valid plugin-id :align value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :align "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :align "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:text-align value}))))} @@ -369,11 +370,11 @@ (fn [_ value] (let [value (parser/parse-fills value)] (cond - (not (sm/validate [:vector ::cts/fill] value)) - (u/display-not-valid :fills value) + (not (sm/validate [:vector types.fills/schema:fill] value)) + (u/not-valid plugin-id :fills value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fills "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:fills value})))))} @@ -400,10 +401,10 @@ ;; editor as well (cond (or (not (string? value)) (empty? value)) - (u/display-not-valid :characters value) + (u/not-valid plugin-id :characters value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :characters "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :characters "Plugin doesn't have 'content:write' permission") (contains? (:workspace-editor-state @st/state) id) (let [shape (u/proxy->shape self) @@ -427,10 +428,10 @@ value (keyword value)] (cond (not (contains? #{:auto-width :auto-height :fixed} value)) - (u/display-not-valid :growType value) + (u/not-valid plugin-id :growType value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :growType "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :growType "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value))))))} @@ -444,10 +445,10 @@ variant (fonts/get-default-variant font)] (cond (not font) - (u/display-not-valid :fontId value) + (u/not-valid plugin-id :fontId value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontId "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontId "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (font-data font variant))))))} @@ -461,10 +462,10 @@ variant (fonts/get-default-variant font)] (cond (not font) - (u/display-not-valid :fontFamily value) + (u/not-valid plugin-id :fontFamily value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontFamily "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontFamily "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (font-data font variant))))))} @@ -478,10 +479,10 @@ variant (fonts/get-variant font value)] (cond (not variant) - (u/display-not-valid :fontVariantId value) + (u/not-valid plugin-id :fontVariantId value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontVariantId "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontVariantId "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (variant-data variant))))))} @@ -494,10 +495,10 @@ value (str/trim (dm/str value))] (cond (or (empty? value) (not (re-matches font-size-re value))) - (u/display-not-valid :fontSize value) + (u/not-valid plugin-id :fontSize value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontSize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontSize "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:font-size value})))))} @@ -516,10 +517,10 @@ (fonts/find-variant font {:weight weight}))] (cond (nil? variant) - (u/display-not-valid :fontWeight (dm/str "Font weight '" value "' not supported for the current font")) + (u/not-valid plugin-id :fontWeight (dm/str "Font weight '" value "' not supported for the current font")) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontWeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontWeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (variant-data variant))))))} @@ -538,10 +539,10 @@ (fonts/find-variant font {:style style}))] (cond (nil? variant) - (u/display-not-valid :fontStyle (dm/str "Font style '" value "' not supported for the current font")) + (u/not-valid plugin-id :fontStyle (dm/str "Font style '" value "' not supported for the current font")) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontStyle "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontStyle "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (variant-data variant))))))} @@ -554,10 +555,10 @@ value (str/trim (dm/str value))] (cond (or (empty? value) (not (re-matches line-height-re value))) - (u/display-not-valid :lineHeight value) + (u/not-valid plugin-id :lineHeight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :lineHeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :lineHeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:line-height value})))))} @@ -570,10 +571,10 @@ value (str/trim (dm/str value))] (cond (or (not (string? value)) (not (re-matches letter-spacing-re value))) - (u/display-not-valid :letterSpacing value) + (u/not-valid plugin-id :letterSpacing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :letterSpacing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :letterSpacing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:letter-spacing value})))))} @@ -585,10 +586,10 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches text-transform-re value))) - (u/display-not-valid :textTransform value) + (u/not-valid plugin-id :textTransform value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textTransform "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textTransform "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:text-transform value})))))} @@ -600,10 +601,10 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches text-decoration-re value))) - (u/display-not-valid :textDecoration value) + (u/not-valid plugin-id :textDecoration value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textDecoration "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textDecoration "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:text-decoration value})))))} @@ -615,10 +616,10 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches text-direction-re value))) - (u/display-not-valid :textDirection value) + (u/not-valid plugin-id :textDirection value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textDirection "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textDirection "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:text-direction value})))))} @@ -630,10 +631,10 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches text-align-re value))) - (u/display-not-valid :align value) + (u/not-valid plugin-id :align value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :align "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :align "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:text-align value})))))} @@ -645,10 +646,13 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches vertical-align-re value))) - (u/display-not-valid :verticalAlign value) + (u/not-valid plugin-id :verticalAlign value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalAlign "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalAlign "Plugin doesn't have 'content:write' permission") :else - (st/emit! (dwt/update-attrs id {:vertical-align value})))))})) + (st/emit! (dwt/update-attrs id {:vertical-align value})))))} + + {:name "textBounds" + :get #(-> % u/proxy->shape gst/shape->bounds format/format-geom-rect)})) diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index 268652e334..13888f0481 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -19,18 +19,58 @@ [app.main.store :as st] [app.plugins.utils :as u] [app.util.object :as obj] - [clojure.datafy :refer [datafy]])) + [clojure.datafy :refer [datafy]] + [clojure.set :refer [map-invert]])) ;; === Token +;; Give more semantic names to the shape attributes that tokens can be applied to +(def ^:private map:token-attr->token-attr-plugin + {:r1 :border-radius-top-left + :r2 :border-radius-top-right + :r3 :border-radius-bottom-right + :r4 :border-radius-bottom-left + + :p1 :padding-top-left + :p2 :padding-top-right + :p3 :padding-bottom-right + :p4 :padding-bottom-left + + :m1 :margin-top-left + :m2 :margin-top-right + :m3 :margin-bottom-right + :m4 :margin-bottom-left}) + +(def ^:private map:token-attr-plugin->token-attr + (map-invert map:token-attr->token-attr-plugin)) + +(defn token-attr->token-attr-plugin + [k] + (get map:token-attr->token-attr-plugin k k)) + +(defn token-attr-plugin->token-attr + [k] + (get map:token-attr-plugin->token-attr k k)) + +(defn applied-tokens-plugin->applied-tokens + [value] + (into {} + (map (fn [[k v]] [(token-attr->token-attr-plugin k) v])) + value)) + +(defn token-attr? + [attr] + (cto/token-attr? (token-attr-plugin->token-attr attr))) + (defn- apply-token-to-shapes - [file-id set-id id shape-ids attrs] + [plugin-id file-id set-id id shape-ids attrs] + (let [token (u/locate-token file-id set-id id)] - (if (some #(not (cto/token-attr? %)) attrs) - (u/display-not-valid :applyToSelected attrs) + (if (some #(not (token-attr? %)) attrs) + (u/not-valid plugin-id :applyToSelected attrs) (st/emit! (dwta/toggle-token {:token token - :attrs attrs + :attrs (into #{} (map token-attr-plugin->token-attr) attrs) :shape-ids shape-ids :expand-with-children false}))))) @@ -52,7 +92,7 @@ (defn token-proxy [plugin-id file-id set-id id] (obj/reify {:name "TokenProxy" - :on-error u/handle-error} + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)} :$file-id {:enumerable false :get (constantly file-id)} :$set-id {:enumerable false :get (constantly set-id)} @@ -146,16 +186,16 @@ {:enumerable false :schema [:tuple [:vector [:fn shape-proxy?]] - [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]] + [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [shapes attrs] - (apply-token-to-shapes file-id set-id id (map #(obj/get % "$id") shapes) attrs))} + (apply-token-to-shapes plugin-id file-id set-id id (map #(obj/get % "$id") shapes) attrs))} :applyToSelected {:enumerable false - :schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]] + :schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [attrs] (let [selected (get-in @st/state [:workspace-local :selected])] - (apply-token-to-shapes file-id set-id id selected attrs)))})) + (apply-token-to-shapes plugin-id file-id set-id id selected attrs)))})) ;; === Token Set @@ -165,7 +205,7 @@ (defn token-set-proxy [plugin-id file-id id] (obj/reify {:name "TokenSetProxy" - :on-error u/handle-error} + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)} :$file-id {:enumerable false :get (constantly file-id)} :$id {:enumerable false :get (constantly id)} @@ -247,15 +287,19 @@ :addToken {:enumerable false :schema (fn [args] - [:tuple (-> (cfo/make-token-schema - (-> (u/locate-tokens-lib file-id) (ctob/get-tokens id)) - (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) - ;; Don't allow plugins to set the id - (sm/dissoc-key :id) - ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) - ;; and set a converter that changes DTCG types to internal types (:decode/json). - ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width - (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))]) + (let [tokens-tree (-> (u/locate-tokens-lib file-id) + (ctob/get-tokens id) + ;; Convert to the adecuate format for schema + (ctob/tokens-tree))] + [:tuple (-> (cfo/make-token-schema + tokens-tree + (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) + ;; Don't allow plugins to set the id + (sm/dissoc-key :id) + ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) + ;; and set a converter that changes DTCG types to internal types (:decode/json). + ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width + (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))])) :decode/options {:key-fn identity} :fn (fn [attrs] (let [tokens-lib (u/locate-tokens-lib file-id) @@ -270,7 +314,7 @@ (if resolved-value (do (st/emit! (dwtl/create-token id token)) (token-proxy plugin-id file-id id (:id token))) - (do (u/display-not-valid :addToken (str errors)) + (do (u/not-valid plugin-id :addToken (str errors)) nil))))} :duplicate @@ -287,7 +331,7 @@ (defn token-theme-proxy [plugin-id file-id id] (obj/reify {:name "TokenThemeProxy" - :on-error u/handle-error} + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)} :$file-id {:enumerable false :get (constantly file-id)} :$id {:enumerable false :get (constantly id)} @@ -394,7 +438,7 @@ (defn tokens-catalog [plugin-id file-id] (obj/reify {:name "TokensCatalog" - :on-error u/handle-error} + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)} :$id {:enumerable false :get (constantly file-id)} diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index dfb8242a12..19de73fcde 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -9,14 +9,17 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.json :as json] + [app.common.i18n :as i18n :refer [tr]] [app.common.schema :as sm] + [app.common.schema.messages :as csm] + [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.tokens-lib :as ctob] [app.main.data.helpers :as dsh] [app.main.store :as st] - [app.util.object :as obj])) + [app.util.object :as obj] + [cuerdas.core :as str])) (defn locate-file [id] @@ -221,6 +224,16 @@ (resolve value)))))] [ret-v ret-p])) +(defn natural-child-ordering? + [plugin-id] + (boolean + (dm/get-in @st/state [:plugins :flags plugin-id :natural-child-ordering]))) + +(defn throw-validation-errors? + [plugin-id] + (boolean + (dm/get-in @st/state [:plugins :flags plugin-id :throw-validation-errors]))) + (defn display-not-valid [code value] (if (some? value) @@ -228,34 +241,54 @@ (.error js/console (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code))) nil) +(defn throw-not-valid + [code value] + (if (some? value) + (throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code))) + (throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code)))) + nil) + +(defn not-valid + [plugin-id code value] + (if (throw-validation-errors? plugin-id) + (throw-not-valid code value) + (display-not-valid code value))) + (defn reject-not-valid [reject code value] (let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)] (.error js/console msg) (reject msg))) -(defn coerce - "Decodes a javascript object into clj and check against schema. If schema validation fails, - displays a not-valid message with the code and hint provided and returns nil." - [attrs schema code hint] - (let [decoder (sm/decoder schema sm/json-transformer) - explainer (sm/explainer schema) - attrs (-> attrs json/->clj decoder)] - (if-let [explain (explainer attrs)] - (display-not-valid code (str hint " " (sm/humanize-explain explain))) - attrs))) - (defn mixed-value [values] (let [s (set values)] (if (= (count s) 1) (first s) "mixed"))) +(defn error-messages + [explain] + (->> (:errors explain) + (reduce csm/interpret-schema-problem {}) + (mapcat (comp seq val)) + (map (fn [[field {:keys [message]}]] + (tr "plugins.validation.message" (name field) message))) + (str/join ". "))) + (defn handle-error "Function to be used in plugin proxies methods to handle errors and print a readable message to the console." - [cause] - (display-not-valid (ex-message cause) nil) - (if-let [explain (-> cause ex-data ::sm/explain)] - (println (sm/humanize-explain explain)) - (js/console.log (ex-data cause))) - (js/console.log (.-stack cause))) \ No newline at end of file + [plugin-id] + (fn [cause] + (let [message + (if-let [explain (-> cause ex-data ::sm/explain)] + (do + (js/console.error (sm/humanize-explain explain)) + (error-messages explain)) + (ex-data cause))] + (js/console.log (.-stack cause)) + (not-valid plugin-id :error message)))) + +(defn is-main-component-proxy? + [p] + (when-let [shape (proxy->shape p)] + (ctk/main-instance? shape))) diff --git a/frontend/src/app/plugins/viewport.cljs b/frontend/src/app/plugins/viewport.cljs index a581e3ba60..8b5b73ac57 100644 --- a/frontend/src/app/plugins/viewport.cljs +++ b/frontend/src/app/plugins/viewport.cljs @@ -38,10 +38,10 @@ new-y (obj/get value "y")] (cond (not (sm/valid-safe-number? new-x)) - (u/display-not-valid :center-x new-x) + (u/not-valid plugin-id :center-x new-x) (not (sm/valid-safe-number? new-y)) - (u/display-not-valid :center-y new-y) + (u/not-valid plugin-id :center-y new-y) :else (let [vb (dm/get-in @st/state [:workspace-local :vbox]) @@ -63,7 +63,7 @@ (fn [value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :zoom value) + (u/not-valid plugin-id :zoom value) :else (let [z (dm/get-in @st/state [:workspace-local :zoom])] @@ -87,7 +87,7 @@ (fn [shapes] (cond (not (every? ps/shape-proxy? shapes)) - (u/display-not-valid :zoomIntoView "Argument should be valid shapes") + (u/not-valid plugin-id :zoomIntoView "Argument should be valid shapes") :else (let [ids (->> shapes diff --git a/frontend/src/app/util/forms.cljs b/frontend/src/app/util/forms.cljs index 253d32470c..58bbe3569a 100644 --- a/frontend/src/app/util/forms.cljs +++ b/frontend/src/app/util/forms.cljs @@ -10,84 +10,10 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.schema :as sm] - [app.util.i18n :as i18n :refer [tr]] + [app.common.schema.messages :as csm] [cuerdas.core :as str] - [malli.core :as m] [rumext.v2 :as mf])) -;; --- Handlers Helpers - -(defn- translate-code - [code] - (if (vector? code) - (tr (nth code 0) (i18n/c (nth code 1))) - (tr code))) - -(defn- handle-error-fn - [props problem] - (let [v-fn (:error/fn props) - result (v-fn problem)] - (if (string? result) - {:message result} - {:message (or (some-> (get result :code) - (translate-code)) - (get result :message) - (tr "errors.invalid-data"))}))) - -(defn- handle-error-message - [props] - {:message (get props :error/message)}) - -(defn- handle-error-code - [props] - (let [code (get props :error/code)] - {:message (translate-code code)})) - -(defn- interpret-schema-problem - [acc {:keys [schema in value type] :as problem}] - (let [props (m/properties schema) - tprops (m/type-properties schema) - field (or (:error/field props) - in) - field (if (vector? field) - field - [field])] - - (if (and (= 1 (count field)) - (contains? acc (first field))) - acc - (cond - (or (nil? field) - (empty? field)) - acc - - (or (= type :malli.core/missing-key) - (nil? value)) - (assoc-in acc field {:message (tr "errors.field-missing")}) - - ;; --- CHECK on schema props - (contains? props :error/fn) - (assoc-in acc field (handle-error-fn props problem)) - - (contains? props :error/message) - (assoc-in acc field (handle-error-message props)) - - (contains? props :error/code) - (assoc-in acc field (handle-error-code props)) - - ;; --- CHECK on type props - (contains? tprops :error/fn) - (assoc-in acc field (handle-error-fn tprops problem)) - - (contains? tprops :error/message) - (assoc-in acc field (handle-error-message tprops)) - - (contains? tprops :error/code) - (assoc-in acc field (handle-error-code tprops)) - - :else - (assoc-in acc field {:message (tr "errors.invalid-data")}))))) - (defn- use-rerender-fn [] (let [state (mf/useState 0) @@ -97,24 +23,6 @@ (fn [] (render-fn inc))))) -(defn- apply-validators - [validators state errors] - (reduce (fn [errors validator-fn] - (merge errors (validator-fn errors (:data state)))) - errors - validators)) - -(defn- collect-schema-errors - [schema validators state] - (let [explain (sm/explain schema (:data state)) - errors (->> (reduce interpret-schema-problem {} (:errors explain)) - (apply-validators validators state))] - - (-> (:errors state) - (merge errors) - (d/without-nils) - (not-empty)))) - (defn- wrap-update-schema-fn [f {:keys [schema validators]}] (fn [& args] @@ -124,7 +32,7 @@ errors (when-not valid? - (collect-schema-errors schema validators state)) + (csm/collect-schema-errors schema validators state)) extra-errors (not-empty (:extra-errors state))] diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index fa2641c7b3..e586dc3d1a 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -462,3 +462,9 @@ (defn print-last-exception [] (some-> errors/last-exception ex/print-throwable)) + + +(defn ^:export dbg + [o] + (app.common.pprint/pprint o {:level 100 :length 100})) + diff --git a/frontend/translations/en.po b/frontend/translations/en.po index a39cfaf85c..bfe1c4b6fe 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -338,77 +338,6 @@ msgstr "You're going to restore %s." msgid "dashboard-restore-file-confirmation.title" msgstr "Restore file" -#: src/app/main/ui/settings/access_tokens.cljs:103 -msgid "dashboard.access-tokens.copied-success" -msgstr "Copied token" - -#: src/app/main/ui/settings/access_tokens.cljs:189 -msgid "dashboard.access-tokens.create" -msgstr "Generate new token" - -#: src/app/main/ui/settings/access_tokens.cljs:64 -msgid "dashboard.access-tokens.create.success" -msgstr "Access token created successfully." - -#: src/app/main/ui/settings/access_tokens.cljs:286 -msgid "dashboard.access-tokens.empty.add-one" -msgstr "Press the button \"Generate new token\" to generate one." - -#: src/app/main/ui/settings/access_tokens.cljs:285 -msgid "dashboard.access-tokens.empty.no-access-tokens" -msgstr "You have no tokens so far." - -#: src/app/main/ui/settings/access_tokens.cljs:135 -msgid "dashboard.access-tokens.expiration-180-days" -msgstr "180 days" - -#: src/app/main/ui/settings/access_tokens.cljs:132 -msgid "dashboard.access-tokens.expiration-30-days" -msgstr "30 days" - -#: src/app/main/ui/settings/access_tokens.cljs:133 -msgid "dashboard.access-tokens.expiration-60-days" -msgstr "60 days" - -#: src/app/main/ui/settings/access_tokens.cljs:134 -msgid "dashboard.access-tokens.expiration-90-days" -msgstr "90 days" - -#: src/app/main/ui/settings/access_tokens.cljs:131 -msgid "dashboard.access-tokens.expiration-never" -msgstr "Never" - -#: src/app/main/ui/settings/access_tokens.cljs:268 -msgid "dashboard.access-tokens.expired-on" -msgstr "Expired on %s" - -#: src/app/main/ui/settings/access_tokens.cljs:269 -msgid "dashboard.access-tokens.expires-on" -msgstr "Expires on %s" - -#: src/app/main/ui/settings/access_tokens.cljs:267 -msgid "dashboard.access-tokens.no-expiration" -msgstr "No expiration date" - -#: src/app/main/ui/settings/access_tokens.cljs:184 -msgid "dashboard.access-tokens.personal" -msgstr "Personal access tokens" - -#: src/app/main/ui/settings/access_tokens.cljs:185 -msgid "dashboard.access-tokens.personal.description" -msgstr "" -"Personal access tokens function like an alternative to our login/password " -"authentication system and can be used to allow an application to access the " -"internal Penpot API" - -#: src/app/main/ui/settings/access_tokens.cljs:142 -msgid "dashboard.access-tokens.token-will-expire" -msgstr "The token will expire on %s" - -#: src/app/main/ui/settings/access_tokens.cljs:143 -msgid "dashboard.access-tokens.token-will-not-expire" -msgstr "The token has no expiration date" - #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "Add file" @@ -2138,6 +2067,237 @@ msgstr "Resolved value:" msgid "inspect.tabs.styles.variants-panel" msgstr "Variant Properties" +#: src/app/main/ui/settings/integrations.cljs:189 +msgid "integrations.access-tokens.create" +msgstr "Create new access token" + +#: src/app/main/ui/settings/integrations.cljs:286 +msgid "integrations.access-tokens.empty.add-one" +msgstr "Press the button \"Create new access token\" to generate one." + +#: src/app/main/ui/settings/integrations.cljs:285 +msgid "integrations.access-tokens.empty.no-access-tokens" +msgstr "You have no tokens so far." + +#: src/app/main/ui/settings/integrations.cljs:184 +msgid "integrations.access-tokens.personal" +msgstr "Personal access tokens" + +#: src/app/main/ui/settings/integrations.cljs:185 +msgid "integrations.access-tokens.personal.description" +msgstr "" +"Personal access tokens function like an alternative to our login/password " +"authentication system and can be used to allow an application to access the " +"internal Penpot API" + +#: src/app/main/ui/settings/integrations.cljs:152, src/app/main/ui/settings/integrations.cljs:158 +msgid "integrations.copy-to-clipboard" +msgstr "Copy to clipboard" + +#: src/app/main/ui/settings/integrations.cljs:432 +msgid "integrations.create-access-token.title" +msgstr "Create access token" + +#: src/app/main/ui/settings/integrations.cljs:433 +msgid "integrations.create-access-token.title.created" +msgstr "Access token created" + +#: src/app/main/ui/settings/integrations.cljs:257 +msgid "integrations.delete-token.accept" +msgstr "Delete token" + +#: src/app/main/ui/settings/integrations.cljs:256 +msgid "integrations.delete-token.message" +msgstr "Are you sure you want to delete this token?" + +#: src/app/main/ui/settings/integrations.cljs:255 +msgid "integrations.delete-token.title" +msgstr "Delete token" + +#: src/app/main/ui/settings/integrations.cljs:135 +msgid "integrations.expiration-180-days" +msgstr "180 days" + +#: src/app/main/ui/settings/integrations.cljs:132 +msgid "integrations.expiration-30-days" +msgstr "30 days" + +#: src/app/main/ui/settings/integrations.cljs:133 +msgid "integrations.expiration-60-days" +msgstr "60 days" + +#: src/app/main/ui/settings/integrations.cljs:134 +msgid "integrations.expiration-90-days" +msgstr "90 days" + +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.expiration-never" +msgstr "Never" + +#: src/app/main/ui/settings/integrations.cljs:268 +msgid "integrations.expired-on" +msgstr "Expired on %s" + +#: src/app/main/ui/settings/integrations.cljs:269 +msgid "integrations.expires-on" +msgstr "Expires on %s" + +#: src/app/main/ui/settings/integrations.cljs:267 +msgid "integrations.no-expiration" +msgstr "No expiration date" + +#: src/app/main/ui/settings/integrations.cljs:130 +msgid "integrations.expiration-date.label" +msgstr "Expiration date" + +#: src/app/main/ui/settings/integrations.cljs:290 +msgid "integrations.generate-mcp-key.title" +msgstr "Generate MCP key" + +#: src/app/main/ui/settings/integrations.cljs:291 +msgid "integrations.generate-mcp-key.title.created" +msgstr "MCP key generated" + +#: src/app/main/ui/settings/integrations.cljs:113 +msgid "integrations.info.mcp-client-config" +msgstr "Add this configuration to your MCP client (e.g. ~/​​​​​​.mcp.json)." + +#: src/app/main/ui/settings/integrations.cljs:183 +msgid "integrations.info.mcp-server" +msgstr "The Penpot MCP Server enables MCP clients to interact directly with Penpot design files." + +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.token.info.non-recuperable" +msgstr "This unique token is non-recuperable. If you lose it, you will need to create a new one." + +#: src/app/main/ui/settings/integrations.cljs:131 +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.mcp-key.info.non-recuperable" +msgstr "This unique MCP key is non-recoverable. If you lose it, you will need to create a new one." + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.mcp-server.title" +msgstr "MCP Server" + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.mcp-server.title.beta" +msgstr "Beta" + +#: src/app/main/ui/settings/integrations.cljs:347 +msgid "integrations.mcp-server.description" +msgstr "The Penpot MCP Server enables MCP clients to interact directly with Penpot design files." + +#: src/app/main/ui/settings/integrations.cljs:353 +msgid "integrations.mcp-server.status" +msgstr "Status" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.mcp-server.status.disabled" +msgstr "Disabled" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.mcp-server.status.enabled" +msgstr "Enabled" + +#: src/app/main/ui/settings/integrations.cljs:363 +msgid "integrations.mcp-server.status.expired.0" +msgstr "The MCP key used to connect to the MCP server has expired. As a result, the connection cannot be established." + +#: src/app/main/ui/settings/integrations.cljs:368 +msgid "integrations.mcp-server.status.expired.1" +msgstr "Please regenerate the MCP key and update your client configuration with the new key." + +#: src/app/main/ui/settings/integrations.cljs:415 +msgid "integrations.mcp-server.mcp-keys.copy" +msgstr "Copy link" + +#: src/app/main/ui/settings/integrations.cljs:422 +msgid "integrations.mcp-server.mcp-keys.help" +msgstr "How to configure MCP clients" + +#: src/app/main/ui/settings/integrations.cljs:405 +msgid "integrations.mcp-server.mcp-keys.info" +msgstr "This is the server url you'll need to configure your MCP client in order to connect it to the Penpot MCP server." + +#: src/app/main/ui/settings/integrations.cljs:387 +msgid "integrations.mcp-server.mcp-keys.regenerate" +msgstr "Regenerate MCP key" + +#: src/app/main/ui/settings/integrations.cljs:381 +msgid "integrations.mcp-server.mcp-keys.title" +msgstr "MCP key" + +#: src/app/main/ui/settings/integrations.cljs:388 +msgid "integrations.mcp-server.mcp-keys.tootip" +msgstr "The MCP key is needed for the MCP client set up" + +#: src/app/main/ui/settings/integrations.cljs:124 +msgid "integrations.name.label" +msgstr "Name" + +#: src/app/main/ui/settings/integrations.cljs:126 +msgid "integrations.name.placeholder" +msgstr "The name can help to know what's the token for" + +#: src/app/main/ui/settings/integrations.cljs:103 +msgid "integrations.notification.success.token-copied" +msgstr "Copied token" + +#: src/app/main/ui/settings/integrations.cljs:103 +#: src/app/main/ui/settings/integrations.cljs:103 +msgid "integrations.notification.success.mcp-key-copied" +msgstr "MCP key copied" + +#: src/app/main/ui/settings/integrations.cljs:64 +msgid "integrations.notification.success.created" +msgstr "Token created successfully" + +#: src/app/main/ui/settings/integrations.cljs:327 +msgid "integrations.notification.success.copied-link" +msgstr "Link copied to clipboard" + +#: src/app/main/ui/settings/integrations.cljs:293 +msgid "integrations.notification.success.mcp-server-disabled" +msgstr "MCP server disabled" + +#: src/app/main/ui/settings/integrations.cljs:299 +msgid "integrations.notification.success.mcp-server-enabled" +msgstr "MCP server enabled" + +#: src/app/main/ui/settings/integrations.cljs:317 +msgid "integrations.regenerate-mcp-key.info" +msgstr "Regenerating the MCP key will immediately revoke the current one. Any application using it will stop working." + +#: src/app/main/ui/settings/integrations.cljs:317 +msgid "integrations.regenerate-mcp-key.title" +msgstr "Regenerate MCP key" + +#: src/app/main/ui/settings/integrations.cljs:318 +msgid "integrations.regenerate-mcp-key.title.created" +msgstr "MCP key regenerated" + +#: src/app/main/ui/settings/integrations.cljs:480 +msgid "integrations.title" +msgstr "Integrations" + +#: src/app/main/ui/settings/integrations.cljs:142 +msgid "integrations.token.will-expire" +msgstr "The token will expire on %s" + +#: src/app/main/ui/settings/integrations.cljs:142 +#: src/app/main/ui/settings/integrations.cljs:142 +msgid "integrations.mcp-key.will-expire" +msgstr "The MCP key will expire on %s" + +#: src/app/main/ui/settings/integrations.cljs:143 +msgid "integrations.token.will-not-expire" +msgstr "The token has no expiration date" + +#: src/app/main/ui/settings/integrations.cljs:143 +#: src/app/main/ui/settings/integrations.cljs:143 +msgid "integrations.mcp-key.will-not-expire" +msgstr "The MCP key has no expiration date" + #: src/app/main/ui/dashboard/comments.cljs:96 msgid "label.mark-all-as-read" msgstr "Mark all as read" @@ -2354,6 +2514,9 @@ msgstr "Director" msgid "labels.discard" msgstr "Discard" +msgid "labels.dismiss" +msgstr "Dismiss" + #: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 msgid "labels.download" msgstr "Download %s" @@ -2489,6 +2652,10 @@ msgstr "Info" msgid "labels.installed-fonts" msgstr "Installed fonts" +#: src/app/main/ui/settings/sidebar.cljs:123 +msgid "labels.integrations" +msgstr "Integrations" + #: src/app/main/ui/static.cljs:405 msgid "labels.internal-error.desc-message-first" msgstr "Something bad happened." @@ -3148,30 +3315,6 @@ msgstr "Change email" msgid "modals.change-email.title" msgstr "Change your email" -#: src/app/main/ui/settings/access_tokens.cljs:152, src/app/main/ui/settings/access_tokens.cljs:158 -msgid "modals.create-access-token.copy-token" -msgstr "Copy token" - -#: src/app/main/ui/settings/access_tokens.cljs:130 -msgid "modals.create-access-token.expiration-date.label" -msgstr "Expiration date" - -#: src/app/main/ui/settings/access_tokens.cljs:124 -msgid "modals.create-access-token.name.label" -msgstr "Name" - -#: src/app/main/ui/settings/access_tokens.cljs:126 -msgid "modals.create-access-token.name.placeholder" -msgstr "The name can help to know what's the token for" - -#: src/app/main/ui/settings/access_tokens.cljs:178 -msgid "modals.create-access-token.submit-label" -msgstr "Create token" - -#: src/app/main/ui/settings/access_tokens.cljs:111 -msgid "modals.create-access-token.title" -msgstr "Generate access token" - #: src/app/main/ui/dashboard/team.cljs:1127 msgid "modals.create-webhook.submit-label" msgstr "Create webhook" @@ -3188,18 +3331,6 @@ msgstr "Payload URL" msgid "modals.create-webhook.url.placeholder" msgstr "https://example.com/postreceive" -#: src/app/main/ui/settings/access_tokens.cljs:257 -msgid "modals.delete-acces-token.accept" -msgstr "Delete token" - -#: src/app/main/ui/settings/access_tokens.cljs:256 -msgid "modals.delete-acces-token.message" -msgstr "Are you sure you want to delete this token?" - -#: src/app/main/ui/settings/access_tokens.cljs:255 -msgid "modals.delete-acces-token.title" -msgstr "Delete token" - #: src/app/main/ui/settings/delete_account.cljs:56 msgid "modals.delete-account.cancel" msgstr "Cancel and keep my account" @@ -3699,6 +3830,12 @@ msgstr "Invitation sent successfully" msgid "notifications.invitation-link-copied" msgstr "Invitation link copied" +msgid "notifications.mcp.active-in-another-tab" +msgstr "MCP is active in another tab. Switch here?" + +msgid "notifications.mcp.active-in-this-tab" +msgstr "MCP is now active in this tab." + #: src/app/main/ui/settings/delete_account.cljs:24 msgid "notifications.profile-deletion-not-allowed" msgstr "You can't delete your profile. Reassign your teams before proceed." @@ -5093,14 +5230,14 @@ msgstr "Shared Libraries - %s - Penpot" msgid "title.default" msgstr "Penpot - Design Freedom for Teams" -#: src/app/main/ui/settings/access_tokens.cljs:278 -msgid "title.settings.access-tokens" -msgstr "Profile - Access tokens" - #: src/app/main/ui/settings/feedback.cljs:161 msgid "title.settings.feedback" msgstr "Give feedback - Penpot" +#: src/app/main/ui/settings/integrations.cljs:278 +msgid "title.settings.integrations" +msgstr "Integrations - Penpot" + #: src/app/main/ui/settings/notifications.cljs:45 msgid "title.settings.notifications" msgstr "Notifications - Penpot" @@ -5594,6 +5731,18 @@ msgstr "Hide rulers" msgid "workspace.header.menu.hide-textpalette" msgstr "Hide fonts palette" +msgid "workspace.header.menu.mcp.plugin.status.connect" +msgstr "Connect" + +msgid "workspace.header.menu.mcp.plugin.status.disconnect" +msgstr "Disconnect" + +msgid "workspace.header.menu.mcp.server.status.enabled" +msgstr "Manage (Status: enabled)" + +msgid "workspace.header.menu.mcp.server.status.disabled" +msgstr "Manage (Status: disabled)" + #: src/app/main/ui/workspace/main_menu.cljs:884 msgid "workspace.header.menu.option.edit" msgstr "Edit" @@ -5606,6 +5755,9 @@ msgstr "File" msgid "workspace.header.menu.option.help-info" msgstr "Help & info" +msgid "workspace.header.menu.option.mcp" +msgstr "MCP server" + #: src/app/main/ui/workspace/main_menu.cljs:916 #, unused msgid "workspace.header.menu.option.power-up" @@ -7234,11 +7386,14 @@ msgid "workspace.plugins.empty-plugins" msgstr "No plugins installed yet" #: src/app/main/ui/workspace/plugins.cljs:193 -msgid "workspace.plugins.error.manifest" -msgstr "The plugin manifest is incorrect." + msgid "workspace.plugins.error.manifest" + msgstr "The plugin manifest is incorrect." + +msgid "plugins.validation.message" +msgstr "Field %s is invalid: %s" #: src/app/main/data/plugins.cljs:105, src/app/main/ui/workspace/main_menu.cljs:766, src/app/main/ui/workspace/plugins.cljs:84 -msgid "workspace.plugins.error.need-editor" + msgid "workspace.plugins.error.need-editor" msgstr "You need to be an editor to use this plugin" #: src/app/main/ui/workspace/plugins.cljs:189 diff --git a/frontend/translations/es.po b/frontend/translations/es.po index f30b9e12e3..a13e9c56b7 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -347,77 +347,6 @@ msgstr "Vas a restaurar %s." msgid "dashboard-restore-file-confirmation.title" msgstr "Restaurar archivo" -#: src/app/main/ui/settings/access_tokens.cljs:103 -msgid "dashboard.access-tokens.copied-success" -msgstr "Token copiado" - -#: src/app/main/ui/settings/access_tokens.cljs:189 -msgid "dashboard.access-tokens.create" -msgstr "Generar nuevo token" - -#: src/app/main/ui/settings/access_tokens.cljs:64 -msgid "dashboard.access-tokens.create.success" -msgstr "Access token creado con éxito." - -#: src/app/main/ui/settings/access_tokens.cljs:286 -msgid "dashboard.access-tokens.empty.add-one" -msgstr "Pulsa el botón \"Generar nuevo token\" para generar uno." - -#: src/app/main/ui/settings/access_tokens.cljs:285 -msgid "dashboard.access-tokens.empty.no-access-tokens" -msgstr "Todavía no tienes ningún token." - -#: src/app/main/ui/settings/access_tokens.cljs:135 -msgid "dashboard.access-tokens.expiration-180-days" -msgstr "180 días" - -#: src/app/main/ui/settings/access_tokens.cljs:132 -msgid "dashboard.access-tokens.expiration-30-days" -msgstr "30 días" - -#: src/app/main/ui/settings/access_tokens.cljs:133 -msgid "dashboard.access-tokens.expiration-60-days" -msgstr "60 días" - -#: src/app/main/ui/settings/access_tokens.cljs:134 -msgid "dashboard.access-tokens.expiration-90-days" -msgstr "90 días" - -#: src/app/main/ui/settings/access_tokens.cljs:131 -msgid "dashboard.access-tokens.expiration-never" -msgstr "Nunca" - -#: src/app/main/ui/settings/access_tokens.cljs:268 -msgid "dashboard.access-tokens.expired-on" -msgstr "Expiró el %s" - -#: src/app/main/ui/settings/access_tokens.cljs:269 -msgid "dashboard.access-tokens.expires-on" -msgstr "Expira el %s" - -#: src/app/main/ui/settings/access_tokens.cljs:267 -msgid "dashboard.access-tokens.no-expiration" -msgstr "Sin fecha de expiración" - -#: src/app/main/ui/settings/access_tokens.cljs:184 -msgid "dashboard.access-tokens.personal" -msgstr "Access tokens personales" - -#: src/app/main/ui/settings/access_tokens.cljs:185 -msgid "dashboard.access-tokens.personal.description" -msgstr "" -"Los access tokens personales funcionan como una alternativa a nuestro " -"sistema de autenticación usuario/password y se pueden usar para permitir a " -"otras aplicaciones acceso a la API interna de Penpot" - -#: src/app/main/ui/settings/access_tokens.cljs:142 -msgid "dashboard.access-tokens.token-will-expire" -msgstr "El token expirará el %s" - -#: src/app/main/ui/settings/access_tokens.cljs:143 -msgid "dashboard.access-tokens.token-will-not-expire" -msgstr "El token no tiene fecha de expiración" - #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "Añadir archivo" @@ -2105,6 +2034,237 @@ msgstr "Valor resuelto:" msgid "inspect.tabs.styles.variants-panel" msgstr "Propiedades de las variantes" +#: src/app/main/ui/settings/integrations.cljs:189 +msgid "integrations.access-tokens.create" +msgstr "Crear nuevo token de acceso" + +#: src/app/main/ui/settings/integrations.cljs:286 +msgid "integrations.access-tokens.empty.add-one" +msgstr "Pulsa el botón \"Crear nuevo token de accesso\" para generar uno." + +#: src/app/main/ui/settings/integrations.cljs:285 +msgid "integrations.access-tokens.empty.no-access-tokens" +msgstr "Todavía no tienes ningún token." + +#: src/app/main/ui/settings/integrations.cljs:184 +msgid "integrations.access-tokens.personal" +msgstr "Tokens de acceso personales" + +#: src/app/main/ui/settings/integrations.cljs:185 +msgid "integrations.access-tokens.personal.description" +msgstr "" +"Los tokens de accesso personales funcionan como una alternativa a nuestro " +"sistema de autenticación usuario/password y se pueden usar para permitir a " +"otras aplicaciones acceso a la API interna de Penpot" + +#: src/app/main/ui/settings/integrations.cljs:152, src/app/main/ui/settings/integrations.cljs:158 +msgid "integrations.copy-to-clipboard" +msgstr "Copiar al portapapeles" + +#: src/app/main/ui/settings/integrations.cljs:432 +msgid "integrations.create-access-token.title" +msgstr "Crear token de accesso" + +#: src/app/main/ui/settings/integrations.cljs:433 +msgid "integrations.create-access-token.title.created" +msgstr "Token de acceso creado" + +#: src/app/main/ui/settings/integrations.cljs:257 +msgid "integrations.delete-token.accept" +msgstr "Borrar token" + +#: src/app/main/ui/settings/integrations.cljs:256 +msgid "integrations.delete-token.message" +msgstr "¿Seguro que deseas borrar este token?" + +#: src/app/main/ui/settings/integrations.cljs:255 +msgid "integrations.delete-token.title" +msgstr "Borrar token" + +#: src/app/main/ui/settings/integrations.cljs:135 +msgid "integrations.expiration-180-days" +msgstr "180 días" + +#: src/app/main/ui/settings/integrations.cljs:132 +msgid "integrations.expiration-30-days" +msgstr "30 días" + +#: src/app/main/ui/settings/integrations.cljs:133 +msgid "integrations.expiration-60-days" +msgstr "60 días" + +#: src/app/main/ui/settings/integrations.cljs:134 +msgid "integrations.expiration-90-days" +msgstr "90 días" + +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.expiration-never" +msgstr "Nunca" + +#: src/app/main/ui/settings/integrations.cljs:268 +msgid "integrations.expired-on" +msgstr "Expiró el %s" + +#: src/app/main/ui/settings/integrations.cljs:269 +msgid "integrations.expires-on" +msgstr "Expira el %s" + +#: src/app/main/ui/settings/integrations.cljs:267 +msgid "integrations.no-expiration" +msgstr "Sin fecha de expiración" + +#: src/app/main/ui/settings/integrations.cljs:130 +msgid "integrations.expiration-date.label" +msgstr "Fecha de expiración" + +#: src/app/main/ui/settings/integrations.cljs:290 +msgid "integrations.generate-mcp-key.title" +msgstr "Generar clave MCP" + +#: src/app/main/ui/settings/integrations.cljs:291 +msgid "integrations.generate-mcp-key.title.created" +msgstr "Clave MCP generada" + +#: src/app/main/ui/settings/integrations.cljs:113 +msgid "integrations.info.mcp-client-config" +msgstr "Agrega esta configuración a tu cliente MCP (por ejemplo, ~/.mcp.json)." + +#: src/app/main/ui/settings/integrations.cljs:183 +msgid "integrations.info.mcp-server" +msgstr "El servidor MCP de Penpot permite a los clientes MCP interactuar directamente con los archivos de diseño de Penpot." + +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.token.info.non-recuperable" +msgstr "Esta clave única no es recuperable. Si la pierdes, tendrás que crear una nueva." + +#: src/app/main/ui/settings/integrations.cljs:131 +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.mcp-key.info.non-recuperable" +msgstr "Esta clave MCP única no es recuperable. Si la pierdes, tendrás que crear una nueva." + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.mcp-server.title" +msgstr "Servidor MCP" + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.mcp-server.title.beta" +msgstr "Beta" + +#: src/app/main/ui/settings/integrations.cljs:347 +msgid "integrations.mcp-server.description" +msgstr "El servidor MCP de Penpot permite que los clientes MCP interactúen directamente con los archivos de diseño de Penpot." + +#: src/app/main/ui/settings/integrations.cljs:353 +msgid "integrations.mcp-server.status" +msgstr "Estado" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.mcp-server.status.enabled" +msgstr "Habilitado" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.mcp-server.status.disabled" +msgstr "Deshabilitado" + +#: src/app/main/ui/settings/integrations.cljs:363 +msgid "integrations.mcp-server.status.expired.0" +msgstr "La clave MCP utilizada para conectarse al servidor MCP ha expirado. Como resultado, no se puede establecer la conexión." + +#: src/app/main/ui/settings/integrations.cljs:368 +msgid "integrations.mcp-server.status.expired.1" +msgstr "Por favor, regenera la clave MCP y actualiza la configuración de tu cliente con la nueva clave." + +#: src/app/main/ui/settings/integrations.cljs:415 +msgid "integrations.mcp-server.mcp-keys.copy" +msgstr "Copiar enlace" + +#: src/app/main/ui/settings/integrations.cljs:422 +msgid "integrations.mcp-server.mcp-keys.help" +msgstr "Cómo configurar clientes MCP" + +#: src/app/main/ui/settings/integrations.cljs:405 +msgid "integrations.mcp-server.mcp-keys.info" +msgstr "Esta es la URL del servidor que necesitarás configurar en tu cliente MCP para conectarlo al servidor MCP de Penpot." + +#: src/app/main/ui/settings/integrations.cljs:387 +msgid "integrations.mcp-server.mcp-keys.regenerate" +msgstr "Regenerar clave MCP" + +#: src/app/main/ui/settings/integrations.cljs:381 +msgid "integrations.mcp-server.mcp-keys.title" +msgstr "Clave MCP" + +#: src/app/main/ui/settings/integrations.cljs:388 +msgid "integrations.mcp-server.mcp-keys.tootip" +msgstr "La clave MCP es necesaria para la configuración del cliente MCP" + +#: src/app/main/ui/settings/integrations.cljs:124 +msgid "integrations.name.label" +msgstr "Nombre" + +#: src/app/main/ui/settings/integrations.cljs:126 +msgid "integrations.name.placeholder" +msgstr "El nombre te pude ayudar a saber para qué se utiliza el token" + +#: src/app/main/ui/settings/integrations.cljs:103 +msgid "integrations.notification.success.token-copied" +msgstr "Token copiado" + +#: src/app/main/ui/settings/integrations.cljs:103 +#: src/app/main/ui/settings/integrations.cljs:103 +msgid "integrations.notification.success.mcp-key-copied" +msgstr "Clave MCP copiada" + +#: src/app/main/ui/settings/integrations.cljs:64 +msgid "integrations.notification.success.created" +msgstr "Token creado con éxito" + +#: src/app/main/ui/settings/integrations.cljs:327 +msgid "integrations.notification.success.copied-link" +msgstr "Enlace copiado al portapapeles" + +#: src/app/main/ui/settings/integrations.cljs:293 +msgid "integrations.notification.success.mcp-server-disabled" +msgstr "Servidor MCP deshabilitado" + +#: src/app/main/ui/settings/integrations.cljs:299 +msgid "integrations.notification.success.mcp-server-enabled" +msgstr "Servidor MCP habilitado" + +#: src/app/main/ui/settings/integrations.cljs:317 +msgid "integrations.regenerate-mcp-key.info" +msgstr "Regenerar la clave MCP revocará inmediatamente la actual. Cualquier aplicación que la esté utilizando dejará de funcionar." + +#: src/app/main/ui/settings/integrations.cljs:317 +msgid "integrations.regenerate-mcp-key.title" +msgstr "Regenerar clave MCP" + +#: src/app/main/ui/settings/integrations.cljs:318 +msgid "integrations.regenerate-mcp-key.title.created" +msgstr "Clave MCP regenerada" + +#: src/app/main/ui/settings/integrations.cljs:480 +msgid "integrations.title" +msgstr "Integraciones" + +#: src/app/main/ui/settings/integrations.cljs:142 +msgid "integrations.token.will-expire" +msgstr "El token expirará el %s" + +#: src/app/main/ui/settings/integrations.cljs:142 +#: src/app/main/ui/settings/integrations.cljs:142 +msgid "integrations.mcp-key.will-expire" +msgstr "La clave MCP expirará el %s" + +#: src/app/main/ui/settings/integrations.cljs:143 +msgid "integrations.token.will-not-expire" +msgstr "El token no tiene fecha de expiración" + +#: src/app/main/ui/settings/integrations.cljs:143 +#: src/app/main/ui/settings/integrations.cljs:143 +msgid "integrations.mcp-key.will-not-expire" +msgstr "La clave MCP no tiene fecha de expiración" + #: src/app/main/ui/dashboard/comments.cljs:96 msgid "label.mark-all-as-read" msgstr "Marcar todo como leído" @@ -2321,6 +2481,9 @@ msgstr "Director" msgid "labels.discard" msgstr "Descartar" +msgid "labels.dismiss" +msgstr "Cancelar" + #: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 msgid "labels.download" msgstr "Descargar %s" @@ -2456,6 +2619,10 @@ msgstr "Información" msgid "labels.installed-fonts" msgstr "Fuentes instaladas" +#: src/app/main/ui/settings/sidebar.cljs:123 +msgid "labels.integrations" +msgstr "Integraciones" + #: src/app/main/ui/static.cljs:405 msgid "labels.internal-error.desc-message-first" msgstr "Ha ocurrido algo extraño." @@ -3111,30 +3278,6 @@ msgstr "Cambiar correo" msgid "modals.change-email.title" msgstr "Cambiar tu correo" -#: src/app/main/ui/settings/access_tokens.cljs:152, src/app/main/ui/settings/access_tokens.cljs:158 -msgid "modals.create-access-token.copy-token" -msgstr "Copiar token" - -#: src/app/main/ui/settings/access_tokens.cljs:130 -msgid "modals.create-access-token.expiration-date.label" -msgstr "Fecha de expiración" - -#: src/app/main/ui/settings/access_tokens.cljs:124 -msgid "modals.create-access-token.name.label" -msgstr "Nombre" - -#: src/app/main/ui/settings/access_tokens.cljs:126 -msgid "modals.create-access-token.name.placeholder" -msgstr "El nombre te pude ayudar a saber para qué se utiliza el token" - -#: src/app/main/ui/settings/access_tokens.cljs:178 -msgid "modals.create-access-token.submit-label" -msgstr "Crear token" - -#: src/app/main/ui/settings/access_tokens.cljs:111 -msgid "modals.create-access-token.title" -msgstr "Generar access token" - #: src/app/main/ui/dashboard/team.cljs:1127 msgid "modals.create-webhook.submit-label" msgstr "Crear webhook" @@ -3151,18 +3294,6 @@ msgstr "Payload URL" msgid "modals.create-webhook.url.placeholder" msgstr "https://example.com/postreceive" -#: src/app/main/ui/settings/access_tokens.cljs:257 -msgid "modals.delete-acces-token.accept" -msgstr "Borrar token" - -#: src/app/main/ui/settings/access_tokens.cljs:256 -msgid "modals.delete-acces-token.message" -msgstr "¿Seguro que deseas borrar este token?" - -#: src/app/main/ui/settings/access_tokens.cljs:255 -msgid "modals.delete-acces-token.title" -msgstr "Borrar token" - #: src/app/main/ui/settings/delete_account.cljs:56 msgid "modals.delete-account.cancel" msgstr "Cancelar y mantener mi cuenta" @@ -3666,6 +3797,12 @@ msgstr "Invitación enviada con éxito" msgid "notifications.invitation-link-copied" msgstr "Enlace de invitacion copiado" +msgid "notifications.mcp.active-in-another-tab" +msgstr "MCP está activo en otra pestaña. ¿Cambiar a esta?" + +msgid "notifications.mcp.active-in-this-tab" +msgstr "MCP está ahora activo en esta pestaña." + #: src/app/main/ui/settings/delete_account.cljs:24 msgid "notifications.profile-deletion-not-allowed" msgstr "No puedes borrar tu perfil. Reasigna tus equipos antes de seguir." @@ -5068,14 +5205,14 @@ msgstr "Bibliotecas Compartidas - %s - Penpot" msgid "title.default" msgstr "Penpot - Diseño Libre para Equipos" -#: src/app/main/ui/settings/access_tokens.cljs:278 -msgid "title.settings.access-tokens" -msgstr "Perfil - Access tokens" - #: src/app/main/ui/settings/feedback.cljs:161 msgid "title.settings.feedback" msgstr "Danos tu opinión - Penpot" +#: src/app/main/ui/settings/integrations.cljs:278 +msgid "title.settings.integrations" +msgstr "Integraciones - Penpot" + #: src/app/main/ui/settings/notifications.cljs:45 msgid "title.settings.notifications" msgstr "Notificaciones - Penpot" @@ -5571,6 +5708,18 @@ msgstr "Ocultar reglas" msgid "workspace.header.menu.hide-textpalette" msgstr "Ocultar paleta de textos" +msgid "workspace.header.menu.mcp.plugin.status.connect" +msgstr "Conectar" + +msgid "workspace.header.menu.mcp.plugin.status.disconnect" +msgstr "Desconectar" + +msgid "workspace.header.menu.mcp.server.status.enabled" +msgstr "Gestionar (estado: habilitado)" + +msgid "workspace.header.menu.mcp.server.status.disabled" +msgstr "Gestionar (estado: deshabilitado)" + #: src/app/main/ui/workspace/main_menu.cljs:884 msgid "workspace.header.menu.option.edit" msgstr "Editar" @@ -5583,6 +5732,9 @@ msgstr "Archivo" msgid "workspace.header.menu.option.help-info" msgstr "Ayuda e información" +msgid "workspace.header.menu.option.mcp" +msgstr "Servidor MCP" + #: src/app/main/ui/workspace/main_menu.cljs:906 msgid "workspace.header.menu.option.preferences" msgstr "Preferencias" diff --git a/mcp/.gitignore b/mcp/.gitignore index 8a245a5dca..039f51d722 100644 --- a/mcp/.gitignore +++ b/mcp/.gitignore @@ -1,6 +1,8 @@ .idea +.claude node_modules dist +*.tgz *.bak *.orig temp diff --git a/mcp/.serena/memories/project_overview.md b/mcp/.serena/memories/project_overview.md index 528976b077..7e80a5e2d5 100644 --- a/mcp/.serena/memories/project_overview.md +++ b/mcp/.serena/memories/project_overview.md @@ -1,7 +1,10 @@ # Penpot MCP Project Overview - Updated ## Purpose -This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools with bidirectional WebSocket communication. +This project is a Model Context Protocol (MCP) server for Penpot integration. +The MCP server communicates with a Penpot plugin via WebSockets, allowing +the MCP server to send tasks to the plugin and receive results, +enabling advanced AI-driven features in Penpot. ## Tech Stack - **Language**: TypeScript @@ -13,21 +16,22 @@ This project is a Model Context Protocol (MCP) server for Penpot integration. It ## Project Structure ``` -penpot-mcp/ -├── common/ # Shared type definitions +/ (project root) +├── packages/common/ # Shared type definitions │ ├── src/ │ │ ├── index.ts # Exports for shared types │ │ └── types.ts # PluginTaskResult, request/response interfaces │ └── package.json # @penpot-mcp/common package -├── mcp-server/ # Main MCP server implementation +├── packages/server/ # Main MCP server implementation │ ├── src/ │ │ ├── index.ts # Main server entry point │ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation │ │ ├── PluginTask.ts # Now supports result promises │ │ ├── tasks/ # PluginTask implementations │ │ └── tools/ # Tool implementations +| ├── data/ # Contains resources, such as API info and prompts │ └── package.json # Includes @penpot-mcp/common dependency -├── penpot-plugin/ # Penpot plugin with response capability +├── packages/plugin/ # Penpot plugin with response capability │ ├── src/ │ │ ├── main.ts # Enhanced WebSocket handling with response forwarding │ │ └── plugin.ts # Now sends task responses back to server @@ -37,55 +41,24 @@ penpot-mcp/ ## Key Tasks +### Adjusting the System Prompt + +The system prompt file is located in `packages/server/data/initial_instructions.md`. + ### Adding a new Tool -1. Implement the tool class in `mcp-server/src/tools/` following the `Tool` interface. +1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface. IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally. 2. Register the tool in `PenpotMcpServer`. -Look at `PrintTextTool` as an example. - -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/`. +Tools can be associated with a `PluginTask` that is executed in the plugin. +Many tools build on `ExecuteCodePluginTask`, as many operations can be reduced to code execution. ### Adding a new PluginTask -1. Implement the input data interface for the task in `common/src/types.ts`. -2. Implement the `PluginTask` class in `mcp-server/src/tasks/`. -3. Implement the corresponding task handler class in the plugin (`penpot-plugin/src/task-handlers/`). +1. Implement the input data interface for the task in `packages/common/src/types.ts`. +2. Implement the `PluginTask` class in `packages/server/src/tasks/`. +3. Implement the corresponding task handler class in the plugin (`packages/plugin/src/task-handlers/`). * In the success case, call `task.sendSuccess`. * In the failure case, just throw an exception, which will be handled centrally! - * Look at `PrintTextTaskHandler` as an example. -4. Register the task handler in `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 -``` - +4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list. diff --git a/mcp/.serena/project.yml b/mcp/.serena/project.yml index c9ed0f7330..e5729836cd 100644 --- a/mcp/.serena/project.yml +++ b/mcp/.serena/project.yml @@ -1,5 +1,3 @@ - - # whether to use the project's gitignore file to ignore files # Added on 2025-04-07 ignore_all_files_in_gitignore: true @@ -19,7 +17,7 @@ read_only: false # list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. # Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, +# To make sure you have the latest list of tools, and to view their descriptions, # execute `uv run scripts/print_tool_overview.py`. # # * `activate_project`: Activates a project by name. @@ -62,15 +60,17 @@ excluded_tools: [] # (contrary to the memories, which are loaded on demand). initial_prompt: | IMPORTANT: You use an idiomatic, object-oriented style. - 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). - Comments: + Always read the "project_overview" memory. + + Comments: 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. When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless - the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is + the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is required for sentences). # the name by which the project can be referenced within Serena project_name: "penpot-mcp" @@ -128,3 +128,39 @@ encoding: utf-8 # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. languages: - 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: + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} diff --git a/mcp/README.md b/mcp/README.md index 9e9821c065..f23af675e9 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -50,31 +50,65 @@ Follow the steps below to enable the integration. ### Prerequisites -The project requires [Node.js](https://nodejs.org/) (tested with v22.x -with corepack). +The project requires [Node.js](https://nodejs.org/) (tested with v22.x). -Following the installation of Node.js, the tools `pnpm` and `npx` -should be available in your terminal. For ensure corepack installed -and enabled correctly, just execute the `./scripts/setup`. +### 1. Starting the MCP Server and the Plugin Server -It is also required to have `caddy` executeable in the path, it is -used for start a local server for generate types documentation from -the current branch. If you want to run it outside devenv where all -dependencies are already provided, please download caddy from -[here](https://caddyserver.com/download). +#### Running a Released Version via npx -You should probably be using penpot devenv, where all this -dependencies are already present and correctly setup. But nothing -prevents you execute this outside of devenv if you satisfy the -specified dependencies. +The easiest way to launch the servers is to use `npx` to run the appropriate +version that matches your Penpot version. +* If you are using the latest Penpot release, e.g. as served on [design.penpot.app](https://design.penpot.app), run: + ```shell + npx -y @penpot/mcp@latest + ``` +* If you are participating in the MCP beta-test, which uses [test-mcp.penpot.dev](https://test-mcp.penpot.dev), run: + ```shell + npx -y @penpot/mcp@beta + ``` -### 1. Build & Launch the MCP Server and the Plugin Server +Once the servers are running, continue with step 2. -If it's your first execution, install the required dependencies: +#### Running the Source Version from the Repository + +The tools `corepack` and `npx` should be available in your terminal. + +On Windows, use the Git Bash terminal to ensure compatibility with the provided scripts. + +##### Clone the Appropriate Branch of the Repository + +> [!IMPORTANT] +> The branches are subject to change in the future. +> Be sure to check the instructions for the latest information on which branch to use. + +Clone the Penpot repository, using the proper branch depending on the +version of Penpot you want to use the MCP server with. + + * For the current Penpot release 2.14, use the `mcp-prod-2.14.1` branch: + + ```shell + git clone https://github.com/penpot/penpot.git --branch mcp-prod-2.14.1 --depth 1 + ``` + + * For the MCP beta-test, use the `staging` branch: + + ```shell + git clone https://github.com/penpot/penpot.git --branch staging --depth 1 + ``` + +Then change into the `mcp` directory: + +```shell +cd penpot/mcp +``` + +##### Build & Launch the MCP Server and the Plugin Server + +If it's your first execution, install the required dependencies. +(If you are using the Penpot devenv, this step is not necessary, as dependencies are already installed.) ```shell -cd mcp/ ./scripts/setup ``` @@ -86,9 +120,9 @@ pnpm run bootstrap This bootstrap command will: - * install dependencies for all components (`pnpm -r run install`) - * build all components (`pnpm -r run build`) - * start all components (`pnpm -r --parallel run start`) + * install dependencies for all components + * build all components + * start all components ### 2. Load the Plugin in Penpot and Establish the Connection @@ -123,6 +157,19 @@ This bootstrap command will: ### 3. Connect an MCP Client +> [!IMPORTANT] +> **Use an appropriate model.** +> +> We recommend that you ... +> * use the most capable model at your disposal. +> You will achieve the best results with frontier models, +> especially when dealing with more complex tasks. +> Weaker models, including most locally hosted ones, +> are unlikely to produce usable results for anything beyond simple tasks. +> * use a vision language model (VLM), as many design tasks necessitate visual +> inspection. +> (If you are using a standard commercial model, it almost certainly supports vision already.) + By default, the server runs on port 4401 and provides: - **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp` @@ -140,14 +187,9 @@ NOTE: only relevant if you are executing this outside of devenv The `mcp-remote` package can proxy stdio transport to HTTP/SSE, allowing clients that support only stdio to connect to the MCP server indirectly. +Use it to provide the launch command for your MCP client as follows: -1. Install `mcp-remote` globally if you haven't already: - - npm install -g mcp-remote - -2. Use `mcp-remote` to provide the launch command for your MCP client: - - npx -y mcp-remote http://localhost:4401/sse --allow-http + npx -y mcp-remote http://localhost:4401/mcp --allow-http #### Example: Claude Desktop @@ -170,7 +212,7 @@ Add a `penpot` entry under `mcpServers` with the following content: "mcpServers": { "penpot": { "command": "npx", - "args": ["-y", "mcp-remote", "http://localhost:4401/sse", "--allow-http"] + "args": ["-y", "mcp-remote", "http://localhost:4401/mcp", "--allow-http"] } } } @@ -195,37 +237,36 @@ To add the Penpot MCP server to a Claude Code project, issue the command This repository is a monorepo containing four main components: -1. **Common Types** (`common/`): +1. **Common Types** (`packages/common/`): - Shared TypeScript definitions for request/response protocol - Ensures type safety across server and plugin components -2. **Penpot MCP Server** (`mcp-server/`): +2. **Penpot MCP Server** (`packages/server/`): - Provides MCP tools to LLMs for Penpot interaction - Runs a WebSocket server accepting connections from the Penpot MCP plugin - Implements request/response correlation with unique task IDs - Handles task timeouts and proper error reporting -3. **Penpot MCP Plugin** (`penpot-plugin/`): +3. **Penpot MCP Plugin** (`packages/plugin/`): - Connects to the MCP server via WebSocket - Executes tasks in Penpot using the Plugin API - Sends structured responses back to the server# -4. **Helper Scripts** (`python-scripts/`): - - Python scripts that prepare data for the MCP server (development use) +4. **Types Generator** (`types-generator/`): + - Generates data on API types for the MCP server (development use) The core components are written in TypeScript, rendering interactions with the Penpot Plugin API both natural and type-safe. ## Configuration -The Penpot MCP server can be configured using environment variables. All configuration -options use the `PENPOT_MCP_` prefix for consistency. +The Penpot MCP server can be configured using environment variables. ### Server Configuration | Environment Variable | Description | Default | |------------------------------------|----------------------------------------------------------------------------|--------------| -| `PENPOT_MCP_SERVER_LISTEN_ADDRESS` | Address on which the MCP server listens (binds to) | `localhost` | +| `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` | | `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` | | `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` | | `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | @@ -243,7 +284,7 @@ options use the `PENPOT_MCP_` prefix for consistency. | Environment Variable | Description | Default | |-------------------------------------------|-----------------------------------------------------------------------------------------|--------------| -| `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) | +| `PENPOT_MCP_PLUGIN_SERVER_HOST` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) | ## Beyond Local Execution @@ -263,3 +304,17 @@ you may set the following environment variables to configure the two servers * `PENPOT_MCP_SERVER_ADDRESS=`: This sets the hostname or IP address where the MCP server can be reached. The Penpot MCP Plugin uses this to construct the WebSocket URL as `ws://:` (default port: `4402`). + +## Development + +* The [contribution guidelines for Penpot](../CONTRIBUTING.md) apply +* Auto-formatting: Use `pnpm run fmt` +* Generating API type data: See [types-generator/README.md](types-generator/README.md) +* Versioning: Use `bash scripts/set-version` to set the version for the MCP package (in `package.json`). + - Ensure that at least the major, minor and patch components of the version are always up-to-date. + - The MCP plugin assumes that a mismatch between the MCP version and the Penpot version (as returned by the API) + indicates incompatibility, resulting in the display of a warning message in the plugin UI. +* Packaging and publishing: + 1. Ensure release version is set correctly in package.json (call `bash scripts/set-version` to update it automatically) + 2. Create npm package: `bash scripts/pack` (creates `penpot-mcp-.tgz` for publishing) + 3. Publish to npm: `npm publish penpot-mcp-.tgz --access public` diff --git a/mcp/bin/mcp-local.js b/mcp/bin/mcp-local.js new file mode 100644 index 0000000000..65a2b0f763 --- /dev/null +++ b/mcp/bin/mcp-local.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); + +function run(command) { + execSync(command, { cwd: root, stdio: "inherit" }); +} + +// pnpm-lock.yaml is hard-excluded by npm pack; it is shipped as pnpm-lock.dist.yaml +// and restored here before bootstrap runs. +const distLock = path.join(root, "pnpm-lock.dist.yaml"); +const lock = path.join(root, "pnpm-lock.yaml"); +if (fs.existsSync(distLock)) { + fs.copyFileSync(distLock, lock); +} + +try { + run("corepack pnpm run bootstrap"); +} catch (error) { + if (error.code === "ENOENT") { + console.error( + "corepack is required but was not found. It ships with Node.js >= 16." + ); + process.exit(1); + } + process.exit(error.status ?? 1); +} diff --git a/mcp/package.json b/mcp/package.json index fdb75dd03b..6324fe3898 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,15 +1,18 @@ { - "name": "mcp-meta", - "version": "1.0.0", - "description": "", + "name": "@penpot/mcp", + "version": "2.15.0-rc.1.153", + "description": "MCP server for Penpot integration", + "bin": { + "penpot-mcp": "./bin/mcp-local.js" + }, "scripts": { "build": "pnpm -r run build", "build:multi-user": "pnpm -r run build:multi-user", "build:types": "bash ./scripts/build-types", "start": "pnpm -r --parallel run start", - "start:multi-user": "pnpm -r --parallel --filter \"./packages/*\" run start:multi-user", + "start:multi-user": "pnpm -r --parallel run start:multi-user", "bootstrap": "pnpm -r install && pnpm run build && pnpm run start", - "bootstrap:multi-user": "pnpm -r install && pnpm run build:multi-user && pnpm run start:multi-user", + "bootstrap:multi-user": "pnpm -r install && pnpm run build && pnpm run start:multi-user", "fmt": "prettier --write packages/", "fmt:check": "prettier --check packages/" }, @@ -17,8 +20,7 @@ "type": "git", "url": "https://github.com/penpot/penpot.git" }, - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", - "private": true, + "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "devDependencies": { "concurrently": "^9.2.1", "prettier": "^3.0.0" diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index 6c014b34ac..fc6d9c9cfd 100644 --- a/mcp/packages/common/package.json +++ b/mcp/packages/common/package.json @@ -4,7 +4,7 @@ "description": "Shared type definitions and interfaces for Penpot MCP", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", + "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "scripts": { "build": "tsc --build --clean && tsc --build", "watch": "tsc --watch", diff --git a/mcp/packages/plugin/index.html b/mcp/packages/plugin/index.html index b2c08b5dae..de2ff5853c 100644 --- a/mcp/packages/plugin/index.html +++ b/mcp/packages/plugin/index.html @@ -3,12 +3,87 @@ - Penpot plugin example + Penpot MCP Plugin - +
+ -
Not connected
+
+ + Not connected +
+ + + + +
+ + + Execution status + + +
+ Current task +
+ + --- +
+ +
+ Executed code + +
+ +
+
+
diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index 2fca8aeaae..0ccf276181 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -5,9 +5,8 @@ "type": "module", "scripts": { "start": "vite build --watch --config vite.config.ts", - "start:multi-user": "cross-env MULTI_USER_MODE=true vite build --watch --config vite.config.ts", + "start:multi-user": "pnpm run start", "build": "tsc && vite build --config vite.release.config.ts", - "build:multi-user": "tsc && cross-env MULTI_USER_MODE=true vite build --config vite.release.config.ts", "types:check": "tsc --noEmit", "clean": "rm -rf dist/" }, diff --git a/mcp/packages/plugin/public/icon.jpg b/mcp/packages/plugin/public/icon.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9df8dd26a4a08fea0c10604f732f7ceb204b7bf4 GIT binary patch literal 7632 zcmch62UJtdy7s0wY0`TTkRk!8h9X6zgf1NdL1}_?=_1mkh!P-D1f@4YibQ%75h)^F zIw*uLMS4~Ecl0~|@vQ%R=iYy7ehvuDpd@AE!0IU75h0cbSU;A#LK9suBh zAK(m&HwITxvDP=xQ-f=({;uc*z(jZ%09@U??;5CIgP56HKuBhO{{mNc%ht;ScmJCN z_PalV>m2}wpnucmKNXYN*?ZZ74K~4x_b&KwaIo~CO@I5hEr7Fae%tam+t1s>8*HPG zv+o)isDQQ|Xbar_!?yXuw)MD+YySjnBk$_sgL@V(;YNJU-p$w$ypw_#JKzNv0P4Us z-1~#y!Q`F;0I)p(Ac+5GnN2DH)IPc(VY2`XK;R_x-c%pL61I>+Y>z-4TL0 zzJmh*Y!?Cmr8xl5^#cGo;#VD*{ms}QU==$!E_d*A8*l;a0SEvG+yGlZ1hm9~%YYal zeKrZG0K|ku#6*O|#6-j-B*diTH00!DWaRWz)aPiJ=$V{`G8CeDXztdR@Kud}zj3-QhcLBhs z#Ur4_J8J_taBw0d!28F$f_G9pViGcZ0wO}N`U(wzM?i>&PxR};HNyk&3BcE(B{|Pc zN+)*X)&+58PkJ5+hHIe%vy2JZ6}&12Xz+*hIBfrG!j;ngzoiqvIj~=RS^`=?5l9k< z4vCH?{ol#2M&ThNFeAcCl}vY#z8u31{||ySt0x-8S88cj`;w;(xs8rGLy9AwNt#ng{BMPm=-i&q&a3(^Na z&gCer4Qv=;=Dt$y%lSwe&W9gWUB|cGb=D-cW5!Ce`WzcUAqNz8iH%+{hAImk_PHw~ z4ejLBm@=yO<(1pSljB-4T-p`B+=Ku(a>}){<_YUMgDR!Uw5G;+CEbacGF|K0$7SR@ z#w5m8ua7kcrr%aY_q#lg^?K~lQ%C}#K z6fVpmd`TSaJln@Fed)J6x@OulC0^;B!`MsA)PN|?&LiGl3ub~El+3-#ZW zjuPj`IG1Ea=aAUHH!iYpV-{F-;aLqKqpqU(MRR)*KvUm#*6Cf!S_0 zjubyCy^ws(?&0cizKgkPAFFTeb5(~rsoWlKRCH#nE1|yAP$-G!eSgO;uHd;C>E7+5 zmEgR&w=KK+ELzq?Np8}UAH+PN)KmoHgc;Yb6JA&Pn@n{8VNA&&nZzLK;?X|1^b5=- zMKLH4%)Bn1YO*huH~@OtEs9UuUxhAK%ndOjChplML^VFb(xnwP_wYUCc-G-|3z}?_ zvCZW^h}z(5d-_eEOoTUMzC4)XLm7+fBAZVclY)A!OXzs+Q~}dtOHB7?=@TO=w0b%d z9#~W~+h*zH+Tv2tan2Pz%cGoTBlmPofyOl2|I2jfxH-FL)d3l+{7UqO9;Q6HDo#Dl|rt>LI8%*ZYQrF9P(LoCM+hc5|Cj;Z_;mDhT`HH1ZaIL*B=q~4s`eu_3LLCvl4lQJ3M{3t|2Or~~>W4ji@s*is zSrsF$n>$_AD8;b3Q-N_S-g)mx%6&6+r#!kl&)a%=4?-nQvDW%-=TwJ?HQbm7>*XIsk_-ukZMEVgNv4>LwzA2X&$^e7=OFMf)mEVrHmky zhjQZALN|^2=GGR6o?GygR(%@8D%3tYeijsO<@oP-{&4}OKuFe!|7Ai&+q=(8uw@`D zDam7CsOzfMB>N2%j`xVD8n1b2*L-_a;y>SOU-z&YZV}k|K!M@D@{V@O$|49CndJTR z<9tpheJ29jSopY{fB>A|Zmm3!@`IZ>4jRQI@JqdqtA0acN_=ukdexE&4JRdP@pU{9 z^7o`>MZ9K{;E9bz&+KoQM}L2NGptdyJ$a41huxzJGuo8ZEymGxXig1ZW`r)58D+Js z>!D=Wpo1m}?2xrMbZC!!k$cP9cRK@VoY!WP^q(XV-F*`Fmajp_qh5REc0$|1)u$Chl6pN1t-~>kRirLAr8Q3Q3Ad z+2tDMix2&0)lHfyPW8xKVMAG=?>CNwEDYbX(~85)M#RGNO9@_+()VLBh*jxWJI5qP zLdS@o2fpB`l0;rZq1{ui8{Rm*y=;N zQ}>(sn;SH=w0N|CW=yK~Mv~rxdWJl}y$+Sasd*(Vh?M*U0-;I~8jY7#u+eL7X{ zNBLnx#(t5)2g7vrJ46NtN5M!ih+uXon};d#0z3aesK+Iq3o+9Fh!?ju!x~lEtJjD> zc4A}Erk(u`&rtIx!VjY`AEx>~R>Bwx)YSHMeBx>8m5#aYhBbnO#>|nw)xO{DoluX0 z_~mXG(RY0xKH|Uz#(wnck^jw91$x1?b}0cHOz^Lg+qD@>uMa7@Z7mNXE6;-%#fug3 za8~9$2J$+hQ1m{5*`4BMQo4g2=cu{-y2wE8nzlH+%bsywaN=Hep~p$HW^(-XPHq?d ziju#mi{^T#t&68viLF|=(i;AeyPt)YRP~+7hdnW*k=-x6ivsHh&0XJ^OkH%*zdndv z;t){nV}EO&X36DV-MQ>aQyVgpxxsZewD(7oX_{%bJBZp+u?FR=Gr()UDVO`+1lX_ptI=T~QHzWyz&!R&FeOxZyAN5|PSV9`=hta9IdJ>dS{C-1{p z!s>4MlH_#=bWCo$*@(YxN~p{SGi>tds4(dRsLZ+qE>d$ICS*5H=VUb*1XB*yO2v`b z1`DaE?RT>wTiH7(!xXviZ1k}YO$%NqaDLL8klm!vFfndI$8epwv|6@Rdp#n%?f4k7 zeS}~S=WBJ7lu+aRF4!AalN+*D)nTRkO|xrd{sbFv>t|SXoN@V_)^MqJ`uE+cq}1If znC}DoXMm7S@K1Wq6lN>oZ~u;|)IV(uw0Rn?{8Tdd9?TE+Q7)G{bV6 zr%vIW-Q~xuJEnX$W=u#LVG45s&`8DJpZ(rm)Xlv=4QbP!-*{l0@7 zZn=E-TpIqzoAy>G&hV?du_|!FAjEhUo$K%B9MKDC^ZfI6i8VBq*1dFOdvP=N>5$vW?*Q(xEC2GZsiwXS^J%=dR7|d z=sF-NFByP#ZiuPxS9B)cIS?Dd8i`w!3`!#!vnrF*qoeAMbRR4VxoAdeS!I}X`6lF@ zA9^c#Y9v@cb;qr0aClS`jB&qkuZ+lVEm%T8jlIj#0;ml$=BfsC^!T*bB8g?TScDwxE{pXgEP;BaOUU{#aMomkyRdmazG}D}Z|mCD{)1C}d3ldq;Z2p`E^ewa z|8J2@)a!9x+j)T&toc?s)SvxUC zwJTG#sBX*QH}%EE=3fsquyZGT_HKa-GH+hJE6DXqy%Q4O{(VDXl@`+AUp#oy8+1GG z$$2X|t24m13M1&aGT5)UnPd7;_#(WFfrZb`B2sMO-RG-T8}xOY>HYr_C~mxW z<(YCarcvsnHPadFNjEi>%}^gOmMcZ$y;fidkFsHMDPE_ftWH+1+ZgbJ52@E7F4iFg z$~*&1$<6NYoB=yL$`m=Hx2i1Q(vv|_VhRP)K-ecNr)14dX%lsvYK0Ix{nDqQrW)b% z^`ok;HAcptVbTI-NbCc~MwxKQq8kK;%kIZks5zcER{2rTk+m5bFRKwYm$LC@VL zi(5`kKt?LR7Y{@pqBND62vAg9V)o$VJ_AgbJ}2(rJ3&q>o8A)i zLqi9K)ZsepG?&F;6RablS@={QzobVRC_Qu>SpiYhua#IVDNm|UX43+-ywI18N?&L1 z;S{px^t4xsdigbJylO;898zQ)p%_`V>+1PgoZI&ksr(iu8V8&9PwvOX4A51wQZ@FW zUw8&kRs}h$a}^xIPO&xvo>4Y_YnP+X2eTw2@e}u~X4PaeDkaw!Ea@phkj%&1_xleQ@V2RjCJ6Bnj2PkIY2+ z&Arxy33JL5-iY#2h^zoop%0z+SjiEfddJ$|PoM;dh8_9mU=W3+wc2A@kD7FOvr4zu z)$MC^5FV)&%5++fUwDJjPIh}8dD}CHsM+*`)1x-xaF7X#y=p{897Y8NO1Hu3<1Q&tPJ^dBa*Ecv4j5$~V8X3OvjM4@8D$E1Hp#d3~W#!nYID2b}M zM_A>%G&XjQ1g^5HbLF*{&?ncEzUK}NxpyzK3px02&n-yj=0vhTwL>>!cbj#Rs1irE zf?a}iDJc=eKT?$xzhxuiHARUME6xCsJp-j33fBH$*@>iY%>MFkKPab+Oy2g5wN&XI z{}EPjkJJ_0AzNP5^)=2=dwIF2WtCEPRY>-@=r-0my3Dkp0mDXkF$YeZACdcZP54UW zX1no&MpeBVZfWSuSI210X+TjxUPQd5EMRzFNQl`ILPcy|wOdhqZ|e)KOKVAj{v_7X z-*Ja`)Q^6yC53w-#iUk6tYx{K^Pa9fMKJaukeBX z(UE1qPO4x9t4yg$02_2d$wEtS|Fd9&Vl3Vp~r1war3p%kMZYb~3(xIRpKT?%zmn8Z_e)E;^)`th3v&0oGvdlP0 zgk^C0->7~z^LGAqxY;HbJr&zBSL}}>$Vg^p|^uvr7fe$A= z8rY2_X)EOcJ8;_cVLPPNw@fr%x^Dg7ATsc$dj3O96BT`AJ`uMRS#X0=p*7;v&H$>x zQOZG-vrlUdIy)cJy4`_)HD*E?Kz-vr%*U<>icojC^fMWDf60#Ja&GEZpR(CAjQi>& z;_U`aOP_sjof+|3S8!g=*UKi%pU>8rwV&^C&^wDI9(C0~vuZt`c9C->Z-%=FOXb!A zxyF>=WE!hgUg^g>z9C1sr6Nqf7R6JYij)h8Z$SK9x7zW~Xdh=X6e!ME4{9LgWs#*I z_WCMri1ikq6vlLbYmWFl^!C!J38b$P{-ykKx7Mu?#7wXo#`mW%r4{0awZU;rm$hV{ zoY$1ECY7mNgmG`RMg2-TD5LB8JjeNbhLoX3U>KBzBkx&&lwkl;NYVJYb6)cSLly%V z&6Es|!yC!Za==B8GHE1HA&7FI_Sz0>=7gEZY3qO8HoKnm&_|gf%JEe1V?vL+25^sB z)ud7?yA;4Xq?l=*US3OHdF3V{&FVj)^#U7uzhPh;Is__RJDilZ{EIf+oIQew2vL!J zbkWWhK8yDgmypO;J#Kdg5E;v*w4BKyztEs$TF7$qityqA10TB^{p|Z5P*Tu6c}-CO zDvIQ=3lko*i=WgT;yz|f1x6q|8h9n8m($osP<2w%9n(ax@8lpH1RSC9Rb>RnXyAAugZB}yK!3Oe1vy1ecj9F=;1Gh&ZhnM$TG#JMzyZo0xTR+a5h;EcPU z#4f#mQDIv0ZPgoWxe0};Wvtw;k|DE&fO>@qv2)v4v-#)a3DR-7w z1q~BPsq8F(u4(eT!FXk5BqKQQk4W@O62MRH3Lw;K=tvC=&p zkpFJiWW|c1KwJq}E&q$fL5FqZ^)`uqq2#DIb##delG6n1zy}?1`Q@^WUp&=9inZ1? z&tw8{GwwNvi-$a)zy+e;oof`Dj4;Dy*IXNE#9M@yeHXaY;jdFeUh!VV{ZaNzuz`-B z=YL#@KTf2NHW=b3mlunR{ZY(mmP=eMqzZux6X1Sq5f0*iWC@~)Ey%hN`ci+qo7a?{Z@|SW%J<;Sy_Q82qB9-7>K%` zT&BqIyB~>)MFN(W^s+mYc=+~rpeOm;Cb({%U1?O6Q=HnhXvxSYH~McL&cN#q$| zV4zT#dDnOZ|3850k5_>ROJFm8N@b!PB2yR8#$2K|PZcjo2yWm!51&41T*|p zM|MJ1c+->ow<0O6)9r5b>sLlksN~0^dhWcbsY1il6Zl%hZ^(`D=DZxb=sr*FO&1*J z@!XBU7B@`Xt}$%`Th`L}#O9|m+TvC%;v$IU>3xMvqI=8#-G4VIdOKa|M<=fu*H?vZ z>b7mu(>D0wc*Dq+MxSn*4&jl}I(QZ?+3fbr{QUfMR;w>#mJf%|zCs))i)z zP7!m6WTHT{V6N9Pr{B32U!HN2+}0PbtU|x=#N2Ln)-J<0dvx;PTDn=!Y50XI6>FFD zrz!$t1G|S7@6Ds$cU-U(dx1%w<}E`{8*Q;Yn;XwNy+($8{7{ejriUD%%Yzijzdzso z*Mnao_uu2wbFXV1XWiQUH9kv$aBIudHorB^9br$<;jS&Y3EH}y+%FFfgOwflbvv%E z7V#C-49RO)@%Rjvw;0Aa+NRbgOUnns@DtiPpAWIIwx38kLS8X5w^ucXIEvhfdHLU+ M+yAL{;b-Ij2S*HOvH$=8 literal 0 HcmV?d00001 diff --git a/mcp/packages/plugin/public/manifest.json b/mcp/packages/plugin/public/manifest.json index e2a769c7f8..aa97095b30 100644 --- a/mcp/packages/plugin/public/manifest.json +++ b/mcp/packages/plugin/public/manifest.json @@ -1,6 +1,7 @@ { "name": "Penpot MCP Plugin", "code": "plugin.js", + "icon": "icon.jpg", "version": 2, "description": "This plugin enables interaction with the Penpot MCP server", "permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"] diff --git a/mcp/packages/plugin/src/PenpotUtils.ts b/mcp/packages/plugin/src/PenpotUtils.ts index 964cf70f4c..2c2cc3e4e5 100644 --- a/mcp/packages/plugin/src/PenpotUtils.ts +++ b/mcp/packages/plugin/src/PenpotUtils.ts @@ -1,4 +1,4 @@ -import { Board, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape } from "@penpot/plugin-types"; +import { Board, Bounds, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape, Text } from "@penpot/plugin-types"; export class PenpotUtils { /** @@ -189,6 +189,24 @@ export class PenpotUtils { 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. * Visual containment means all edges of the child are within the parent's bounding box. @@ -198,11 +216,13 @@ export class PenpotUtils { * @returns true if child is fully contained within parent bounds, false otherwise */ public static isContainedIn(child: Shape, parent: Shape): boolean { + const childBounds = this.getBounds(child); + const parentBounds = this.getBounds(parent); return ( - child.x >= parent.x && - child.y >= parent.y && - child.x + child.width <= parent.x + parent.width && - child.y + child.height <= parent.y + parent.height + childBounds.x >= parentBounds.x && + childBounds.y >= parentBounds.y && + childBounds.x + childBounds.width <= parentBounds.x + parentBounds.width && + childBounds.y + childBounds.height <= parentBounds.y + parentBounds.height ); } @@ -298,39 +318,16 @@ export class PenpotUtils { /** * 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 * @returns The decoded data as a Uint8Array */ - public static atob(base64: string): Uint8Array { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - const lookup = new Uint8Array(256); - for (let i = 0; i < chars.length; i++) { - lookup[chars.charCodeAt(i)] = i; + public static base64ToByteArray(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); } - - let bufferLength = base64.length * 0.75; - if (base64[base64.length - 1] === "=") { - bufferLength--; - if (base64[base64.length - 2] === "=") { - bufferLength--; - } - } - - const bytes = new Uint8Array(bufferLength); - let p = 0; - for (let i = 0; i < base64.length; i += 4) { - const encoded1 = lookup[base64.charCodeAt(i)]; - const encoded2 = lookup[base64.charCodeAt(i + 1)]; - const encoded3 = lookup[base64.charCodeAt(i + 2)]; - const encoded4 = lookup[base64.charCodeAt(i + 3)]; - - bytes[p++] = (encoded1 << 2) | (encoded2 >> 4); - bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2); - bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63); - } - return bytes; } @@ -360,7 +357,7 @@ export class PenpotUtils { height: number | undefined ): Promise { // convert base64 to Uint8Array - const bytes = PenpotUtils.atob(base64); + const bytes = PenpotUtils.base64ToByteArray(base64); // upload the image data to Penpot const imageData = await penpot.uploadMediaData(name, bytes, mimeType); @@ -423,6 +420,11 @@ export class PenpotUtils { * - For mode="fill", it will be whatever format the fill image is stored in. */ public static async exportImage(shape: Shape, mode: "shape" | "fill", asSVG: boolean): Promise { + // Updates are asynchronous in Penpot, so wait a tick to ensure any pending updates are applied before export. + // The constant wait time is a temporary workardound until a better solution for penpot/penpot-mcp#27 + // is implemented. + await new Promise((resolve) => setTimeout(resolve, 200)); + // Perform export switch (mode) { case "shape": return shape.export({ type: asSVG ? "svg" : "png" }); diff --git a/mcp/packages/plugin/src/index.d.ts b/mcp/packages/plugin/src/index.d.ts new file mode 100644 index 0000000000..42587c8304 --- /dev/null +++ b/mcp/packages/plugin/src/index.d.ts @@ -0,0 +1,21 @@ +import "@penpot/plugin-types"; + +declare module "@penpot/plugin-types" { + interface Penpot { + /** The Penpot application version string. */ + version: string; + } +} + +interface McpOptions { + getToken(): string; + getServerUrl(): string; + setMcpStatus(status: string); + on(eventType: "disconnect" | "connect", cb: () => void); +} + +declare global { + const mcp: undefined | McpOptions; +} + +export {}; diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 18877d35dd..40b5bd7ba8 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -1,29 +1,74 @@ import "./style.css"; // get the current theme from the URL -const searchParams = new URLSearchParams(window.location.search); +const searchParams = new URLSearchParams(window.location.hash.split("?")[1]); document.body.dataset.theme = searchParams.get("theme") ?? "light"; -// Determine whether multi-user mode is enabled based on URL parameters -const isMultiUserMode = searchParams.get("multiUser") === "true"; -console.log("Penpot MCP multi-user mode:", isMultiUserMode); - // WebSocket connection management let ws: WebSocket | null = null; -const statusElement = document.getElementById("connection-status"); + +const statusPill = document.getElementById("connection-status") as HTMLElement; +const statusText = document.getElementById("status-text") as HTMLElement; +const currentTaskEl = document.getElementById("current-task") as HTMLElement; +const executedCodeEl = document.getElementById("executed-code") as HTMLTextAreaElement; +const copyCodeBtn = document.getElementById("copy-code-btn") as HTMLButtonElement; +const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement; +const disconnectBtn = document.getElementById("disconnect-btn") as HTMLButtonElement; +const versionWarningEl = document.getElementById("version-warning") as HTMLElement; +const versionWarningTextEl = document.getElementById("version-warning-text") as HTMLElement; /** - * Updates the connection status display element. + * Updates the status pill and button visibility based on connection state. * - * @param status - the base status text to display - * @param isConnectedState - whether the connection is in a connected state (affects color) - * @param message - optional additional message to append to the status + * @param code - the connection state code ("idle" | "connecting" | "connected" | "disconnected" | "error") + * @param label - human-readable label to display inside the pill */ -function updateConnectionStatus(status: string, isConnectedState: boolean, message?: string): void { - if (statusElement) { - const displayText = message ? `${status}: ${message}` : status; - statusElement.textContent = displayText; - statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)"; +function updateConnectionStatus(code: string, label: string): void { + if (statusPill) { + statusPill.dataset.status = code; + } + if (statusText) { + statusText.textContent = label; + } + + const isConnected = code === "connected"; + if (connectBtn) connectBtn.hidden = isConnected; + if (disconnectBtn) disconnectBtn.hidden = !isConnected; + + parent.postMessage( + { + type: "update-connection-status", + status: code, + }, + "*" + ); +} + +/** + * Updates the "Current task" display with the currently executing task name. + * + * @param taskName - the task name to display, or null to reset to "---" + */ +function updateCurrentTask(taskName: string | null): void { + if (currentTaskEl) { + currentTaskEl.textContent = taskName ?? "---"; + } + if (taskName === null) { + updateExecutedCode(null); + } +} + +/** + * Updates the executed code textarea with the last code run during task execution. + * + * @param code - the code string to display, or null to clear + */ +function updateExecutedCode(code: string | null): void { + if (executedCodeEl) { + executedCodeEl.value = code ?? ""; + } + if (copyCodeBtn) { + copyCodeBtn.disabled = !code; } } @@ -44,31 +89,41 @@ function sendTaskResponse(response: any): void { /** * Establishes a WebSocket connection to the MCP server. */ -function connectToMcpServer(): void { +function connectToMcpServer(baseUrl?: string, token?: string): void { if (ws?.readyState === WebSocket.OPEN) { - updateConnectionStatus("Already connected", true); + updateConnectionStatus("connected", "Connected"); return; } try { - let wsUrl = PENPOT_MCP_WEBSOCKET_URL; - if (isMultiUserMode) { - // TODO obtain proper userToken from penpot - const userToken = "dummyToken"; - wsUrl += `?userToken=${encodeURIComponent(userToken)}`; + let wsUrl = baseUrl || PENPOT_MCP_WEBSOCKET_URL; + let wsError: unknown | undefined; + + if (token) { + wsUrl += `?userToken=${encodeURIComponent(token)}`; } + ws = new WebSocket(wsUrl); - updateConnectionStatus("Connecting...", false); + updateConnectionStatus("connecting", "Connecting..."); ws.onopen = () => { - console.log("Connected to MCP server"); - updateConnectionStatus("Connected to MCP server", true); + setTimeout(() => { + if (ws) { + console.log("Connected to MCP server"); + updateConnectionStatus("connected", "Connected"); + } + }, 100); }; ws.onmessage = (event) => { - console.log("Received from MCP server:", event.data); try { + console.log("Received from MCP server:", event.data); const request = JSON.parse(event.data); + // Track the current task received from the MCP server + if (request.task) { + updateCurrentTask(request.task); + updateExecutedCode(request.params?.code ?? null); + } // Forward the task request to the plugin for execution parent.postMessage(request, "*"); } catch (error) { @@ -77,34 +132,70 @@ function connectToMcpServer(): void { }; ws.onclose = (event: CloseEvent) => { - console.log("Disconnected from MCP server"); - const message = event.reason || undefined; - updateConnectionStatus("Disconnected", false, message); + // If we've send the error update we don't send the disconnect as well + if (!wsError) { + console.log("Disconnected from MCP server"); + const label = event.reason ? `Disconnected: ${event.reason}` : "Disconnected"; + updateConnectionStatus("disconnected", label); + updateCurrentTask(null); + } ws = null; }; ws.onerror = (error) => { console.error("WebSocket error:", error); + wsError = error; // note: WebSocket error events typically don't contain detailed error messages - updateConnectionStatus("Connection error", false); + updateConnectionStatus("error", "Connection error"); }; } catch (error) { console.error("Failed to connect to MCP server:", error); - const message = error instanceof Error ? error.message : undefined; - updateConnectionStatus("Connection failed", false, message); + const reason = error instanceof Error ? error.message : undefined; + const label = reason ? `Connection failed: ${reason}` : "Connection failed"; + updateConnectionStatus("error", label); } } -document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click", () => { +copyCodeBtn?.addEventListener("click", () => { + const code = executedCodeEl?.value; + if (!code) return; + + navigator.clipboard.writeText(code).then(() => { + copyCodeBtn.classList.add("copied"); + setTimeout(() => copyCodeBtn.classList.remove("copied"), 1500); + }); +}); + +connectBtn?.addEventListener("click", () => { connectToMcpServer(); }); +disconnectBtn?.addEventListener("click", () => { + ws?.close(); +}); + // Listen plugin.ts messages window.addEventListener("message", (event) => { - if (event.data.source === "penpot") { + if (event.data.type === "start-server") { + connectToMcpServer(event.data.url, event.data.token); + } + if (event.data.type === "version-mismatch") { + if (versionWarningEl && versionWarningTextEl) { + versionWarningTextEl.innerHTML = + `Version mismatch detected: This version of the MCP server is intended for Penpot ` + + `${event.data.mcpVersion} while the current version is ${event.data.penpotVersion}. ` + + `Executions may not work or produce suboptimal results.`; + versionWarningEl.hidden = false; + } + } + if (event.data.type === "stop-server") { + ws?.close(); + } else if (event.data.source === "penpot") { document.body.dataset.theme = event.data.theme; } else if (event.data.type === "task-response") { // Forward task response back to MCP server sendTaskResponse(event.data.response); } }); + +parent.postMessage({ type: "ui-initialized" }, "*"); diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index e6a1fad33e..3827db70eb 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -1,22 +1,58 @@ import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler"; import { Task, TaskHandler } from "./TaskHandler"; +/** + * Extracts the major.minor.patch prefix from a version string. + * + * @param version - a version string starting with major.minor.patch + * @returns the major.minor.patch prefix, or the original string if it does not match + */ +function extractVersionPrefix(version: string): string { + const match = version.match(/^(\d+\.\d+\.\d+)/); + return match ? match[1] : version; +} + +mcp?.setMcpStatus("connecting"); + /** * Registry of all available task handlers. */ const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()]; -// Determine whether multi-user mode is enabled based on build-time configuration -declare const IS_MULTI_USER_MODE: boolean; -const isMultiUserMode = typeof IS_MULTI_USER_MODE !== "undefined" ? IS_MULTI_USER_MODE : false; - // Open the plugin UI (main.ts) -penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, { width: 158, height: 200 }); +penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, { + width: 236, + height: 210, + hidden: !!mcp, +} as any); -// Handle messages -penpot.ui.onMessage((message) => { - // Handle plugin task requests - if (typeof message === "object" && message.task && message.id) { +// Register message handlers +penpot.ui.onMessage((message) => { + if (typeof message === "object" && message.type === "ui-initialized") { + // Check Penpot version compatibility + const penpotVersionPrefix = penpot.version ? extractVersionPrefix(penpot.version) : "<2.15"; // pre-2.15 versions don't have version info + const mcpVersionPrefix = extractVersionPrefix(PENPOT_MCP_VERSION); + console.log(`Penpot version: ${penpotVersionPrefix}, MCP version: ${mcpVersionPrefix}`); + const isLocalPenpotVersion = penpotVersionPrefix == "0.0.0"; + if (penpotVersionPrefix !== mcpVersionPrefix && !isLocalPenpotVersion) { + penpot.ui.sendMessage({ + type: "version-mismatch", + mcpVersion: mcpVersionPrefix, + penpotVersion: penpotVersionPrefix, + }); + } + // Initiate connection to remote MCP server (if enabled) + if (mcp) { + penpot.ui.sendMessage({ + type: "start-server", + url: mcp?.getServerUrl(), + token: mcp?.getToken(), + }); + } + } else if (typeof message === "object" && message.type === "update-connection-status") { + mcp?.setMcpStatus(message.status || "unknown"); + } else if (typeof message === "object" && message.task && message.id) { + // Handle plugin tasks submitted by the MCP server handlePluginTaskRequest(message).catch((error) => { console.error("Error in handlePluginTaskRequest:", error); }); @@ -59,6 +95,21 @@ async function handlePluginTaskRequest(request: { id: string; task: string; para } } +if (mcp) { + mcp.on("disconnect", async () => { + penpot.ui.sendMessage({ + type: "stop-server", + }); + }); + mcp.on("connect", async () => { + penpot.ui.sendMessage({ + type: "start-server", + url: mcp?.getServerUrl(), + token: mcp?.getToken(), + }); + }); +} + // Handle theme change in the iframe penpot.on("themechange", (theme) => { penpot.ui.sendMessage({ diff --git a/mcp/packages/plugin/src/style.css b/mcp/packages/plugin/src/style.css index 030f2204e9..53e0a9da3d 100644 --- a/mcp/packages/plugin/src/style.css +++ b/mcp/packages/plugin/src/style.css @@ -1,10 +1,190 @@ @import "@penpot/plugin-styles/styles.css"; body { - line-height: 1.5; - padding: 10px; + margin: 0; + padding: 0; } -p { - margin-block-end: 0.75rem; +.plugin-container { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-16) var(--spacing-8); + box-sizing: border-box; +} + +/* ── Status pill ─────────────────────────────────────────────────── */ + +.status-pill { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-16); + border-radius: var(--spacing-8); + border: 1px solid var(--background-quaternary); + color: var(--foreground-secondary); + width: 100%; + box-sizing: border-box; +} + +.status-pill[data-status="connected"] { + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.status-pill[data-status="disconnected"], +.status-pill[data-status="error"] { + border-color: var(--error-500); + color: var(--error-500); +} + +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: currentColor; + flex-shrink: 0; +} + +/* ── Collapsible section ─────────────────────────────────────────── */ + +.collapsible-section { + border: 1px solid var(--background-quaternary); + border-radius: var(--spacing-8); + overflow: hidden; +} + +.collapsible-header { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + cursor: pointer; + color: var(--foreground-secondary); + list-style: none; + user-select: none; +} + +.collapsible-header::-webkit-details-marker { + display: none; +} + +.collapsible-arrow { + flex-shrink: 0; + transition: transform 0.2s ease; +} + +details[open] > .collapsible-header .collapsible-arrow { + transform: rotate(90deg); +} + +.collapsible-body { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-4) var(--spacing-12) var(--spacing-12); + border-top: 1px solid var(--background-quaternary); +} + +/* ── Tool section ────────────────────────────────────────────────── */ + +.tool-label { + color: var(--foreground-secondary); +} + +.tool-display { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--spacing-8); + background-color: var(--background-tertiary); + color: var(--foreground-secondary); + min-height: 32px; + box-sizing: border-box; +} + +.tool-icon { + flex-shrink: 0; + opacity: 0.7; +} + +/* ── Code section ────────────────────────────────────────────────── */ + +.code-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: var(--spacing-8); +} + +.code-textarea { + width: 100%; + height: 100px; + resize: vertical; + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--spacing-8); + border: 1px solid var(--background-quaternary); + background-color: var(--background-tertiary); + color: var(--foreground-secondary); + font-family: monospace; + font-size: 11px; + line-height: 1.5; + box-sizing: border-box; + outline: none; +} + +.copy-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--background-quaternary); + border-radius: var(--spacing-4); + background-color: transparent; + color: var(--foreground-secondary); + cursor: pointer; + flex-shrink: 0; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.copy-btn:hover:not(:disabled) { + background-color: var(--background-tertiary); + color: var(--foreground-primary); +} + +.copy-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.copy-btn.copied { + color: var(--accent-primary); + border-color: var(--accent-primary); +} + +/* ── Version warning ─────────────────────────────────────────────── */ + +.version-warning { + align-items: flex-start; + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--spacing-8); + border: 1px solid var(--warning-500, #f59e0b); + color: var(--warning-500, #f59e0b); + width: 100%; + box-sizing: border-box; +} + +/* ── Action buttons ──────────────────────────────────────────────── */ + +#connect-btn, +#disconnect-btn { + width: 100%; + margin-top: var(--spacing-4); } diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index 86fc62710f..85ed5a32d1 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -195,10 +195,44 @@ export class ExecuteCodeTaskHandler extends TaskHandler { const context = this.context; const code = task.params.code; - let result: any = await (async (ctx) => { - const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`); - return fn(...Object.values(ctx)); - })(context); + // set the flags naturalChildOrdering and throwValidationErrors to true during code execution. + // TODO: Remove all ts-ignore once Penpot types have been updated + let originalNaturalChildOrdering: any, originalThrowValidationErrors: any; + // @ts-ignore + if (penpot.flags) { + // @ts-ignore + originalNaturalChildOrdering = penpot.flags.naturalChildOrdering; + // @ts-ignore + penpot.flags.naturalChildOrdering = true; + // @ts-ignore + originalThrowValidationErrors = penpot.flags.throwValidationErrors; + // @ts-ignore + penpot.flags.throwValidationErrors = 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 the flags + // @ts-ignore + penpot.flags.naturalChildOrdering = originalNaturalChildOrdering; + // @ts-ignore + penpot.flags.throwValidationErrors = originalThrowValidationErrors; + } console.log("Code execution result:", result); diff --git a/mcp/packages/plugin/src/vite-env.d.ts b/mcp/packages/plugin/src/vite-env.d.ts index ddbf746e04..252ff654af 100644 --- a/mcp/packages/plugin/src/vite-env.d.ts +++ b/mcp/packages/plugin/src/vite-env.d.ts @@ -2,3 +2,4 @@ declare const IS_MULTI_USER_MODE: boolean; declare const PENPOT_MCP_WEBSOCKET_URL: string; +declare const PENPOT_MCP_VERSION: string; diff --git a/mcp/packages/plugin/vite.config.ts b/mcp/packages/plugin/vite.config.ts index 38e610f247..9e79ca7f73 100644 --- a/mcp/packages/plugin/vite.config.ts +++ b/mcp/packages/plugin/vite.config.ts @@ -1,11 +1,16 @@ import { defineConfig } from "vite"; import livePreview from "vite-live-preview"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const rootPkg = require("../../package.json"); let WS_URI = process.env.WS_URI || "http://localhost:4402"; -let MULTI_USER_MODE = process.env.MULTI_USER_MODE === "true"; +let SERVER_HOST = process.env.PENPOT_MCP_PLUGIN_SERVER_HOST ?? "localhost"; +let MCP_VERSION = JSON.stringify(rootPkg.version); -console.log("Will define IS_MULTI_USER_MODE as:", JSON.stringify(MULTI_USER_MODE)); -console.log("Will define PENPOT_MCP_WEBSOCKET_URL as:", JSON.stringify(WS_URI)); +console.log("PENPOT_MCP_WEBSOCKET_URL:", JSON.stringify(WS_URI)); +console.log("PENPOT_MCP_VERSION:", MCP_VERSION); export default defineConfig({ base: "./", @@ -31,13 +36,13 @@ export default defineConfig({ }, }, preview: { - host: "0.0.0.0", + host: SERVER_HOST, port: 4400, cors: true, allowedHosts: [], }, define: { - IS_MULTI_USER_MODE: JSON.stringify(process.env.MULTI_USER_MODE === "true"), PENPOT_MCP_WEBSOCKET_URL: JSON.stringify(WS_URI), + PENPOT_MCP_VERSION: MCP_VERSION, }, }); diff --git a/mcp/packages/plugin/vite.release.config.ts b/mcp/packages/plugin/vite.release.config.ts index 462fc9f598..7156a2f85c 100644 --- a/mcp/packages/plugin/vite.release.config.ts +++ b/mcp/packages/plugin/vite.release.config.ts @@ -4,7 +4,6 @@ import baseConfig from "./vite.config"; export default mergeConfig( baseConfig, defineConfig({ - base: "./", plugins: [], }) ); diff --git a/mcp/packages/server/.gitignore b/mcp/packages/server/.gitignore index e69de29bb2..54e8e7dc16 100644 --- a/mcp/packages/server/.gitignore +++ b/mcp/packages/server/.gitignore @@ -0,0 +1 @@ +/pnpm-lock.yaml diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index 18079c5c3d..901037c249 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -11,7 +11,7 @@ Penpot: open: ( name: string, url: string, - options?: { width: number; height: number }, + options?: { width: number; height: number; hidden: boolean }, ) => void; size: { width: number; height: number } | null; resize: (width: number, height: number) => void; @@ -84,6 +84,7 @@ Penpot: distributeHorizontal(shapes: Shape[]): void; distributeVertical(shapes: Shape[]): void; flatten(shapes: Shape[]): Path[]; + createVariantFromComponents(shapes: Board[]): VariantContainer; } ``` @@ -99,7 +100,7 @@ Penpot: open: ( name: string, url: string, - options?: { width: number; height: number }, + options?: { width: number; height: number; hidden: boolean }, ) => void; size: { width: number; height: number } | null; resize: (width: number, height: number) => void; @@ -110,7 +111,7 @@ Penpot: Type Declaration - * open: (name: string, url: string, options?: { width: number; height: number }) => void + * open: ( name: string, url: string, options?: { width: number; height: number; hidden: boolean },) => void Opens the plugin UI. It is possible to develop a plugin without interface (see Palette color example) but if you need, the way to open this UI is using `penpot.ui.open`. 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. @@ -823,6 +824,24 @@ Penpot: to flatten Returns Path[] + createVariantFromComponents: |- + ``` + createVariantFromComponents(shapes: Board[]): VariantContainer + ``` + + Combine several standard Components into a VariantComponent. Similar to doing it + with the contextual menu on the Penpot interface. + All the shapes passed as arguments should be main instances. + + Parameters + + * shapes: Board[] + + A list of main instances of the components to combine. + + Returns VariantContainer + + The variant container created ActiveUser: overview: |- Interface ActiveUser @@ -1062,7 +1081,7 @@ Board: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -1071,10 +1090,10 @@ Board: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -1090,14 +1109,14 @@ Board: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -1114,7 +1133,7 @@ Board: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -1193,12 +1212,20 @@ Board: ``` The horizontal sizing behavior of the board. + It can be one of the following values: + + * 'fix': The containers has its own intrinsic fixed size. + * 'auto': The container fits the content. verticalSizing: |- ``` verticalSizing?: "auto" | "fix" ``` The vertical sizing behavior of the board. + It can be one of the following values: + + * 'fix': The containers has its own intrinsic fixed size. + * 'auto': The container fits the content. fills: |- ``` fills: Fill[] @@ -1456,7 +1483,7 @@ Board: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -1469,10 +1496,10 @@ Board: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -1488,14 +1515,14 @@ Board: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -1881,7 +1908,7 @@ Board: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -1894,7 +1921,9 @@ Board: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -2171,7 +2200,7 @@ VariantContainer: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -2180,10 +2209,10 @@ VariantContainer: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -2199,14 +2228,14 @@ VariantContainer: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -2223,7 +2252,7 @@ VariantContainer: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -2250,7 +2279,7 @@ VariantContainer: * Board + VariantContainer - Referenced by: ContextTypesUtils + Referenced by: Board, Boolean, Context, ContextTypesUtils, Ellipse, Group, Image, Path, Penpot, Rectangle, ShapeBase, SvgRaw, Text, VariantContainer members: Properties: type: |- @@ -2301,12 +2330,20 @@ VariantContainer: ``` The horizontal sizing behavior of the board. + It can be one of the following values: + + * 'fix': The containers has its own intrinsic fixed size. + * 'auto': The container fits the content. verticalSizing: |- ``` verticalSizing?: "auto" | "fix" ``` The vertical sizing behavior of the board. + It can be one of the following values: + + * 'fix': The containers has its own intrinsic fixed size. + * 'auto': The container fits the content. fills: |- ``` fills: Fill[] @@ -2568,7 +2605,7 @@ VariantContainer: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -2581,10 +2618,10 @@ VariantContainer: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -2600,14 +2637,14 @@ VariantContainer: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -2993,7 +3030,7 @@ VariantContainer: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -3006,7 +3043,9 @@ VariantContainer: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -3270,7 +3309,7 @@ Boolean: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -3279,10 +3318,10 @@ Boolean: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -3298,14 +3337,14 @@ Boolean: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -3322,7 +3361,7 @@ Boolean: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -3629,7 +3668,7 @@ Boolean: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -3642,10 +3681,10 @@ Boolean: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -3661,14 +3700,14 @@ Boolean: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -4005,7 +4044,7 @@ Boolean: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -4018,7 +4057,9 @@ Boolean: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -4575,8 +4616,8 @@ CommonLayout: leftPadding: number; horizontalSizing: "fill" | "auto" - | "fit-content"; - verticalSizing: "fill" | "auto" | "fit-content"; + | "fix"; + verticalSizing: "fill" | "auto" | "fix"; remove(): void; } ``` @@ -4706,26 +4747,26 @@ CommonLayout: The `leftPadding` property specifies the padding at the left of the container. horizontalSizing: |- ``` - horizontalSizing: "fill" | "auto" | "fit-content" + horizontalSizing: "fill" | "auto" | "fix" ``` The `horizontalSizing` property specifies the horizontal sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. verticalSizing: |- ``` - verticalSizing: "fill" | "auto" | "fit-content" + verticalSizing: "fill" | "auto" | "fix" ``` The `verticalSizing` property specifies the vertical sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. Methods: remove: |- ``` @@ -4808,6 +4849,7 @@ Context: distributeHorizontal(shapes: Shape[]): void; distributeVertical(shapes: Shape[]): void; flatten(shapes: Shape[]): Path[]; + createVariantFromComponents(shapes: Board[]): VariantContainer; } ``` members: @@ -5449,6 +5491,24 @@ Context: to flatten Returns Path[] + createVariantFromComponents: |- + ``` + createVariantFromComponents(shapes: Board[]): VariantContainer + ``` + + Combine several standard Components into a VariantComponent. Similar to doing it + with the contextual menu on the Penpot interface. + All the shapes passed as arguments should be main instances. + + Parameters + + * shapes: Board[] + + A list of main instances of the components to combine. + + Returns VariantContainer + + The variant container created ContextGeometryUtils: overview: |- Interface ContextGeometryUtils @@ -5850,7 +5910,7 @@ Ellipse: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -5859,10 +5919,10 @@ Ellipse: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -5878,14 +5938,14 @@ Ellipse: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -5902,7 +5962,7 @@ Ellipse: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -6179,7 +6239,7 @@ Ellipse: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -6192,10 +6252,10 @@ Ellipse: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -6211,14 +6271,14 @@ Ellipse: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -6500,7 +6560,7 @@ Ellipse: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -6513,7 +6573,9 @@ Ellipse: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -7244,8 +7306,8 @@ FlexLayout: leftPadding: number; horizontalSizing: "fill" | "auto" - | "fit-content"; - verticalSizing: "fill" | "auto" | "fit-content"; + | "fix"; + verticalSizing: "fill" | "auto" | "fix"; remove(): void; dir: "row" | "row-reverse" | "column" | "column-reverse"; wrap?: "wrap" | "nowrap"; @@ -7379,26 +7441,26 @@ FlexLayout: The `leftPadding` property specifies the padding at the left of the container. horizontalSizing: |- ``` - horizontalSizing: "fill" | "auto" | "fit-content" + horizontalSizing: "fill" | "auto" | "fix" ``` The `horizontalSizing` property specifies the horizontal sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. verticalSizing: |- ``` - verticalSizing: "fill" | "auto" | "fit-content" + verticalSizing: "fill" | "auto" | "fix" ``` The `verticalSizing` property specifies the vertical sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. dir: |- ``` dir: "row" | "row-reverse" | "column" | "column-reverse" @@ -7802,8 +7864,8 @@ GridLayout: leftPadding: number; horizontalSizing: "fill" | "auto" - | "fit-content"; - verticalSizing: "fill" | "auto" | "fit-content"; + | "fix"; + verticalSizing: "fill" | "auto" | "fix"; remove(): void; dir: "row" | "column"; rows: Track[]; @@ -7946,26 +8008,26 @@ GridLayout: The `leftPadding` property specifies the padding at the left of the container. horizontalSizing: |- ``` - horizontalSizing: "fill" | "auto" | "fit-content" + horizontalSizing: "fill" | "auto" | "fix" ``` The `horizontalSizing` property specifies the horizontal sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. verticalSizing: |- ``` - verticalSizing: "fill" | "auto" | "fit-content" + verticalSizing: "fill" | "auto" | "fix" ``` The `verticalSizing` property specifies the vertical sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. dir: |- ``` dir: "row" | "column" @@ -8279,7 +8341,7 @@ Group: | "mixed"; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -8288,10 +8350,10 @@ Group: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -8307,14 +8369,14 @@ Group: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -8331,7 +8393,7 @@ Group: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -8614,7 +8676,7 @@ Group: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -8627,10 +8689,10 @@ Group: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -8646,14 +8708,14 @@ Group: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -9001,7 +9063,7 @@ Group: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -9014,7 +9076,9 @@ Group: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -9523,7 +9587,7 @@ Image: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -9532,10 +9596,10 @@ Image: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -9551,14 +9615,14 @@ Image: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -9575,7 +9639,7 @@ Image: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -9852,7 +9916,7 @@ Image: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -9865,10 +9929,10 @@ Image: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -9884,14 +9948,14 @@ Image: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -10173,7 +10237,7 @@ Image: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -10186,7 +10250,9 @@ Image: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -10444,6 +10510,8 @@ LayoutCellProperties: position?: "area" | "auto" | "manual"; } ``` + + Referenced by: Board, Boolean, Ellipse, Group, Image, Path, Rectangle, ShapeBase, SvgRaw, Text, VariantContainer members: Properties: row: |- @@ -12986,7 +13054,7 @@ Path: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -12995,10 +13063,10 @@ Path: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -13014,14 +13082,14 @@ Path: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -13038,7 +13106,7 @@ Path: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -13339,7 +13407,7 @@ Path: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -13352,10 +13420,10 @@ Path: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -13371,14 +13439,14 @@ Path: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -13674,7 +13742,7 @@ Path: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -13687,7 +13755,9 @@ Path: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -14313,7 +14383,7 @@ Rectangle: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -14322,10 +14392,10 @@ Rectangle: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -14341,14 +14411,14 @@ Rectangle: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -14365,7 +14435,7 @@ Rectangle: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -14644,7 +14714,7 @@ Rectangle: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -14657,10 +14727,10 @@ Rectangle: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -14676,14 +14746,14 @@ Rectangle: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -14965,7 +15035,7 @@ Rectangle: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -14978,7 +15048,9 @@ Rectangle: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -15349,7 +15421,7 @@ ShapeBase: | "mixed"; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -15358,10 +15430,10 @@ ShapeBase: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -15377,14 +15449,14 @@ ShapeBase: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -15401,7 +15473,7 @@ ShapeBase: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -15679,7 +15751,7 @@ ShapeBase: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -15692,10 +15764,10 @@ ShapeBase: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -15711,14 +15783,14 @@ ShapeBase: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -16000,7 +16072,7 @@ ShapeBase: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -16013,7 +16085,9 @@ ShapeBase: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -16273,7 +16347,7 @@ Stroke: strokeColorRefFile?: string; strokeColorRefId?: string; strokeOpacity?: number; - strokeStyle?: "svg" | "none" | "mixed" | "solid" | "dotted" | "dashed"; + strokeStyle?: "none" | "svg" | "mixed" | "solid" | "dotted" | "dashed"; strokeWidth?: number; strokeAlignment?: "center" | "inner" | "outer"; strokeCapStart?: StrokeCap; @@ -16312,7 +16386,7 @@ Stroke: Defaults to 1 if omitted. strokeStyle: |- ``` - strokeStyle?: "svg" | "none" | "mixed" | "solid" | "dotted" | "dashed" + strokeStyle?: "none" | "svg" | "mixed" | "solid" | "dotted" | "dashed" ``` The optional style of the stroke. @@ -16415,7 +16489,7 @@ SvgRaw: | "mixed"; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -16424,10 +16498,10 @@ SvgRaw: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -16443,14 +16517,14 @@ SvgRaw: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -16467,7 +16541,7 @@ SvgRaw: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -16739,7 +16813,7 @@ SvgRaw: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -16752,10 +16826,10 @@ SvgRaw: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -16771,14 +16845,14 @@ SvgRaw: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -17064,7 +17138,7 @@ SvgRaw: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -17077,7 +17151,9 @@ SvgRaw: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -17334,7 +17410,7 @@ Text: | "mixed"; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -17343,10 +17419,10 @@ Text: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -17362,14 +17438,14 @@ Text: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -17386,7 +17462,7 @@ Text: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -17421,6 +17497,7 @@ Text: direction: "mixed" | "ltr" | "rtl" | null; align: "center" | "left" | "right" | "mixed" | "justify" | null; verticalAlign: "center" | "top" | "bottom" | null; + textBounds: { x: number; y: number; width: number; height: number }; getRange(start: number, end: number): TextRange; applyTypography(typography: LibraryTypography): void; } @@ -17675,7 +17752,7 @@ Text: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -17688,10 +17765,10 @@ Text: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -17707,14 +17784,14 @@ Text: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -17835,6 +17912,13 @@ Text: ``` 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: getPluginData: |- ``` @@ -18097,7 +18181,7 @@ Text: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -18110,7 +18194,9 @@ Text: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -22608,7 +22694,11 @@ TokenBorderRadiusProps: ================================= ``` - TokenBorderRadiusProps: "r1" | "r2" | "r3" | "r4" + TokenBorderRadiusProps: + | "borderRadiusTopLeft" + | "borderRadiusTopRight" + | "borderRadiusBottomRight" + | "borderRadiusBottomLeft" ``` The properties that a BorderRadius token can be applied to. @@ -22760,14 +22850,14 @@ TokenSpacingProps: TokenSpacingProps: | "rowGap" | "columnGap" - | "p1" - | "p2" - | "p3" - | "p4" - | "m1" - | "m2" - | "m3" - | "m4" + | "paddingLeft" + | "paddingTop" + | "paddingRight" + | "paddingBottom" + | "marginLeft" + | "marginTop" + | "marginRight" + | "marginBottom" ``` The properties that a Spacing token can be applied to. diff --git a/mcp/packages/server/data/base_instructions.md b/mcp/packages/server/data/base_instructions.md new file mode 100644 index 0000000000..10245a2b45 --- /dev/null +++ b/mcp/packages/server/data/base_instructions.md @@ -0,0 +1,2 @@ +You have access to Penpot tools in order to interact with Penpot designs. +Before working with these tools, be sure to read the 'Penpot High-Level Overview' via the `high_level_overview` tool. diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index 8aa60b1a1b..33ba407d23 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -1,10 +1,6 @@ You have access to Penpot tools in order to interact with a Penpot design project directly. As a precondition, the user must connect the Penpot design project to the MCP server using the Penpot MCP Plugin. -IMPORTANT: When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design. - NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to - non-creative defaults such as white/black if you are lacking information). - # Executing Code One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API @@ -43,18 +39,30 @@ 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. 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. - * `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds. + * `bounds` is READ-ONLY (members: x, y, width, height). To modify the bounding box, change `x`, `y` or apply `resize()`. **Other Writable Properties**: * `name` - Shape name - * `fills`, `strokes` - Styling properties - * `rotation`, `opacity`, `blocked`, `hidden`, `visible` + * `fills: Fill[]`, `strokes: Stroke[]`, `shadows: Shadow[]` - Styling properties + - Setting fills: `shape.fills = [{ fillColor: "#FF0000", fillOpacity: 1 }]`; no fill (transparent): `shape.fills = []`; + - Reusing objects in another shape: `targetShape.fills = sourceShape.fills` or more granular `targetShape.fills = [{ fillOpacity: 1, fillImage: sourceShape.fills[0].fillImage }]` + The objects are not shared references; you can modify properties of the fills in the target shape without affecting the source shape. + - Colors: Use hex strings with caps only (e.g. '#FF5533') + - IMPORTANT: The contents of the arrays are read-only. You cannot modify individual fills/strokes; you need to replace the entire array to change them! + * `borderRadius` - Uniform border radius for all corners + * `borderRadiusTopLeft`, `borderRadiusTopRight`, `borderRadiusBottomRight`, `borderRadiusBottomLeft` - Individual corner radii. + * `blur: Blur` - Blur properties + * `blendMode` - Blend mode (e.g. `"normal"`, `"multiply"`, `"overlay"`, etc.) + * `rotation` (deg), `opacity`, `blocked`, `hidden`, `visible` + * `proportionLock` - Whether width and height are locked to the same ratio + * `constraintsHorizontal` - Horizontal resize constraint (`"left"`, `"right"`, `"center"`, `"leftright"`, `"scale"`) + * `constraintsVertical` - Vertical resize constraint (`"top"`, `"bottom"`, `"center"`, `"topbottom"`, `"scale"`) + * `flipX`, `flipY` - Horizontal/vertical flip **Z-Order**: * 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 (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()`, and, for precise control, `setParentIndex(index)` (0-based). @@ -67,15 +75,15 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image` **Hierarchical Structure**: * `parent` - The parent shape (null for root shapes) Note: Hierarchical nesting does not necessarily imply visual containment - * 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) + * To add children to a parent shape (e.g. a `Board`): `parent.appendChild(shape)` or `parent.insertChild(index, shape)` * Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent - Automatically removes the shape from its old parent - Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position) Cloning: Use `shape.clone(): Shape` to create an exact duplicate (including all properties and children) of a shape; same position as original. +Annotations: Don't add text elements to the design that just repeat a shape's name. In the Penpot UI, the name is displayed anyway. + # Images The `Image` type is a legacy type. Images are now typically embedded in a `Fill`, with `fillImage` set to an @@ -87,10 +95,10 @@ Use the `export_shape` and `import_image` tools to export and import images. Boards can have layout systems that automatically control the positioning and spacing of their children: * If a board has a layout system, then child positions are controlled by the layout system. - For every child, key properties of the child within the layout are stored in `child.layoutChild: LayoutChildProperties`: + After adding a shape to the layout as a child, key properties of the child within the layout are controlled in `child.layoutChild: LayoutChildProperties`: - `absolute: boolean` - if true, child position is not controlled by layout system. x/y will set *relative* position within parent! - margins (`topMargin`, `rightMargin`, `bottomMargin`, `leftMargin` or combined `verticalMargin`, `horizontalMargin`) - - sizing (`verticalSizing`, `horizontalSizing`: "fill" | "auto" | "fix") + - sizing (`verticalSizing`, `horizontalSizing`: "fix" | "auto" | "fill") - controls child resizing depending on the layout's sizing mode (see below) - min/max sizes (`minWidth`, `maxWidth`, `minHeight`, `maxHeight`) - `zIndex: number` (higher numbers on top) @@ -99,18 +107,11 @@ Boards can have layout systems that automatically control the positioning and sp - `dir`: "row" | "column" | "row-reverse" | "column-reverse" - Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding` - To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions. - Optionally, adjust indivudual child margins via `child.layoutChild`. - - When a board has flex layout, - - child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true); - appending or inserting children automatically positions them according to the layout rules. - - CRITICAL: For 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 - they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front - 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". + Optionally, adjust individual child margins via `child.layoutChild`. + - When a board has flex layout, child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true); + appending or inserting children automatically positions them according to the layout rules. + - To append children to a flex layout board such that they appear visually at the end, use the Board's method `board.appendChild(shape)`, i.e. call it in the order of visual appearance. + To insert at a specific index, use `board.insertChild(index, shape)`. - 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, use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children. @@ -122,9 +123,14 @@ Boards can have layout systems that automatically control the positioning and sp Check with: `if (board.grid) { ... }` - Properties: `rows`, `columns`, `rowGap`, `columnGap` - Children are positioned via 1-based row/column indices - - Add to grid via `board.flex.appendChild(shape, row, column)` + - Add to grid via `board.grid.appendChild(shape, row, column)` - Modify grid positioning after the fact via `shape.layoutCell: LayoutCellProperties` + * Auto-sizing: both types of layouts have properties `verticalSizing`, `horizontalSizing`: "fix" | "auto" | "fill" + - `fix` (default): no resizing (size determined by shape's own width/height) + - `auto`: size determined by content (container will resize depending on children's dimensions); ALWAYS set this if you want the container size to adapt to contents/margins/spacings! + - `fill`: resize children to fill the container's size (child resizing is controlled by each child's `layoutChild` properties) + * When working with boards: - ALWAYS check if the board has a layout system before attempting to reposition children - Modify layout properties (gaps, padding) instead of trying to set child x/y positions directly @@ -132,13 +138,23 @@ Boards can have layout systems that automatically control the positioning and sp # Text Elements -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, -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". -`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. +`Text` elements: + * The text to be rendered 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, + 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. + * Property `bounds` is sized automatically (in one dimension) if the `growType` property is set to "auto-width" or "auto-height". + `resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-width" or "auto-height" if you want automatic sizing! + The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box. + * Method `getRange(start, end): TextRange` to reference a range of characters as a `TextRange` object, which can be styled separately from the rest of the text; `start` index inclusive, `end` exclusive + * Other Writable font properties: `fontId`, `fontFamily`, `fontWeight`, `fontVariant`, `fontStyle` + - To discover valid values, check available fonts in `penpot.fonts: FontContext` + - `FontContext` provides `Font` instances; each font has property `variants: FontVariant[]` + - Example: Determine available weights for a font using `penpot.fonts.findByName("Laila").variants.map(v => v.fontWeight)` + - To apply a `Font` to a `Text` instance and set all font properties at once: + - `font.applyToText(text: Text, variant?: FontVariant)` + - `applyToRange(range: TextRange, variant?: FontVariant)` + * Further writable properties: `align`, `verticalAlign`, `lineHeight`, `letterSpacing`, `textTransform`, `textDecoration` (see API info) + * Method `applyTypography(typography: LibraryTypography)` # The `penpot` and `penpotUtils` Objects, Exploring Designs @@ -210,19 +226,6 @@ Common tasks - Quick Reference (ALWAYS use penpotUtils for these): }); Always validate against the root container that is supposed to contain the shapes. -# Visual Inspection of Designs - -For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose! - -# Revising Designs - -* Before applying design changes, ask: "Would a designer consider this appropriate?" -* When dealing with containment issues, ask: Is the parent too small OR is the child too large? - Container sizes are usually intentional, check content first. -* Check for reasonable font sizes and typefaces -* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning. - Consider converting boards to flex layout when appropriate. - # Asset Libraries Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files. @@ -242,31 +245,75 @@ Each `Library` object has: * `colors: LibraryColor[]` - Array of colors * `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: * 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: - const instance: Shape = component.instance(); + `const instance: Shape = component.instance();` This returns a `Shape` (often a `Board` containing child elements). After instantiation, modify the instance's properties as desired. * get the reference to the main component shape: - const mainShape: Shape = component.mainInstance(); + `const mainShape: Shape = component.mainInstance();` -Adding assets to a library: - * const newColor: LibraryColor = penpot.library.local.createColor(); - newColor.name = 'Brand Primary'; - newColor.color = '#0066FF'; - * const newTypo: LibraryTypography = penpot.library.local.createTypography(); - newTypo.name = 'Heading Large'; - // Set typography properties... - * const shapes: Shape[] = [shape1, shape2]; // shapes to include - const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes); - newComponent.name = 'My Button'; +Adding a component to a library: +``` +const shapes: Shape[] = [shape1, shape2]; // shapes to include +const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes); +newComponent.name = 'My Button'; +``` 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. * 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(): void`, `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**: +- `penpot.createVariantFromComponents(mainInstances: Board[]): VariantContainer`: Combines several main component instances into a new variant group. + All components end up inside a single new container on the canvas. + The container's `Variants` instance is initialised with one property `Property 1`, with the property values set to the respective component's name. +- After creation, 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 are reusable design values (colors, dimensions, typography, etc.) for consistent styling. @@ -274,21 +321,22 @@ Design tokens are reusable design values (colors, dimensions, typography, etc.) The token library: `penpot.library.local.tokens` (type: `TokenCatalog`) * `sets: TokenSet[]` - Token collections (order matters for precedence) * `themes: TokenTheme[]` - Presets that activate specific sets - * `addSet(name: string): TokenSet` - Create new set + * `addSet({name: string}): TokenSet` - Create new set * `addTheme(group: string, name: string): TokenTheme` - Create new theme `TokenSet` contains tokens with unique names: * `active: boolean` - Only active sets affect shapes; use `set.toggleActive()` to change: `if (!set.active) set.toggleActive();` * `tokens: Token[]` - All tokens in set - * `addToken(type: TokenType, name: string, value: TokenValueString): Token` - Creates a token, adding it to the set. + * `addToken({type: TokenType, name: string, value: TokenValueString}): Token` - Creates a token, adding it to the set. - `TokenType`: "color" | "dimension" | "spacing" | "typography" | "shadow" | "opacity" | "borderRadius" | "borderWidth" | "fontWeights" | "fontSizes" | "fontFamilies" | "letterSpacing" | "textDecoration" | "textCase" + - `value`: depends on the type of token (inspect `Token` and related types) - Examples: - const token = set.addToken("color", "color.primary", "#0066FF"); // direct value - const token2 = set.addToken("color", "color.accent", "{color.primary}"); // reference to another token + const token = set.addToken({type: "color", name: "color.primary", value: "#0066FF"}); // direct value + const token2 = set.addToken({type: "color", name: "color.accent", value: "{color.primary}"}); // reference to another token -`Token`: - * `name: string` - Token name (may include group path like "color.base.white") - * `value: string | TokenValueString` - Raw value (may be direct value or reference to another token like "{color.primary}") +`Token`: union type encompassing various token types, with common properties: + * `name: string` - Token name (typically structured, e.g. "color.base.white") + * `value` - Raw value (direct value or reference to another token like "{color.primary}") * `resolvedValue` - Computed final value (follows references) * `type: TokenType` @@ -303,21 +351,21 @@ Applying tokens: (if properties is undefined, use a default property based on the token type - not usually recommended). `TokenProperty` is a union type; possible values are: - "all": applies the token to all properties it can control - - TokenBorderRadiusProps: "r1", "r2", "r3", "r4" + - TokenBorderRadiusProps: "borderRadiusTopLeft", "borderRadiusTopRight", "borderRadiusBottomRight", "borderRadiusBottomLeft" - TokenShadowProps: "shadow" - - TokenColorProps: "fill", "stroke-color" - - TokenDimensionProps: "x", "y", "stroke-width" - - TokenFontFamiliesProps: "font-families" - - TokenFontSizesProps: "font-size" - - TokenFontWeightProps: "font-weight" - - TokenLetterSpacingProps: "letter-spacing" - - TokenNumberProps: "rotation", "line-height" + - TokenColorProps: "fill", "strokeColor" + - TokenDimensionProps: "x", "y", "strokeWidth" + - TokenFontFamiliesProps: "fontFamilies" + - TokenFontSizesProps: "fontSize" + - TokenFontWeightProps: "fontWeight" + - TokenLetterSpacingProps: "letterSpacing" + - TokenNumberProps: "rotation" - TokenOpacityProps: "opacity" - - TokenSizingProps: "width", "height", "layout-item-min-w", "layout-item-max-w", "layout-item-min-h", "layout-item-max-h" - - TokenSpacingProps: "row-gap", "column-gap", "p1", "p2", "p3", "p4", "m1", "m2", "m3", "m4" - - TokenBorderWidthProps: "stroke-width" - - TokenTextCaseProps: "text-case" - - TokenTextDecorationProps: "text-decoration" + - TokenSizingProps: "width", "height", "layoutItemMinW", "layoutItemMaxW", "layoutItemMinH", "layoutItemMaxH" + - TokenSpacingProps: "rowGap", "columnGap", "paddingLeft", "paddingTop", "paddingRight", "paddingBottom", "marginLeft", "marginTop", "marginRight", "marginBottom" + - TokenBorderWidthProps: "strokeWidth" + - TokenTextCaseProps: "textCase" + - TokenTextDecorationProps: "textDecoration" - TokenTypographyProps: "typography" * `token.applyToShapes(shapes, properties)` - Apply from token * Application is **asynchronous** (wait for ~100ms to see the effects) @@ -326,8 +374,27 @@ Applying tokens: - The actual shape properties that the tokens control will reflect the token's resolved value. Removing tokens: - Simply set the respective property directly - token binding is automatically removed, e.g. + Simply set the respective property directly - token binding is automatically removed, e.g. shape.fills = [{ fillColor: "#000000", fillOpacity: 1 }]; // Removes fill token +# Visual Inspection of Designs + +For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose! + +# Creating and Translating Designs + +* When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design. + NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to + non-creative defaults such as white/black if you are lacking information). + +# Revising Designs + +* Before applying design changes, ask: "Would a designer consider this appropriate?" +* When dealing with containment issues, ask: Is the parent too small OR is the child too large? + Container sizes are usually intentional, check content first. +* Check for reasonable font sizes and typefaces +* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning. + Consider converting boards to flex layout when appropriate. + -- You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again. diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index 09e47a4dce..922be86975 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -6,8 +6,7 @@ "main": "dist/index.js", "scripts": { "build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:js-yaml --external:sharp", - "build": "pnpm run build:server && cp -r src/static dist/static && cp -r data dist/data", - "build:multi-user": "pnpm run build", + "build": "pnpm run build:server && node scripts/copy-resources.js", "build:types": "tsc --emitDeclarationOnly --outDir dist", "start": "node dist/index.js", "start:multi-user": "node dist/index.js --multi-user", @@ -23,7 +22,7 @@ ], "author": "", "license": "MIT", - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", + "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "dependencies": { "@modelcontextprotocol/sdk": "^1.24.0", "class-transformer": "^0.5.1", @@ -39,6 +38,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "cross-env": "^7.0.3", "@penpot/mcp-common": "workspace:../common", "@types/express": "^4.17.0", "@types/js-yaml": "^4.0.9", diff --git a/mcp/packages/server/scripts/copy-resources.js b/mcp/packages/server/scripts/copy-resources.js new file mode 100644 index 0000000000..08b3604f37 --- /dev/null +++ b/mcp/packages/server/scripts/copy-resources.js @@ -0,0 +1,5 @@ +import { cpSync } from "fs"; + +// copy static assets and data to dist +cpSync("src/static", "dist/static", { recursive: true }); +cpSync("data", "dist/data", { recursive: true }); diff --git a/mcp/packages/server/src/ConfigurationLoader.ts b/mcp/packages/server/src/ConfigurationLoader.ts index 390522ff24..2b4b11288e 100644 --- a/mcp/packages/server/src/ConfigurationLoader.ts +++ b/mcp/packages/server/src/ConfigurationLoader.ts @@ -4,15 +4,12 @@ import { createLogger } from "./logger.js"; /** * Configuration loader for prompts and server settings. - * - * Handles loading and parsing of YAML configuration files, - * providing type-safe access to configuration values with - * appropriate fallbacks for missing files or values. */ export class ConfigurationLoader { private readonly logger = createLogger("ConfigurationLoader"); private readonly baseDir: string; - private initialInstructions: string; + private readonly initialInstructions: string; + private readonly baseInstructions: string; /** * Creates a new configuration loader instance. @@ -22,6 +19,7 @@ export class ConfigurationLoader { constructor(baseDir: string) { this.baseDir = baseDir; this.initialInstructions = this.loadFileContent(join(this.baseDir, "data", "initial_instructions.md")); + this.baseInstructions = this.loadFileContent(join(this.baseDir, "data", "base_instructions.md")); } private loadFileContent(filePath: string): string { @@ -32,11 +30,22 @@ export class ConfigurationLoader { } /** - * Gets the initial instructions for the MCP server. + * Gets the initial instructions for the MCP server corresponding to the + * 'Penpot High-Level Overview' * - * @returns The initial instructions string, or undefined if not configured + * @returns The initial instructions string */ public getInitialInstructions(): string { return this.initialInstructions; } + + /** + * Gets the base instructions which shall be provided to clients when connecting to + * the MCP server + * + * @returns The initial instructions string + */ + public getBaseInstructions(): string { + return this.baseInstructions; + } } diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 3aa741dd83..ae95724a09 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -21,34 +21,61 @@ export interface SessionContext { userToken?: string; } +/** + * Represents an active Streamable HTTP session, grouping the transport, MCP server, and session metadata. + */ +class StreamableSession { + constructor( + public readonly transport: StreamableHTTPServerTransport, + public readonly userToken: string | undefined, + public lastActiveTime: number + ) {} +} + +/** + * Holds information about a registered tool, including its instance, name, and configuration. + */ +class ToolInfo { + constructor( + public readonly instance: Tool, + public readonly name: string, + public readonly config: { description: string; inputSchema: any } + ) {} +} + export class PenpotMcpServer { + /** + * Timeout, in minutes, for idle Streamable HTTP sessions before they are automatically closed and removed. + */ + private static readonly SESSION_TIMEOUT_MINUTES = 60; + private readonly logger = createLogger("PenpotMcpServer"); - private readonly server: McpServer; - private readonly tools: Map>; + private readonly tools: ToolInfo[]; public readonly configLoader: ConfigurationLoader; private app: any; public readonly pluginBridge: PluginBridge; private readonly replServer: ReplServer; private apiDocs: ApiDocs; + private readonly penpotHighLevelOverview: string; + private readonly connectionInstructions: string; /** * Manages session-specific context, particularly user tokens for each request. */ private readonly sessionContext = new AsyncLocalStorage(); - private readonly transports = { - streamable: {} as Record, - sse: {} as Record, - }; + private readonly streamableTransports: Record = {}; + private readonly sseTransports: Record = {}; public readonly host: string; public readonly port: number; public readonly webSocketPort: number; public readonly replPort: number; + private sessionTimeoutInterval: ReturnType | undefined; constructor(private isMultiUser: boolean = false) { // read port configuration from environment variables - this.host = process.env.PENPOT_MCP_SERVER_HOST ?? "0.0.0.0"; + this.host = process.env.PENPOT_MCP_SERVER_HOST ?? "localhost"; this.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10); this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10); this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10); @@ -56,21 +83,16 @@ export class PenpotMcpServer { this.configLoader = new ConfigurationLoader(process.cwd()); this.apiDocs = new ApiDocs(); - this.server = new McpServer( - { - name: "penpot-mcp-server", - version: "1.0.0", - }, - { - instructions: this.getInitialInstructions(), - } - ); + // prepare instructions + let instructions = this.configLoader.getInitialInstructions(); + instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", ")); + this.penpotHighLevelOverview = instructions; + this.connectionInstructions = this.configLoader.getBaseInstructions(); + + this.tools = this.initTools(); - this.tools = new Map>(); this.pluginBridge = new PluginBridge(this, this.webSocketPort); this.replServer = new ReplServer(this.pluginBridge, this.replPort); - - this.registerTools(); } /** @@ -104,10 +126,11 @@ export class PenpotMcpServer { return !this.isRemoteMode(); } - public getInitialInstructions(): string { - let instructions = this.configLoader.getInitialInstructions(); - instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", ")); - return instructions; + /** + * Retrieves the high-level overview instructions explaining core Penpot usage. + */ + public getHighLevelOverviewInstructions(): string { + return this.penpotHighLevelOverview; } /** @@ -119,88 +142,134 @@ export class PenpotMcpServer { return this.sessionContext.getStore(); } - private registerTools(): void { - // Create relevant tool instances (depending on file system access) + private initTools(): ToolInfo[] { const toolInstances: Tool[] = [ new ExecuteCodeTool(this), new HighLevelOverviewTool(this), new PenpotApiInfoTool(this, this.apiDocs), - new ExportShapeTool(this), // tool adapts to file system access internally + new ExportShapeTool(this), ]; if (this.isFileSystemAccessEnabled()) { toolInstances.push(new ImportImageTool(this)); } - for (const tool of toolInstances) { - const toolName = tool.getToolName(); - this.tools.set(toolName, tool); + return toolInstances.map((instance) => { + this.logger.info(`Registering tool: ${instance.getToolName()}`); + return new ToolInfo(instance, instance.getToolName(), { + description: instance.getToolDescription(), + inputSchema: instance.getInputSchema(), + }); + }); + } - // Register each tool with McpServer - this.logger.info(`Registering tool: ${toolName}`); - this.server.registerTool( - toolName, - { - description: tool.getToolDescription(), - inputSchema: tool.getInputSchema(), - }, - async (args) => { - return tool.execute(args); - } - ); + /** + * Creates a fresh {@link McpServer} instance with all tools registered. + */ + private createMcpServer(): McpServer { + const server = new McpServer( + { name: "penpot", version: "1.0.0" }, + { instructions: this.connectionInstructions } + ); + + for (const tool of this.tools) { + server.registerTool(tool.name, tool.config, async (args: any) => tool.instance.execute(args)); } + + return server; + } + + /** + * Starts a periodic timer that closes and removes Streamable HTTP sessions that have been + * idle for longer than {@link SESSION_TIMEOUT_MINUTES}. + */ + private startSessionTimeoutChecker(): void { + const timeoutMs = PenpotMcpServer.SESSION_TIMEOUT_MINUTES * 60 * 1000; + const checkIntervalMs = timeoutMs / 2; + this.sessionTimeoutInterval = setInterval(() => { + this.logger.info("Checking for stale sessions..."); + const now = Date.now(); + let removed = 0; + for (const session of Object.values(this.streamableTransports)) { + if (now - session.lastActiveTime > timeoutMs) { + session.transport.close(); + removed++; + } + } + this.logger.info( + `Removed ${removed} stale session(s); total sessions remaining: ${Object.keys(this.streamableTransports).length}` + ); + }, checkIntervalMs); } private setupHttpEndpoints(): void { /** - * Modern Streamable HTTP connection endpoint + * Modern Streamable HTTP connection endpoint. + * + * New sessions are created on initialize requests (no mcp-session-id header). + * Subsequent requests for an existing session are routed to the stored transport, + * with the session context populated from the stored userToken. */ this.app.all("/mcp", async (req: any, res: any) => { - const userToken = req.query.userToken as string | undefined; + const sessionId = req.headers["mcp-session-id"] as string | undefined; + let userToken: string | undefined = undefined; + let transport: StreamableHTTPServerTransport; - await this.sessionContext.run({ userToken }, async () => { + // obtain transport and user token for the session, either from an existing session or by creating a new one + if (sessionId && this.streamableTransports[sessionId]) { + // existing session: reuse stored transport and token + const session = this.streamableTransports[sessionId]; + transport = session.transport; + userToken = session.userToken; + session.lastActiveTime = Date.now(); + this.logger.info( + `Received request for existing session with id=${sessionId}; userToken=${session.userToken}` + ); + } else { + // new session: create a fresh McpServer and transport + userToken = req.query.userToken as string | undefined; + this.logger.info(`Received new session request; userToken=${userToken}`); const { randomUUID } = await import("node:crypto"); + const server = this.createMcpServer(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + this.streamableTransports[id] = new StreamableSession(transport, userToken, Date.now()); + this.logger.info( + `Session initialized with id=${id} for userToken=${userToken}; total sessions: ${Object.keys(this.streamableTransports).length}` + ); + }, + }); + transport.onclose = () => { + if (transport.sessionId) { + this.logger.info(`Closing session with id=${transport.sessionId} for userToken=${userToken}`); + delete this.streamableTransports[transport.sessionId]; + } + }; + await server.connect(transport); + } - const sessionId = req.headers["mcp-session-id"] as string | undefined; - let transport: StreamableHTTPServerTransport; - - 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); - } - + // handle the request + await this.sessionContext.run({ userToken }, async () => { await transport.handleRequest(req, res, req.body); }); }); /** - * Legacy SSE connection endpoint + * Legacy SSE connection endpoint. */ this.app.get("/sse", async (req: any, res: any) => { const userToken = req.query.userToken as string | undefined; await this.sessionContext.run({ userToken }, async () => { const transport = new SSEServerTransport("/messages", res); - this.transports.sse[transport.sessionId] = { transport, userToken }; + this.sseTransports[transport.sessionId] = { transport, userToken }; + const server = this.createMcpServer(); + await server.connect(transport); res.on("close", () => { - delete this.transports.sse[transport.sessionId]; + delete this.sseTransports[transport.sessionId]; + server.close(); }); - - await this.server.connect(transport); }); }); @@ -209,7 +278,7 @@ export class PenpotMcpServer { */ this.app.post("/messages", async (req: any, res: any) => { const sessionId = req.query.sessionId as string; - const session = this.transports.sse[sessionId]; + const session = this.sseTransports[sessionId]; if (session) { await this.sessionContext.run({ userToken: session.userToken }, async () => { @@ -236,8 +305,9 @@ export class PenpotMcpServer { this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`); this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`); - // start the REPL server + // start the REPL server and session timeout checker await this.replServer.start(); + this.startSessionTimeoutChecker(); resolve(); }); @@ -251,6 +321,7 @@ export class PenpotMcpServer { */ public async stop(): Promise { this.logger.info("Stopping Penpot MCP Server..."); + clearInterval(this.sessionTimeoutInterval); await this.replServer.stop(); this.logger.info("Penpot MCP Server stopped"); } diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 413ec2fa70..5147d361fd 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -5,6 +5,8 @@ import { PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common"; import { createLogger } from "./logger"; import type { PenpotMcpServer } from "./PenpotMcpServer"; +const KEEP_ALIVE_TIME = 30000; // 30 seconds + interface ClientConnection { socket: WebSocket; userToken: string | null; @@ -38,6 +40,8 @@ export class PluginBridge { * channel between the MCP mcpServer and Penpot plugin instances. */ private setupWebSocketHandlers(): void { + let interval: NodeJS.Timeout | undefined; + this.wsServer.on("connection", (ws: WebSocket, request: http.IncomingMessage) => { // extract userToken from query parameters const url = new URL(request.url!, `ws://${request.headers.host}`); @@ -64,6 +68,7 @@ export class PluginBridge { if (this.clientsByToken.has(userToken)) { this.logger.warn("Duplicate connection for given user token; rejecting new connection"); ws.close(1008, "Duplicate connection for given user token; close previous connection first."); + return; } this.clientsByToken.set(userToken, connection); @@ -86,6 +91,9 @@ export class PluginBridge { if (connection?.userToken) { this.clientsByToken.delete(connection.userToken); } + if (interval) { + clearInterval(interval); + } }); ws.on("error", (error) => { @@ -95,7 +103,14 @@ export class PluginBridge { if (connection?.userToken) { this.clientsByToken.delete(connection.userToken); } + if (interval) { + clearInterval(interval); + } }); + + interval = setInterval(() => { + ws?.ping(); + }, KEEP_ALIVE_TIME); }); this.logger.info("WebSocket mcpServer started on port %d", this.port); diff --git a/mcp/packages/server/src/Tool.ts b/mcp/packages/server/src/Tool.ts index 65cfe539bd..df4e1f2266 100644 --- a/mcp/packages/server/src/Tool.ts +++ b/mcp/packages/server/src/Tool.ts @@ -22,6 +22,9 @@ export class EmptyToolArgs { export abstract class Tool { private readonly logger = createLogger("Tool"); + /** monotonically increasing counter for unique tool execution IDs */ + private static executionCounter = 0; + protected constructor( protected mcpServer: PenpotMcpServer, private inputSchema: z.ZodRawShape @@ -34,17 +37,21 @@ export abstract class Tool { * delegating to the type-safe implementation. */ async execute(args: unknown): Promise { + const executionId = ++Tool.executionCounter; try { let argsInstance: TArgs = args as TArgs; - this.logger.info("Executing tool: %s; arguments: %s", this.getToolName(), this.formatArgs(argsInstance)); + this.logger.info("Tool execution #%d starting: %s", executionId, this.getToolName()); + if (this.logger.isLevelEnabled("debug")) { + this.logger.debug("Tool execution #%d arguments: %s", executionId, this.formatArgs(argsInstance)); + } // execute the actual tool logic let result = await this.executeCore(argsInstance); - this.logger.info("Tool execution completed: %s", this.getToolName()); + this.logger.info("Tool execution #%d complete: %s", executionId, this.getToolName()); return result; } catch (error) { - this.logger.error(error); + this.logger.error("Tool execution #%d failed: %s; error: %s", executionId, this.getToolName(), error); return new TextResponse(`Tool execution failed: ${String(error)}`); } } diff --git a/mcp/packages/server/src/tools/ExecuteCodeTool.ts b/mcp/packages/server/src/tools/ExecuteCodeTool.ts index adcb6339fa..6a514e0cf4 100644 --- a/mcp/packages/server/src/tools/ExecuteCodeTool.ts +++ b/mcp/packages/server/src/tools/ExecuteCodeTool.ts @@ -53,9 +53,10 @@ export class ExecuteCodeTool extends Tool { "could come in handy later should be stored in `storage` instead of just a fleeting variable; " + "you can also store functions and thus build up a library).\n" + "Think of the code being executed as the body of a function: " + - "The tool call returns whatever you return in the applicable `return` statement, if any.\n" + + "The tool call returns whatever you return in the applicable `return` statement, if any. " + + "You can return arbitrary JS objects; no need to apply JSON.stringify.\n" + "If an exception occurs, the exception's message will be returned to you.\n" + - "Any output that you generate via the `console` object will be returned to you separately; so you may use it" + + "Any output that you generate via the `console` object will be returned to you separately; so you may use it " + "to track what your code is doing, but you should *only* do so only if there is an ACTUAL NEED for this! " + "VERY IMPORTANT: Don't use logging prematurely! NEVER log the data you are returning, as you will otherwise receive it twice!\n" + "VERY IMPORTANT: In general, try a simple approach first, and only if it fails, try more complex code that involves " + diff --git a/mcp/packages/server/src/tools/ExportShapeTool.ts b/mcp/packages/server/src/tools/ExportShapeTool.ts index d032b0b770..7b8ceed4a6 100644 --- a/mcp/packages/server/src/tools/ExportShapeTool.ts +++ b/mcp/packages/server/src/tools/ExportShapeTool.ts @@ -16,8 +16,8 @@ export class ExportShapeArgs { .string() .min(1, "shapeId cannot be empty") .describe( - "Identifier of the shape to export. Use the special identifier 'selection' to " + - "export the first shape currently selected by the user." + "Identifier of the shape to export. " + + "Special identifiers you can use: 'selection' (first shape currently selected by the user), 'page' (entire current page)" ), format: z.enum(["svg", "png"]).default("png").describe("The output format, either 'png' (default) or 'svg'."), mode: z @@ -71,7 +71,7 @@ export class ExportShapeTool extends Tool { public getToolDescription(): string { let description = "Exports a shape (or a shape's image fill) from the Penpot design to a PNG or SVG image, " + - "such that you can get an impression of what it looks like. "; + "such that you can get an impression of what it looks like."; if (this.mcpServer.isFileSystemAccessEnabled()) { description += "\nAlternatively, you can save it to a file."; } @@ -88,6 +88,8 @@ export class ExportShapeTool extends Tool { let shapeCode: string; if (args.shapeId === "selection") { shapeCode = `penpot.selection[0]`; + } else if (args.shapeId === "page") { + shapeCode = `penpot.root`; } else { shapeCode = `penpotUtils.findShapeById("${args.shapeId}")`; } diff --git a/mcp/packages/server/src/tools/HighLevelOverviewTool.ts b/mcp/packages/server/src/tools/HighLevelOverviewTool.ts index ada8829771..b16edcb68b 100644 --- a/mcp/packages/server/src/tools/HighLevelOverviewTool.ts +++ b/mcp/packages/server/src/tools/HighLevelOverviewTool.ts @@ -21,6 +21,6 @@ export class HighLevelOverviewTool extends Tool { } protected async executeCore(args: EmptyToolArgs): Promise { - return new TextResponse(this.mcpServer.getInitialInstructions()); + return new TextResponse(this.mcpServer.getHighLevelOverviewInstructions()); } } diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 15088c9791..c90b714cc5 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: '@types/ws': specifier: ^8.5.10 version: 8.18.1 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 esbuild: specifier: ^0.25.0 version: 0.25.12 diff --git a/mcp/scripts/build b/mcp/scripts/build index 41af7a4684..09523164fe 100755 --- a/mcp/scripts/build +++ b/mcp/scripts/build @@ -25,12 +25,12 @@ set -e popd pnpm -r --filter "!mcp-plugin" install; -pnpm -r --filter "mcp-server" run build:multi-user; +pnpm -r --filter "mcp-server" run build; rsync -avr packages/server/dist/ ./dist/; cp packages/server/package.json ./dist/; -cp packages/server/pnpm-lock.yaml ./dist/; +cp pnpm-lock.yaml ./dist/; touch ./dist/pnpm-workspace.yaml; diff --git a/mcp/scripts/pack b/mcp/scripts/pack new file mode 100644 index 0000000000..16a869cf00 --- /dev/null +++ b/mcp/scripts/pack @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "$0")/.." + +# pnpm-lock.yaml is hard-excluded by npm, but we need it; ship it under a neutral name +cp pnpm-lock.yaml pnpm-lock.dist.yaml +trap 'rm -f pnpm-lock.dist.yaml' EXIT + +npm pack diff --git a/mcp/scripts/set-version b/mcp/scripts/set-version new file mode 100644 index 0000000000..170aa6267d --- /dev/null +++ b/mcp/scripts/set-version @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Derives a valid npm semver version from the Git tag produced by +# `git describe` and writes it into the root package.json. +# +# Examples of the conversion: +# 2.14.0 -> 2.14.0 +# 2.14.0-RC1 -> 2.14.0-rc.1 +# 2.14.0-RC1-140-g9f2ca9965 -> 2.14.0-rc.1.140 +# 2.14.0-140-g9f2ca9965 -> 2.14.1-dev.140 +# +# The last case (commits after a release tag) bumps the patch level so +# the resulting semver sorts higher than the release. + +set -euo pipefail + +raw=$(git describe --tags --match "*.*.*") + +# Parse: ..[-